Summary
The bb server binds every network interface, and its public /api/v1 surface has no authentication. On a machine joined to a network the owner doesn't control (coffee shop, coworking, hotel wifi), any peer on that network can reach the full API — including POST /api/v1/terminals, which runs shell commands on the host. That's unauthenticated RCE by anyone on the same network. I've verified the full chain end to end against a stock instance.
apps/server/src/start-server.ts creates the listener with no hostname:
const server = serve({
port: serverConfig.BB_SERVER_PORT,
fetch: app.fetch,
});
@hono/node-server passes the missing hostname through to server.listen(), so Node binds ::/0.0.0.0, and injectWebSocket(server) puts /ws and the terminal WebSockets on that same socket. No env var, flag, or config key restricts it. The host daemon local API (127.0.0.1), the machine-auth proxy (127.0.0.1), and the ACP tool bridge all bind loopback already — the main server just never got a bind host.
Impact
Auth middleware in apps/server/src/server.ts covers only /internal/*. /api/v1 gets request-timing logging and a dev-only gate, then goes straight to routes — no session, token, or credential check. browserRequestProblem isn't a substitute: it 403s only when an Origin header is present and untrusted, so a non-browser client that omits Origin passes, and only a handful of routes consult it anyway.
The exploit chain is three unauthenticated requests:
GET /api/v1/hosts — returns enrolled hosts; take the first id (host_...). This is the only value an attacker needs, and it's handed over without a credential.
POST /api/v1/terminals with start: { "mode": "command", "command": "<shell>" } and target: { "kind": "host_path", "hostId": "host_...", "cwd": null } — the daemon runs it via ['-lc', command] through node-pty (apps/host-daemon/src/terminals/terminal-manager.ts), as the daemon user, with no approval step.
GET /api/v1/terminals/:id/output — streams output back (base64 in chunks[].dataBase64), so it isn't blind execution.
Against a stock instance, id > /tmp/x; hostname >> /tmp/x writes, as the running user:
uid=501(user) gid=20(staff) groups=20(staff),12(everyone),80(admin),...
victim-laptop
GET /api/v1/files/read (arbitrary absolute path when rootPath is omitted) is a milder path to the same data.
Exposure is not limited to network adjacency. Any host with a routable address and no host firewall — a cloud VM, a VPS, a machine on a direct connection — is exploitable from the public internet, and the :: bind includes IPv6, so a home machine with a globally routable IPv6 prefix (no NAT; only the ISP router's stateful firewall, which is inconsistent, in the way) can be internet-reachable too. Every joined network counts as well: untrusted LANs, corporate VPNs, and tailnets. Scored without crediting external mitigations this is CVSS 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) — unauthenticated code execution as the daemon user.
Until patched, users can self-mitigate with a host firewall rule blocking inbound TCP 38886, or by not running bb on untrusted networks.
Reproduction
- Start bb normally (
npx bb-app) on a shared network. lsof -nP -iTCP:38886 -sTCP:LISTEN shows *:38886, not 127.0.0.1:38886.
- From a second machine:
curl http://<laptop-lan-ip>:38886/health returns {"ok":true}.
- Run the three-request chain against
http://<laptop-lan-ip>:38886; the command executes on the first laptop.
(I verified against 127.0.0.1 on the affected build; on an untrusted LAN the only difference is substituting the victim's LAN address, which the default bind makes reachable.)
Fix direction
Give the main server a bind host that defaults to loopback, matching the host daemon. A single listener is enough — it composes with the remote-access paths bb already ships: tailscale serve --bg --https=443 http://127.0.0.1:38886 in front of loopback (already recommended in docs/multiple-devices.md), or the authenticated bb connect tunnel.
This is a behavior change, not a pure bug fix: #81 established that reaching bb off-loopback is intentional. Remote daemons and browsers pointed at a direct LAN/tailnet URL would need to migrate to Tailscale Serve or bb connect, or explicitly opt back in. Upgrade ordering matters — once the server binds loopback, a remote daemon that can't reach it can't self-update either, so the migration has to be documented as something to do before the first upgraded start.
Two adjacent pre-existing issues I noticed but didn't touch:
- A persisted
env.json value beats an explicit launcher flag in createServerBaseEnv, contradicting the precedence in docs/configuration.md (affects BB_SERVER_PORT, BB_HOST_DAEMON_PORT).
- Behind Tailscale Serve, the
assertLoopbackRequest gate on /internal/hosts/enroll-key sees 127.0.0.1 for every peer, so it no longer distinguishes the server machine. No escalation beyond what the unauthenticated API already allows.
I have a working implementation on a branch — BB_SERVER_BIND_HOST defaulting to 127.0.0.1 with a --server-bind-host opt-out flag, validated at the config boundary, plus docs migration and tests. Happy to open a PR, or reshape it if you'd rather handle it differently.
Separately: enabling GitHub private vulnerability reporting (or adding a SECURITY.md) would give future reports a non-public path — this one is public only because there's no other channel.
Summary
The bb server binds every network interface, and its public
/api/v1surface has no authentication. On a machine joined to a network the owner doesn't control (coffee shop, coworking, hotel wifi), any peer on that network can reach the full API — includingPOST /api/v1/terminals, which runs shell commands on the host. That's unauthenticated RCE by anyone on the same network. I've verified the full chain end to end against a stock instance.apps/server/src/start-server.tscreates the listener with nohostname:@hono/node-serverpasses the missing hostname through toserver.listen(), so Node binds::/0.0.0.0, andinjectWebSocket(server)puts/wsand the terminal WebSockets on that same socket. No env var, flag, or config key restricts it. The host daemon local API (127.0.0.1), the machine-auth proxy (127.0.0.1), and the ACP tool bridge all bind loopback already — the main server just never got a bind host.Impact
Auth middleware in
apps/server/src/server.tscovers only/internal/*./api/v1gets request-timing logging and a dev-only gate, then goes straight to routes — no session, token, or credential check.browserRequestProblemisn't a substitute: it 403s only when anOriginheader is present and untrusted, so a non-browser client that omitsOriginpasses, and only a handful of routes consult it anyway.The exploit chain is three unauthenticated requests:
GET /api/v1/hosts— returns enrolled hosts; take the firstid(host_...). This is the only value an attacker needs, and it's handed over without a credential.POST /api/v1/terminalswithstart: { "mode": "command", "command": "<shell>" }andtarget: { "kind": "host_path", "hostId": "host_...", "cwd": null }— the daemon runs it via['-lc', command]through node-pty (apps/host-daemon/src/terminals/terminal-manager.ts), as the daemon user, with no approval step.GET /api/v1/terminals/:id/output— streams output back (base64 inchunks[].dataBase64), so it isn't blind execution.Against a stock instance,
id > /tmp/x; hostname >> /tmp/xwrites, as the running user:GET /api/v1/files/read(arbitrary absolute path whenrootPathis omitted) is a milder path to the same data.Exposure is not limited to network adjacency. Any host with a routable address and no host firewall — a cloud VM, a VPS, a machine on a direct connection — is exploitable from the public internet, and the
::bind includes IPv6, so a home machine with a globally routable IPv6 prefix (no NAT; only the ISP router's stateful firewall, which is inconsistent, in the way) can be internet-reachable too. Every joined network counts as well: untrusted LANs, corporate VPNs, and tailnets. Scored without crediting external mitigations this is CVSS 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) — unauthenticated code execution as the daemon user.Until patched, users can self-mitigate with a host firewall rule blocking inbound TCP 38886, or by not running bb on untrusted networks.
Reproduction
npx bb-app) on a shared network.lsof -nP -iTCP:38886 -sTCP:LISTENshows*:38886, not127.0.0.1:38886.curl http://<laptop-lan-ip>:38886/healthreturns{"ok":true}.http://<laptop-lan-ip>:38886; the command executes on the first laptop.(I verified against
127.0.0.1on the affected build; on an untrusted LAN the only difference is substituting the victim's LAN address, which the default bind makes reachable.)Fix direction
Give the main server a bind host that defaults to loopback, matching the host daemon. A single listener is enough — it composes with the remote-access paths bb already ships:
tailscale serve --bg --https=443 http://127.0.0.1:38886in front of loopback (already recommended indocs/multiple-devices.md), or the authenticated bb connect tunnel.This is a behavior change, not a pure bug fix: #81 established that reaching bb off-loopback is intentional. Remote daemons and browsers pointed at a direct LAN/tailnet URL would need to migrate to Tailscale Serve or bb connect, or explicitly opt back in. Upgrade ordering matters — once the server binds loopback, a remote daemon that can't reach it can't self-update either, so the migration has to be documented as something to do before the first upgraded start.
Two adjacent pre-existing issues I noticed but didn't touch:
env.jsonvalue beats an explicit launcher flag increateServerBaseEnv, contradicting the precedence indocs/configuration.md(affectsBB_SERVER_PORT,BB_HOST_DAEMON_PORT).assertLoopbackRequestgate on/internal/hosts/enroll-keysees127.0.0.1for every peer, so it no longer distinguishes the server machine. No escalation beyond what the unauthenticated API already allows.I have a working implementation on a branch —
BB_SERVER_BIND_HOSTdefaulting to127.0.0.1with a--server-bind-hostopt-out flag, validated at the config boundary, plus docs migration and tests. Happy to open a PR, or reshape it if you'd rather handle it differently.Separately: enabling GitHub private vulnerability reporting (or adding a
SECURITY.md) would give future reports a non-public path — this one is public only because there's no other channel.