Found by the security review on the t-673 branch (org binding for credentials); pre-existing, not introduced there.
What happens. handleGet in app/api/v1/mcp/route.ts (stateful mode only) reads Mcp-Session-Id from the request and calls sessionManager.registerSseListener(sessionId, sink) without checking that the session exists or that session.apiKeyId === auth.apiKeyId. The POST and DELETE paths do make that check (Session not found, 404). registerSseListener is a Map.set, so a second registration replaces the first.
Consequence. Any authenticated MCP key that learns another key's session id can (a) receive that session's server-push notifications and (b) displace the legitimate client's sink, which then stops receiving. Session ids are server-generated random values, so this needs a leaked id — but once credentials are org-bound (§106 t-673) this is a cross-org channel at TENANCY_MODE=multi, which is why it is worth closing.
Fix shape. In handleGet, when a session id is presented: look it up, and answer 404 Session not found unless it exists and belongs to auth.apiKeyId (the DELETE path's check, verbatim). A GET with no session id keeps its current behaviour (a connected-only stream with no per-session fan-in).
Severity. Medium-low: needs a leaked session id, stateful mode only, notifications only (no tool results).
Found by the security review on the t-673 branch (org binding for credentials); pre-existing, not introduced there.
What happens.
handleGetinapp/api/v1/mcp/route.ts(stateful mode only) readsMcp-Session-Idfrom the request and callssessionManager.registerSseListener(sessionId, sink)without checking that the session exists or thatsession.apiKeyId === auth.apiKeyId. The POST and DELETE paths do make that check (Session not found, 404).registerSseListeneris aMap.set, so a second registration replaces the first.Consequence. Any authenticated MCP key that learns another key's session id can (a) receive that session's server-push notifications and (b) displace the legitimate client's sink, which then stops receiving. Session ids are server-generated random values, so this needs a leaked id — but once credentials are org-bound (§106 t-673) this is a cross-org channel at
TENANCY_MODE=multi, which is why it is worth closing.Fix shape. In
handleGet, when a session id is presented: look it up, and answer 404Session not foundunless it exists and belongs toauth.apiKeyId(the DELETE path's check, verbatim). A GET with no session id keeps its current behaviour (a connected-only stream with no per-session fan-in).Severity. Medium-low: needs a leaked session id, stateful mode only, notifications only (no tool results).