@@ -8,9 +8,9 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
88import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js' ;
99
1010// Local imports
11- import { getSessionManager , routeForSessionRequest , sessionFingerprint } from './session-manager.js' ;
11+ import { getSessionManager , routeForSessionRequest , sessionAcceptsCredential , sessionFingerprint } from './session-manager.js' ;
1212
13- import { getOAuthManager } from './oauth-manager.js' ;
13+ import { getOAuthManager , hashToken } from './oauth-manager.js' ;
1414import {
1515 SERVER_CONFIG ,
1616 INSFORGE_CONFIG ,
@@ -230,6 +230,74 @@ function extractOAuthToken(req: Request): string | undefined {
230230 return undefined ;
231231}
232232
233+ /**
234+ * Refuse a request that names a session it was not the one to open.
235+ *
236+ * ONE FUNCTION, CALLED BY EVERY VERB, and that is the whole point of its
237+ * existing at all. The first version of this bound POST only — and a session is
238+ * reachable by three verbs, so it was a POST-shaped fix for a session-shaped
239+ * problem. Quinn demonstrated the gap rather than argued it:
240+ *
241+ * DELETE /mcp with just the session id -> 200
242+ * the victim's very next valid request -> 404
243+ *
244+ * One request, no credential of any kind, and someone else is logged out. GET
245+ * is quieter and worse: it opens the server->client stream on the id alone and
246+ * receives everything pushed for that session, with no forged requests at all.
247+ *
248+ * Returns true when it has already answered, so a caller is one line:
249+ *
250+ * if (refuseMismatchedCredential(req, res, sessionId)) return;
251+ *
252+ * Placement matters and is not interchangeable: call it only AFTER the handler
253+ * has established that this process holds the session. Before that, it would
254+ * authenticate ahead of routing and a client with a dead session plus a stale
255+ * token would get 401 where it needs the 404 that tells it to start over.
256+ */
257+ function credentialMatchesSession ( req : Request , sessionId : string ) : boolean {
258+ const stored = getSessionManager ( ) . getSessionData ( sessionId ) ?. oauthTokenHash ;
259+ const presentedToken = extractOAuthToken ( req ) ;
260+ // The FULL sha256, never tokenFingerprint's 8 chars — see
261+ // sessionAcceptsCredential for why that mistake logs everyone out.
262+ const presented = presentedToken ? hashToken ( presentedToken ) : undefined ;
263+ return sessionAcceptsCredential ( stored , presented ) ;
264+ }
265+
266+ function refuseMismatchedCredential ( req : Request , res : Response , sessionId : string ) : boolean {
267+ if ( credentialMatchesSession ( req , sessionId ) ) return false ;
268+
269+ console . log (
270+ `[Streamable HTTP] Session ${ sessionFingerprint ( sessionId ) } refused: ` +
271+ 'credential does not match the one it was opened with'
272+ ) ;
273+
274+ // 404, NOT 401, and this is the one decision here I got wrong first.
275+ //
276+ // 401 is the instruction "re-run OAuth". Consider the client that just did:
277+ // it re-authorized, holds a NEW token, and retries with the session id it
278+ // still has. The credential is valid and the session is real — but they
279+ // belong to different sign-ins, so a 401 sends it round the OAuth loop again,
280+ // to arrive with another new token and the same old id. That is an infinite
281+ // loop triggered by the ordinary act of signing in again, and I only saw it
282+ // because I tested the re-authorize case rather than only the attacker.
283+ //
284+ // The action this client actually needs is the one the routing 404 already
285+ // gives: start a new session. So the answer is identical to "we do not hold
286+ // that session" — which it effectively is, for you — and the recovery is
287+ // coherent: ANY request naming a session this process will not serve you gets
288+ // 404 and initializes again.
289+ //
290+ // It also happens to leak less. To someone probing with a stolen id, "not
291+ // found" and "not yours" are now the same answer.
292+ res . status ( 404 ) . json ( {
293+ error : 'Session not found' ,
294+ error_description :
295+ 'This session is not held by the server — it expired, or the server restarted. ' +
296+ 'Send an initialize request to start a new one.' ,
297+ } ) ;
298+ return true ;
299+ }
300+
233301/**
234302 * Extract legacy headers for backwards compatibility
235303 */
@@ -1160,13 +1228,38 @@ app.post(STREAMABLE_HTTP_ENDPOINTS.mcp, async (req: Request, res: Response) => {
11601228 // bearer credential in its own right. That is unchanged by this file's
11611229 // history — master does the same — and it is why the id is randomUUID() and
11621230 // never derived from anything guessable.
1163- const route = routeForSessionRequest ( {
1231+ const isInitialize = isInitializeRequest ( req . body ) ;
1232+ let route = routeForSessionRequest ( {
11641233 hasRuntime : existingRuntime !== null ,
11651234 sessionId,
1166- isInitialize : isInitializeRequest ( req . body ) ,
1235+ isInitialize,
11671236 } ) ;
11681237
1238+ // THE BINDING REFUSES USE OF A SESSION, NEVER CREATION OF ONE, and this line
1239+ // is here because the first version got that wrong in a way tests missed.
1240+ //
1241+ // Routing prefers a session we hold, so a re-authorized client sending
1242+ // `initialize` while its OLD session is still alive routes to 'use-existing'.
1243+ // Its new token does not match, so it was refused — and `initialize` is
1244+ // precisely the request that repairs the situation, so it was refused
1245+ // forever, for as long as the stale session lived. A loop with a 24-hour
1246+ // exit, caused by signing in again.
1247+ //
1248+ // My test for the escape hatch asserted `hasRuntime: false` and passed
1249+ // happily while the live-session case failed. It only showed up by driving a
1250+ // real re-authorization end to end.
1251+ //
1252+ // Falling through to 'create' rather than reusing the session is the correct
1253+ // half too: a different credential may be a different user or project, so
1254+ // handing it the old session's project binding would be worse than refusing.
1255+ if ( route === 'use-existing' && isInitialize && ! credentialMatchesSession ( req , sessionId ) ) {
1256+ route = 'create' ;
1257+ }
1258+
11691259 if ( route === 'use-existing' ) {
1260+ // A session we hold, so the 404 is already decided above and untouched.
1261+ if ( refuseMismatchedCredential ( req , res , sessionId ) ) return ;
1262+
11701263 transport = existingRuntime ! . transport ;
11711264 console . log ( '[Streamable HTTP] Using existing transport for session:' , sessionFingerprint ( sessionId ) ) ;
11721265 sessionManager . touchSession ( sessionId ) ;
@@ -1260,6 +1353,22 @@ app.post(STREAMABLE_HTTP_ENDPOINTS.mcp, async (req: Request, res: Response) => {
12601353 } ;
12611354 }
12621355
1356+ // GENERATED, NEVER TAKEN FROM THE REQUEST — and since the binding landed
1357+ // this line is load-bearing rather than incidental.
1358+ //
1359+ // The binding exempts `initialize` so a re-authorized client can recover.
1360+ // That exemption is only safe because the id created here cannot be chosen
1361+ // by the caller. If this ever honoured an incoming Mcp-Session-Id — to
1362+ // "preserve session ids across re-initialization", say — an attacker with
1363+ // their own perfectly valid credentials could initialize ONTO a victim's
1364+ // session id, overwrite the entry in the session map, and take the session
1365+ // over. The exemption would then hand them the exact thing the binding
1366+ // exists to prevent.
1367+ //
1368+ // Quinn went looking for that hole specifically and measured it closed:
1369+ // asked for 11111111-…, got a server-generated id, header ignored. There is
1370+ // a test pinning it, because "we happen to generate it" is not a property
1371+ // anyone would notice losing.
12631372 const newSessionId = randomUUID ( ) ;
12641373
12651374 transport = new StreamableHTTPServerTransport ( {
@@ -1326,6 +1435,10 @@ app.get(STREAMABLE_HTTP_ENDPOINTS.mcp, async (req: Request, res: Response) => {
13261435 } ) ;
13271436 }
13281437
1438+ // The stream is the quietest way to use a stolen id — it opens on the id
1439+ // alone and then just receives. Bound here, after the 404 above.
1440+ if ( refuseMismatchedCredential ( req , res , sessionId ) ) return ;
1441+
13291442 // This stream is the only sign of life for a client that opens it and then
13301443 // sends nothing. Hold the session for as long as it is open, and start the
13311444 // idle clock now rather than from whenever the last POST arrived.
@@ -1359,6 +1472,10 @@ app.delete(STREAMABLE_HTTP_ENDPOINTS.mcp, async (req: Request, res: Response) =>
13591472 } ) ;
13601473 }
13611474
1475+ // Destroying someone else's session is a one-request denial of service on
1476+ // the id alone. Bound here, after the 404 above.
1477+ if ( refuseMismatchedCredential ( req , res , sessionId ) ) return ;
1478+
13621479 try {
13631480 await runtime . transport . handleRequest ( req , res , req . body ) ;
13641481 } finally {
0 commit comments