Skip to content

Commit 78d854e

Browse files
authored
Merge pull request #99 from InsForge/fix/a-session-id-is-not-enough
A session id alone is not enough, on all three verbs
2 parents 68776d6 + 2968584 commit 78d854e

5 files changed

Lines changed: 417 additions & 5 deletions

File tree

src/http/oauth-manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ const AUTH_CODE_TTL = 5 * 60; // 5 minutes
9393
/**
9494
* Generate a hash of the token for storage
9595
*/
96-
function hashToken(token: string): string {
96+
export function hashToken(token: string): string {
9797
return createHash('sha256').update(token).digest('hex');
9898
}
9999

src/http/platform-paths.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { readFileSync } from 'fs';
3+
import { join } from 'path';
4+
5+
/**
6+
* The check that would have caught the revoke bug, and the one nothing had.
7+
*
8+
* `revokePlatformToken` called `/oauth/v1/revoke`. The platform serves that at
9+
* `/api/oauth/v1/revoke` and answers 404 at the other, so revoke would have
10+
* failed every time in production — while passing every test, because the stub
11+
* matched `url.includes('/oauth/v1/revoke')`, which is true of both. Nothing
12+
* else could catch it either: the reachable production probes
13+
* (not-our-token -> 200) return before the upstream call, and CI never touches
14+
* the platform.
15+
*
16+
* Iris proposed a live probe against api.insforge.dev. That verifies more than
17+
* this does — it confirms the constants are CORRECT, not merely consistent —
18+
* and it is worth having. But it couples CI to the platform being reachable,
19+
* which makes a green build depend on someone else's uptime. This is the half
20+
* that needs no network and no uptime, so it can run on every push:
21+
*
22+
* the platform has two path families, and every URL must come from the
23+
* constant that names its family rather than from a hand-typed prefix.
24+
*
25+
* That is exactly the mistake that was made. `${INSFORGE_API_BASE}/oauth/...`
26+
* type-checks, reads correctly, and is wrong.
27+
*/
28+
29+
const source = readFileSync(join(__dirname, 'insforge-api.ts'), 'utf8');
30+
31+
/** Every template-literal URL passed to platformFetch. */
32+
function platformUrls(): string[] {
33+
return [...source.matchAll(/platformFetch\(\s*`([^`]+)`/g)].map((m) => m[1]);
34+
}
35+
36+
describe('platform URL construction', () => {
37+
it('finds the calls at all, so this test cannot pass by matching nothing', () => {
38+
// The meta-check. A regex that silently stops matching turns this file into
39+
// a green light that asserts nothing — the same vacuous-check trap as a
40+
// ratio that can never disagree.
41+
expect(platformUrls().length).toBeGreaterThanOrEqual(7);
42+
});
43+
44+
it('builds every OAuth URL from OAUTH_API_BASE, never by hand', () => {
45+
// `${INSFORGE_API_BASE}/oauth/v1/revoke` is a 404 on the real platform.
46+
// It compiles, it reads correctly, and it is wrong.
47+
const handBuiltOAuth = platformUrls().filter(
48+
(u) => /oauth/i.test(u) && !u.startsWith('${OAUTH_API_BASE}')
49+
);
50+
expect(handBuiltOAuth).toEqual([]);
51+
});
52+
53+
it('builds every non-OAuth URL from INSFORGE_API_BASE at the root', () => {
54+
// The other family: /auth/v1, /organizations/v1, /projects/v1 — all at the
55+
// root, and all 404 under /api. Measured, not assumed.
56+
const wrong = platformUrls().filter(
57+
(u) => !/oauth/i.test(u) && !u.startsWith('${INSFORGE_API_BASE}/')
58+
);
59+
expect(wrong).toEqual([]);
60+
});
61+
62+
it('never puts an /api prefix on a root-family URL', () => {
63+
// /api/auth/v1/profile is a 404. This is the inverse of the revoke bug and
64+
// just as invisible to a reader.
65+
const prefixed = platformUrls().filter((u) => u.startsWith('${INSFORGE_API_BASE}/api/'));
66+
expect(prefixed).toEqual([]);
67+
});
68+
69+
it('routes every outbound platform call through platformFetch', () => {
70+
// The timeout lives in that wrapper, so a bare `fetch(` here is a call with
71+
// no bound — which is how the callback exchange sat unclassified in
72+
// server.ts until it was moved into this file.
73+
//
74+
// EXACTLY ONE is correct: platformFetch's own call, the line that applies
75+
// the AbortSignal. A second one is someone adding a platform call that
76+
// skips the timeout, which is the failure this asserts against — and if
77+
// this ever reads 0, the wrapper itself has been refactored away and the
78+
// bound with it.
79+
const bare = [...source.matchAll(/(?<!platformF|F)etch\(/g)]
80+
.length;
81+
expect(bare, 'expected only platformFetch to call fetch directly').toBe(1);
82+
});
83+
});
84+
85+
/**
86+
* A property that only became load-bearing when the binding landed.
87+
*
88+
* #99 exempts `initialize` from the credential check so a re-authorized client
89+
* can recover. That is safe for exactly one reason: the session id created by
90+
* the create path is generated, never taken from the request. If it were ever
91+
* derived from the incoming `Mcp-Session-Id`, an attacker holding their own
92+
* valid credentials could initialize ONTO a victim's session id, overwrite the
93+
* entry in the session map and take the session over — the exemption handing
94+
* them the very thing the binding prevents.
95+
*
96+
* Quinn measured it closed (asked for 11111111-…, got a server-generated id).
97+
* This pins it, because "we happen to generate it" is not a property anyone
98+
* would notice losing in review.
99+
*/
100+
describe('the create path never adopts a caller-supplied session id', () => {
101+
const server = readFileSync(join(__dirname, 'server.ts'), 'utf8');
102+
103+
it('generates the new session id with randomUUID', () => {
104+
expect(server).toMatch(/const newSessionId = randomUUID\(\);/);
105+
});
106+
107+
it('feeds the transport that generated id and nothing from the request', () => {
108+
const generators = [...server.matchAll(/sessionIdGenerator:\s*([^,\n]+)/g)].map((m) => m[1].trim());
109+
// The meta-check: if this stops matching, the assertion below is vacuous.
110+
expect(generators.length).toBeGreaterThanOrEqual(1);
111+
// `() => sessionId` — the request's id — is the mutation this forbids.
112+
expect(generators).toEqual(generators.map(() => '() => newSessionId'));
113+
});
114+
});

src/http/server.ts

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
88
import { 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';
1414
import {
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

Comments
 (0)