Skip to content

fix(http): stop exposing active MCP session IDs on /health - #35

Closed
andesyteoss wants to merge 1 commit into
mcpdotdirect:mainfrom
andesyteoss:fix/cwe200-http-server-health-50ff
Closed

fix(http): stop exposing active MCP session IDs on /health#35
andesyteoss wants to merge 1 commit into
mcpdotdirect:mainfrom
andesyteoss:fix/cwe200-http-server-health-50ff

Conversation

@andesyteoss

@andesyteoss andesyteoss commented Jul 10, 2026

Copy link
Copy Markdown

Summary

The /health endpoint in src/server/http-server.ts returns the full list of active MCP session IDs to any unauthenticated caller. Because those same session IDs are the sole bearer credential used to reuse an existing MCP transport on POST /mcp, GET /mcp, and DELETE /mcp, disclosing them is equivalent to disclosing a session token — a network peer that can reach the health endpoint can hijack any live MCP session and invoke every registered tool (including wallet/transfer tools) as that session.

  • File: src/server/http-server.ts (health handler around L154–L162 pre-fix)
  • Class: CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor
  • Default exposure: the server binds HOST=0.0.0.0 and PORT=3001 by default, so on any host without an external firewall the endpoint is reachable from the local network.

Data flow

  1. A client opens an MCP session via POST /mcp with an initialize request. The server generates a UUID session ID and stores the transport in transports: Map<string, StreamableHTTPServerTransport> (L18, L75).
  2. Subsequent POST /mcp, GET /mcp, and DELETE /mcp requests look up the transport purely by the mcp-session-id header (L61–L66, L117–L122, L138–L143). There is no other authentication.
  3. GET /health returned sessionIds: Array.from(transports.keys()) — the exact strings needed to satisfy step 2.

Session IDs are unguessable UUIDs, so /health is the differentiator: without this leak the transport lookup is effectively bearer-token protected; with it, any network-adjacent process can enumerate live sessions.

Fix

Drop the sessionIds field from the /health response. activeSessions: transports.size is retained because a count is useful for liveness/monitoring and does not disclose credentials.

   res.status(200).json({
     status: "ok",
     server: server ? "initialized" : "initializing",
-    activeSessions: transports.size,
-    sessionIds: Array.from(transports.keys())
+    activeSessions: transports.size
   });

Minimal, one-file change; no behavioural impact on /mcp handlers or on monitoring that reads status / activeSessions.

Proof of concept

With the server running on its defaults (bun start:httphttp://0.0.0.0:3001):

# 1. Open a legitimate MCP session as any client
curl -s -X POST http://127.0.0.1:3001/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"victim","version":"1.0"}}}' -i | grep -i mcp-session-id
# (victim gets some session UUID)

# 2. From any other host that can reach port 3001, harvest live session IDs
curl -s http://<server-ip>:3001/health
# Before fix: {"status":"ok","server":"initialized","activeSessions":1,"sessionIds":["<victim-uuid>"]}
# After  fix: {"status":"ok","server":"initialized","activeSessions":1}

# 3. Reuse the harvested UUID to drive the victim's transport and call tools
curl -s -X POST http://<server-ip>:3001/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'mcp-session-id: <victim-uuid>' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

Post-fix, step 2 no longer yields the session UUID, so step 3 cannot succeed without out-of-band knowledge of the token.

Testing

  • bun run build succeeds; only the health handler is touched.
  • Manually exercised GET /health before and after the change — response shape matches the diff above.
  • GET /mcp, POST /mcp, DELETE /mcp handlers are untouched; existing session lookup (transports.has(sessionId) / transports.get(sessionId)) continues to work for legitimate clients that already hold their own session ID.
  • grep -rn "transports.keys\|Array.from(transports" src confirms no other endpoint leaks the same data.

Adversarial review

Before submitting we tried to disprove this:

  • "Session IDs are UUIDs — leaking them doesn't matter." It does: the /mcp handlers treat a valid mcp-session-id as sufficient to bind to an existing transport (L61–L66). There is no additional auth, origin check, or per-session secret. The UUID is the credential.
  • "/health is normally firewalled." The default bind is 0.0.0.0, no auth middleware is registered, and the README instructs users to run bun start:http directly. Any co-tenant or LAN peer reaches it. Even on a public deployment behind a reverse proxy, /health is commonly whitelisted for uptime checks — that same path then leaks credentials.
  • "The precondition (network reach to port 3001) already grants equivalent access." It doesn't. Network reach alone doesn't give an attacker a valid session ID — those are unguessable UUIDs — so /mcp requests without one are rejected. The /health leak is precisely what turns network reach into session hijack.

Fix is scoped to the one line that made the disclosure possible; monitoring consumers that only need liveness continue to work.

The /health endpoint returned an array of every active MCP session UUID
via 'sessionIds: Array.from(transports.keys())'. Because the HTTP server
binds on 0.0.0.0 by default and treats the 'mcp-session-id' header as
the sole authentication token for reusing a transport on POST /mcp, any
unauthenticated network caller could fetch /health, harvest live
session UUIDs, and hijack another user's MCP transport - including
wallet signing and transfer tools.

Drop 'sessionIds' from the response and keep only the non-sensitive
liveness fields (status, server, activeSessions count).
@andesyteoss

Copy link
Copy Markdown
Author

Closing this as inactive — no maintainer response after 14 days.

The security finding and fix remain valid. If this is still relevant, I'm happy to reopen, rebase, or re-submit against a different branch. Just drop a comment.

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