Skip to content

fix(observability): bind Bun server to 127.0.0.1 to prevent LAN exposure#1386

Open
sebastiondev wants to merge 1 commit into
danielmiessler:mainfrom
sebastiondev:fix/cwe200-index-observabil-105c
Open

fix(observability): bind Bun server to 127.0.0.1 to prevent LAN exposure#1386
sebastiondev wants to merge 1 commit into
danielmiessler:mainfrom
sebastiondev:fix/cwe200-index-observabil-105c

Conversation

@sebastiondev

Copy link
Copy Markdown

Summary

Releases/v2.3/.claude/Observability/apps/server/src/index.ts calls Bun.serve({ port: 4000, ... }) without an explicit hostname. Bun's default in that case is 0.0.0.0, so the Observability dashboard listens on every network interface — LAN, VPN, Tailscale — not just loopback. Combined with Access-Control-Allow-Origin: * and zero authentication on the API routes, this means any host on the same network as the operator can read information from the operator's machine and use the operator's Anthropic API key.

This PR adds a one-line hostname: '127.0.0.1' to that Bun.serve() call so the server only listens on loopback, matching how other PAI Bun servers in the tree are configured (e.g. Releases/v5.0.0/.claude/PAI/PAI-Install/web/server.ts line 56 uses hostname: "127.0.0.1", // Localhost only — never expose to network).

Vulnerability details

  • Category: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor), CWE-306 (Missing Authentication for Critical Function)
  • File: Releases/v2.3/.claude/Observability/apps/server/src/index.ts (the Bun.serve block starting at line 49 in the pre-fix code)
  • Preconditions:
    • Operator has started the dashboard with Releases/v2.3/.claude/Observability/manage.sh start (server listens on port 4000)
    • Attacker is on the same LAN / VPN / Tailscale as the operator
    • Operator has an ANTHROPIC_API_KEY set in ${PAI_DIR}/.env (the documented setup, used by POST /api/haiku/summarize)
  • What an attacker gets, unauthenticated:
    • GET http://<operator-ip>:4000/api/activities — returns the current Kitty tab/window titles. In practice that includes SSH hostnames, running commands, project paths, git branch names, filenames being edited — a live view of what the operator is doing. The endpoint runs kitty @ ls and returns the parsed titles as JSON (index.ts lines 287–336 in the pre-fix file).
    • POST http://<operator-ip>:4000/api/haiku/summarize — reads ANTHROPIC_API_KEY from ${PAI_DIR}/.env and forwards an attacker-supplied prompt to the Anthropic API. The API key is never returned to the attacker, but the attacker can burn the operator's quota and rate limits at will, or use the operator's account as an oracle for arbitrary Claude Haiku prompts (index.ts lines 338+).
    • GET /api/tasks, GET /api/tasks/:id, GET /api/tasks/:id/output — background task metadata and stdout/stderr (index.ts lines 428–468).

Bun's typings confirm the default. Releases/v2.3/.claude/Observability/apps/server/node_modules/bun-types/serve.d.ts line 737 declares hostname?: "0.0.0.0" | "127.0.0.1" | "localhost" | (string & {}); and Bun's runtime binds to 0.0.0.0 when the field is omitted.

