Route SSE proxy responses to originating session - #6702
Conversation
The legacy HTTP+SSE proxy for stdio backends delivered every backend JSON-RPC message to every connected SSE client. In a multi-user deployment any authenticated user received every other user's tool results, and because independent clients reuse ordinary request-id sequences, a client could accept another session's response as its own. Fixes GHSA-wm2j-ch74-276r. The proxy now tags each call's wire id with the session that issued it before forwarding to the shared backend, and routes the echoed response back to that session alone with the client's original id restored. The route is derived from the id itself, so there is no pending-request table to evict. Responses the proxy cannot attribute are dropped, never queued or broadcast. A client's notifications/cancelled names its target request by id, so that parameter is rewritten the same way; otherwise cancellation would reach the backend under an id it does not know. Server-to-client traffic now follows the streamable proxy's policy: only */list_changed notifications are broadcast; progress, logging and resources/updated notifications are dropped because the shared backend cannot say which session they belong to; server-initiated requests are rejected back to the backend with -32601. The first drop or rejection per method is logged at Warn. The queue that replayed messages received while no client was connected to the next client to connect is removed. It was the second path by which one session's response could reach another, and a list_changed delivered to a client that has not yet initialized is of no use to it. The streamable proxy has no such queue either. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #6702 +/- ##
========================================
Coverage 79.30% 79.30%
========================================
Files 799 800 +1
Lines 80826 80936 +110
========================================
+ Hits 64097 64185 +88
- Misses 16724 16746 +22
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Codecov's patch report on the routing fix flagged reachable branches with no test: a response for a session whose stream is full or already disconnected, a server-initiated request rejected while the backend channel is full, and the notifications/cancelled shapes the proxy forwards untouched. Each now has a test. The warn-once bookkeeping is split into a firstOccurrence predicate so the repeat and cap behaviour can be asserted directly instead of by capturing log output. The remaining uncovered lines are encode-error branches that well-formed JSON-RPC cannot reach. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ChrisJBurns
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: ToolHive code reviewer, security advisor, MCP protocol expert, Codex cross-review
Consensus Summary
| # | Finding | Consensus | Severity | Action |
|---|---|---|---|---|
| 1 | Client responses can forge replies to shared-backend requests | 9/10 | HIGH | Fix |
| 2 | List-change broadcasts preserve arbitrary cross-session metadata | 10/10 | HIGH | Fix |
| 3 | HTTP decoding corrupts valid numeric request IDs before routing | 9/10 | MEDIUM | Fix |
| 4 | Backend ping requests are rejected as unsupported | 8/10 | MEDIUM | Fix |
| 5 | Removing exported pending-message symbols breaks downstream builds | 7/10 | MEDIUM | Discuss |
Overall
This is a security-focused routing refactor with a sound core: ownership-validated session IDs are embedded in backend request IDs, response IDs are restored, stale replay buffering is removed, and deterministic cross-principal tests cover collisions and disconnects. The stateless routing approach is substantially safer than the previous broadcast behavior.
Two remaining paths should be fixed before merge. Client-originated JSON-RPC responses still enter the shared backend even though no legitimate server request reaches a client, allowing a session to race replies to another session's backend work. Separately, whitelisted list-change notifications are broadcast verbatim even though MCP permits arbitrary _meta, retaining a cross-session disclosure channel. The numeric-ID and ping findings are protocol-correctness issues; the exported-type removal needs an explicit compatibility decision.
CI is green at the reviewed commit, including unit, race, lint, proxy E2E, MCP conformance, and CodeQL checks.
Documentation
If list-change notifications are sanitized or rejected when parameterized, update docs/arch/03-transport-architecture.md so the routing table states the exact safe payload shape. If the exported pending-message API remains removed, document the compatibility decision or deprecation path.
Generated with Codex using the pr-review workflow
Review of the routing fix found four gaps, each closed here with a test that failed first. A JSON-RPC response sent by a client was forwarded to the shared backend unchanged. The proxy now answers or rejects every server-initiated request itself, so no client response is ever legitimate; one could only be an attempt to answer backend work on behalf of another session. It is refused with 400. list_changed notifications were broadcast verbatim. The spec allows params._meta on them, and anything the backend put there could belong to one session. The broadcast is rebuilt from the method with no params. Numeric request ids were taken from the decoder, which parses through float64 and rounds integers above 2^53, while the cancellation path parsed exactly, so the two could disagree. The id is now read exactly from the raw body for both; fractional ids are rejected with 400 instead of being truncated. A backend ping was rejected with -32601 like other server-initiated requests. It is a liveness check with no session-specific content, so the proxy answers it with an empty result. The ssecommon pending-message type is left as it is on main. Whether to remove it is a separate change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Summary
The legacy HTTP+SSE proxy for stdio backends (
pkg/transport/proxy/httpsse) fronts one shared backend process for every connected session. Backend responses arrive with no session attribution, and the proxy delivered each one to every live SSE stream. In an authenticated multi-user deployment, any user with their own valid session received every other user's tool results, and because independent MCP clients reuse ordinary request-id sequences, a victim client could accept another session's response as the answer to its own pending call. Fixes GHSA-wm2j-ch74-276r.<session_id>|n:<int64>or<session_id>|s:<string>before it reaches the backend, using the samesession_idthe ownership middleware just validated. The backend echoes it, and the proxy routes the response to exactly that session with the client's original id and id type restored. The id is read exactly from the raw body, becausejsonrpc2.DecodeMessageparses numbers throughfloat64and rounds integers above 2^53; fractional ids are rejected with 400.notifications/cancellednames its target request inparams.requestId; that value is rewritten the same way so the backend can match it. Without this, client cancellation would silently stop working once ids are rewritten.dispatcher.go):*/list_changednotifications are broadcast to every live session, rebuilt from the method with no params since the spec allows_metathere;notifications/message,notifications/progress,notifications/resources/updatedand unknown notifications are dropped because the shared backend cannot attribute them to a session; a backendpingis answered by the proxy with an empty result; other server-initiated requests such assampling/createMessageare answered with JSON-RPC-32601written back to the backend. The first drop or rejection per method is logged at Warn so operators can see the fail-closed behavior; later ones at Debug.list_changedhanded to a client that has not yet initialized is of no use to it. The streamable proxy has no replay buffer either. Thessecommon.PendingSSEMessagetype it used is left untouched; removing it is a separate change.docs/arch/03-transport-architecture.mddocuments the legacy SSE routing table alongside the streamable one.Type of change
Test plan
task test)proxy stdio+--proxy-mode ssescenario intest/e2e/proxy_stdio_test.gorun against a real time-server container, proving a real client'sinitializewithid: -1round-trips through the rewrite and restore.task lint-fix) — cannot run locally on this machine (golangci-lint cannot read Go 1.27 export data, pre-existing and unrelated);go vetandgofmtare clean, relying on CI for lint.routing_test.go: two independently authenticated principals, victim's response reaches only the victim's stream; collidingid: 1from both sessions stays isolated when the backend answers out of order; a client whose own string id is spelled like the victim's route gets it back as its own id and the victim sees nothing; a response arriving after its session disconnected reaches nobody; nothing received while no client is connected is replayed to a later client;notifications/cancelledcarries the routed id, including above 2^53; integer ids above 2^53 round-trip exactly and fractional ids are refused; client responses are refused;list_changedbroadcasts carry no params; a backendpingis answered; every row of the dispatch table. Non-delivery is proven by sentinel ordering on the FIFO per-session stream, not by waiting.Does this introduce a user-facing change?
Yes, for deployments using the legacy SSE proxy mode with a stdio backend:
notifications/progress,notifications/messageandnotifications/resources/updatedare no longer delivered on the SSE proxy. Porting the streamable proxy's progress-token rewriting and subscription tracking is a follow-up.sampling/createMessage,elicitation/create) receive a-32601error from the proxy instead of reaching a client, matching the streamable proxy. This applies even with a single connected client, because a request emitted on behalf of a disconnected session would otherwise land on whoever remains connected. A backendpingis the exception and is answered by the proxy.Streamable HTTP proxy mode, the default for stdio backends, is unaffected.
Implementation plan
Approved implementation plan
Mechanism. Rewrite each outgoing call's wire id to a string encoding both the originating session and the original id with its type (
<session_id>|n:<int64>/<session_id>|s:<string>). On a backend response, parse it back, restore the original id, deliver to exactly that session. This is the streamable proxy'scompositeKey(sessID, idKey)shape without its two maps: streamable needs a per-request waiter channel, whereas the SSE destination is the session stream already indexed by session id, so the route is fully derivable from the echoed id. Collisions are solved by construction. The session id embedded is the samesession_idquery value the ownership middleware validated, read once in the handler.Dispatch table. Response with a live routed session: that session only. Response otherwise: dropped.
*/list_changed: every live session.notifications/message,notifications/progress,notifications/resources/updated, anything else: dropped (SECURE-DROP, as streamable). Server-initiated request:-32601back to the backend. Two deliberate fail-closed behavior changes: progress stops arriving on SSE until token rewriting is ported; server-initiated requests are rejected even with one client, because a departed session's still-running tool could otherwise route its sampling request to the next sole session.Tests first. Two-principal HTTP-level regression test modeled on the ownership test, failing on the old code; colliding-id case; table-driven dispatch cases; adapt the existing tests that encoded the broadcast.
Review adjustments.
notifications/cancelledrewritesparams.requestIdthe same way (code review). Warn once per method on drops and rejections (both reviews). Replay queue removed rather than bounded (simplification pass).Special notes for reviewers
compositeKey(sessID, idKey)in the streamable proxy but without itswaiters/idRestoremaps, because the SSE destination (the session's stream) is already indexed by session id inliveSSESessions. The advisory's recommended remediation describes the same shape; it was used only as a cross-check after deriving the fix from the streamable precedent.list_changedbroadcast and-32601for backendping; fixing those there is also a follow-up.🤖 Generated with Claude Code