The MCP proxy's client-facing session ID embeds the authenticated caller's
subject (JWT sub claim) specifically as an anti-hijacking discriminator —
matching the Model Context Protocol specification's documented session-hijacking
guidance (https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#session-hijacking).
However, the session-resume path never checks that discriminator against the
current caller's identity, so any client that obtains a session ID (its own or
another user's) can resume that session and act with it — including on
routes with per-user backend sessions. This is aggravated by the official
Helm chart shipping a literal, publicly known default seed
("default-insecure-seed") for the key that encrypts these session IDs.
An attacker who obtains (or, under the default seed, forges) another user's
Mcp-Session-Id value can resume that user's MCP session against the backend
MCP server(s) bound to the route — reading conversation/tool-call state and
invoking further JSON-RPC methods (e.g. tools/call, resources/read) in the
context of that other user's already-initialized backend session. On a
multi-tenant AI Gateway deployment, this crosses the exact trust boundary the
subject-embedding was added to protect.
internal/mcpproxy/session.go — clientToGatewaySessionID.backendSessionIDs()
(around line 707-765): parses {routeName}@{subject}@{backends} and returns
only (perBackendSessionIDs, route, error). The comment at the subject-parsing
site reads: "The subject ... is retained inside the encrypted session ID for
anti-hijacking purposes but is not needed during parsing." — the subject is
discarded, never returned, never compared.internal/mcpproxy/mcpproxy.go — sessionFromID(id, lastEvent) (around line
282-323): resumes a session purely from the decrypted blob; it is never passed
the current request's authenticated subject, so there is nothing to compare
against even if backendSessionIDs() did return it.internal/mcpproxy/legacy.go — extractSubject(r) (around line 1549-1562) is
called exactly once, only at session creation (handleInitializeRequest ->
newSession, line ~307/484/491) to embed the subject when the session ID is
minted. All three session-resume call sites (serveGET/servePOST/serverDELETE
paths, lines ~133, 167, 216) call sessionFromID with no subject argument at all.internal/mcpproxy/mcpproxy.go line 259 (clientToGatewaySessionIDFromEntries)
is where the subject is embedded at creation, confirming the intended design.manifests/charts/ai-gateway-helm/values.yaml line 297
ships seed: "default-insecure-seed" as the out-of-the-box value for
controller.mcp.sessionEncryption.seed, which templates/deployment.yaml
passes straight to --mcpSessionEncryptionSeed (also literally defaulted to
"default-insecure-seed" in cmd/controller/main.go and
cmd/extproc/mainlib/main.go when the flag is omitted entirely). Session IDs
are salt || nonce || ciphertext (see internal/mcpproxy/crypto.go), so
knowing the seed and the (also-defaulted) PBKDF2 iteration count is sufficient
to derive the AES-GCM key and both decrypt observed session IDs and encrypt
new ones for an arbitrary chosen subject/backend-session combination.MCPRoute that has an OAuth/JWT
SecurityPolicy attached (so internalapi.MCPSubjectHeader is populated)
and at least one backend MCP server, using the Helm chart's default
values.yaml (controller.mcp.sessionEncryption.seed left at
"default-insecure-seed").sub: user-a) and sends an
initialize JSON-RPC request to the route. The gateway creates backend
session(s) and returns Mcp-Session-Id: <encrypted-blob-A>, whose plaintext
is {route}@user-a@{backend}:{base64(sessionID)}:{capHex}.{route}@user-a@{backend}:{base64(<any-or-known-backend-session-id>)}:fff,
derives the AES-GCM key via PBKDF2(sha256, "default-insecure-seed",
salt, 100000, 32) for a salt of their choosing, and encrypts it themselves
to produce a session ID that decrypts to the same structure sessionFromID
expects.tools/call) to the same
route with header Mcp-Session-Id: <forged-or-captured-blob-A>. The gateway
decrypts it, extracts perBackendSessionIDs for backend, and forwards the
attacker's call using that backend session — with no comparison to the
attacker's own JWT subject at any point in sessionFromID /
backendSessionIDs().sessionFromID, thread the current request's extractSubject(r) value
through to backendSessionIDs() (or a wrapping call) and reject resumption
(401/403) when the decrypted subject does not match the current caller's
subject — restoring the anti-hijacking check the embedding was built for.controller.mcp.sessionEncryption.seed to be randomly generated per
install (e.g. via a Helm lookup/randAlphaNum in a pre-install hook, or a
required value with no default), rather than shipping a literal, fixed
string. At minimum, fail startup (rather than just logging) when the seed
equals the documented insecure default in a non-dev/insecure-allowed mode.Aeon vuln-scanner — agentic dataflow review (internal/mcpproxy entrypoint,
Envoy AI Gateway data-plane MCP proxy), followed by manual adversarial
verification against the exact HEAD commit of main
(472da7db7984f769c9a77671d12c0354e75fc3ba, confirmed to match
repos/theagentrouter/agent-router/commits/main at time of report).
Dependency-CVE findings from this scan (osv-scanner, go.mod/package-lock.json)
are not included here — they are all already covered by open Dependabot PRs
(#2555, #2643, #2672, #2497, #2615) and are omitted to avoid duplicate filings.
{
"cwe_ids": [
"CWE-798",
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-19T16:53:26Z",
"nvd_published_at": null,
"severity": "MODERATE"
}