Fix

 const server = Bun.serve({
   port: 4000,
+  // Bind to loopback only. This server exposes /api/activities
+  // (kitty tab titles) and /api/haiku/summarize (proxied Anthropic API
+  // key) with `Access-Control-Allow-Origin: *` and no authentication —
+  // it must never be reachable from the network.
+  hostname: '127.0.0.1',

The client is unaffected: every consumer (apps/client/src/composables/useBackgroundTasks.ts, useThemes.ts, useAgentContext.ts, apps/client/src/utils/haiku.ts, App.vue, FilterPanel.vue) hard-codes http://localhost:4000 / ws://localhost:4000, and manage.sh health-checks via http://localhost:4000/events/filter-options. All of those continue to work; only off-host access is blocked.

Proof of Concept

Two terminals. In terminal A, on the operator machine:

cd Releases/v2.3/.claude/Observability
./manage.sh start
# Server starts on port 4000

Verify the pre-fix binding is on all interfaces:

lsof -iTCP:4000 -sTCP:LISTEN
# Pre-fix: shows *:4000 (IPv4 wildcard, i.e. 0.0.0.0)
# Post-fix: shows 127.0.0.1:4000

From terminal B, on any other machine on the same LAN / VPN / Tailscale, replace OPERATOR_IP with the operator's LAN address:

# 1. Leak Kitty tab titles (SSH hosts, commands, filenames, branches, ...)
curl -s "http://OPERATOR_IP:4000/api/activities" | head

# 2. Burn the operator's Anthropic quota using their API key
curl -s -X POST "http://OPERATOR_IP:4000/api/haiku/summarize" \
  -H "Content-Type: application/json" \
  -d '{"prompt":"Say hello"}'

# 3. Enumerate background tasks
curl -s "http://OPERATOR_IP:4000/api/tasks"

With the fix applied, all three requests fail (Connection refused from off-host), while curl http://localhost:4000/api/activities on the operator's own machine continues to work.

Testing

  • Ran Bun.serve({ port: 4000 }) (no hostname) in a scratch script and confirmed lsof -iTCP:4000 shows a wildcard IPv4 listener reachable from a second host on the LAN.
  • Ran the same script with hostname: '127.0.0.1' and confirmed the listener is 127.0.0.1:4000, that curl http://localhost:4000/... still works from the same host, and that curl http://<lan-ip>:4000/... fails with Connection refused.
  • Re-read every consumer under Releases/v2.3/.claude/Observability/apps/client/ to confirm they only ever address localhost / 127.0.0.1. There are no client-side callers that would break.
  • manage.sh's readiness check (curl -s http://localhost:4000/events/filter-options) is unchanged and still succeeds.

Adversarial review

Before submitting, we tried to disprove this:

  • "Maybe Bun defaults to loopback when hostname is omitted." It doesn't. bun-types/serve.d.ts documents the hostname field with 0.0.0.0 first in the type union, and the Bun runtime binds to 0.0.0.0 when the option is missing — reproducible with a two-line script.
  • "Maybe the OS firewall on macOS blocks inbound 4000 by default." The macOS Application Firewall is off by default and, when on, prompts per-app; users running the dashboard almost always have bun allowed (it's how they run everything else). Corporate/VPN/Tailscale environments routinely route peer traffic without going through the local firewall at all.
  • "Maybe the endpoints require auth we missed." They don't. There is no auth middleware in the Bun.serve fetch handler; the only header handling is the CORS block, and Access-Control-Allow-Origin: '*' is set unconditionally. Grepping the file for Authorization, token, apiKey, or any auth check returns nothing on the request-handling side.
  • "Maybe this is only a demo repo." It isn't. PAI is a live personal-AI project (16k+ stars, Releases/v2.3 is a published version, and later Releases/v2.4/v2.5/v5.0.0 reuse the same architecture). Real users run manage.sh start on their laptops per the README.

The fix does not close every issue in this file — see below — but it does close the LAN-exposure vector cleanly, without changing any client behaviour.

Related — not fixed by this PR

Two follow-ups I want to flag so they aren't lost, but I deliberately kept this PR to the single-line minimal change:

  1. Same bug in v2.4 and v2.5. Releases/v2.4/.claude/Observability/apps/server/src/index.ts line 49 and Releases/v2.5/.claude/Observability/apps/server/src/index.ts line 64 also call Bun.serve({ port: 4000, ... }) with no hostname. In v2.4/v2.5 the /api/haiku/summarize endpoint was refactored to a subprocess and no longer reads the API key directly, but /api/activities (Kitty tab titles) and /api/tasks are still exposed the same way, and CORS is still *. Happy to open a follow-up PR that applies the same one-liner to both, or fold them into this PR if you'd prefer a single change — whichever is easier to review.
  2. Access-Control-Allow-Origin: * on the same origin. Even after this fix, any web page the operator visits in a browser can fetch('http://localhost:4000/api/activities') from the browser and read the response, because CORS is wildcarded. Modern Chrome's Private Network Access mitigates some of this, but it's not a full fix. That's a separate change (either scope CORS to http://localhost:5173 / the client's dev origin, or require an Origin allow-list) and I didn't want to bundle it here.

Discovered by the Sebastion AI GitHub App.

The v2.3 Observability server exposes several unauthenticated endpoints
with 'Access-Control-Allow-Origin: *', including:

  GET  /api/activities        — returns kitty tab titles (which typically
                                contain ssh hostnames, filenames, running
                                commands, project paths, git branches, etc.)
  POST /api/haiku/summarize   — proxies requests to the Anthropic API using
                                the operator's ANTHROPIC_API_KEY from
                                ${PAI_DIR}/.env
  GET  /api/tasks, GET /api/tasks/:id[/output]

Bun.serve() was called without an explicit hostname, which means Bun binds
to 0.0.0.0 by default. On any machine with LAN, VPN, or Tailscale peers,
anyone on the same network can hit these endpoints and either read the
developer's terminal titles or burn their Anthropic quota / rate limit.

Fix: pass hostname: '127.0.0.1' to Bun.serve so the process only listens
on the loopback interface. This matches how other PAI Bun servers in the
tree are configured (e.g., v5.0.0/.claude/PAI/PAI-Install/web/server.ts).

The client always fetches from http://localhost:4000, so this is
transparent to normal use — only off-host access is blocked.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant