fix(observability): bind Bun server to 127.0.0.1 to prevent LAN exposure#1386
Open
sebastiondev wants to merge 1 commit into
Open
fix(observability): bind Bun server to 127.0.0.1 to prevent LAN exposure#1386sebastiondev wants to merge 1 commit into
sebastiondev wants to merge 1 commit into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Releases/v2.3/.claude/Observability/apps/server/src/index.tscallsBun.serve({ port: 4000, ... })without an explicithostname. Bun's default in that case is0.0.0.0, so the Observability dashboard listens on every network interface — LAN, VPN, Tailscale — not just loopback. Combined withAccess-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 thatBun.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.tsline 56 useshostname: "127.0.0.1", // Localhost only — never expose to network).Vulnerability details
Releases/v2.3/.claude/Observability/apps/server/src/index.ts(theBun.serveblock starting at line 49 in the pre-fix code)Releases/v2.3/.claude/Observability/manage.sh start(server listens on port 4000)ANTHROPIC_API_KEYset in${PAI_DIR}/.env(the documented setup, used byPOST /api/haiku/summarize)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 runskitty @ lsand returns the parsed titles as JSON (index.tslines 287–336 in the pre-fix file).POST http://<operator-ip>:4000/api/haiku/summarize— readsANTHROPIC_API_KEYfrom${PAI_DIR}/.envand forwards an attacker-suppliedpromptto 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.tslines 338+).GET /api/tasks,GET /api/tasks/:id,GET /api/tasks/:id/output— background task metadata and stdout/stderr (index.tslines 428–468).Bun's typings confirm the default.
Releases/v2.3/.claude/Observability/apps/server/node_modules/bun-types/serve.d.tsline 737 declareshostname?: "0.0.0.0" | "127.0.0.1" | "localhost" | (string & {});and Bun's runtime binds to0.0.0.0when 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-codeshttp://localhost:4000/ws://localhost:4000, andmanage.shhealth-checks viahttp://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:
Verify the pre-fix binding is on all interfaces:
From terminal B, on any other machine on the same LAN / VPN / Tailscale, replace
OPERATOR_IPwith the operator's LAN address:With the fix applied, all three requests fail (
Connection refusedfrom off-host), whilecurl http://localhost:4000/api/activitieson the operator's own machine continues to work.Testing
Bun.serve({ port: 4000 })(nohostname) in a scratch script and confirmedlsof -iTCP:4000shows a wildcard IPv4 listener reachable from a second host on the LAN.hostname: '127.0.0.1'and confirmed the listener is127.0.0.1:4000, thatcurl http://localhost:4000/...still works from the same host, and thatcurl http://<lan-ip>:4000/...fails withConnection refused.Releases/v2.3/.claude/Observability/apps/client/to confirm they only ever addresslocalhost/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:
bun-types/serve.d.tsdocuments thehostnamefield with0.0.0.0first in the type union, and the Bun runtime binds to0.0.0.0when the option is missing — reproducible with a two-line script.bunallowed (it's how they run everything else). Corporate/VPN/Tailscale environments routinely route peer traffic without going through the local firewall at all.Bun.servefetchhandler; the only header handling is the CORS block, andAccess-Control-Allow-Origin: '*'is set unconditionally. Grepping the file forAuthorization,token,apiKey, or any auth check returns nothing on the request-handling side.Releases/v2.3is a published version, and laterReleases/v2.4/v2.5/v5.0.0reuse the same architecture). Real users runmanage.sh starton 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:
Releases/v2.4/.claude/Observability/apps/server/src/index.tsline 49 andReleases/v2.5/.claude/Observability/apps/server/src/index.tsline 64 also callBun.serve({ port: 4000, ... })with nohostname. In v2.4/v2.5 the/api/haiku/summarizeendpoint was refactored to a subprocess and no longer reads the API key directly, but/api/activities(Kitty tab titles) and/api/tasksare 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.Access-Control-Allow-Origin: *on the same origin. Even after this fix, any web page the operator visits in a browser canfetch('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 tohttp://localhost:5173/ the client's dev origin, or require anOriginallow-list) and I didn't want to bundle it here.Discovered by the Sebastion AI GitHub App.