Skip to content

Unauthenticated RCE: server binds all interfaces and /api/v1 has no auth #1124

Description

@ben-vargas

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:

  1. 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.
  2. 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.
  3. 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

  1. Start bb normally (npx bb-app) on a shared network. lsof -nP -iTCP:38886 -sTCP:LISTEN shows *:38886, not 127.0.0.1:38886.
  2. From a second machine: curl http://<laptop-lan-ip>:38886/health returns {"ok":true}.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions