fix(builder): reap idle /mcp sessions and cap sessions per identity - close the anonymous session-exhaustion vector

This commit is contained in:
FilipBl4gojevic
2026-07-31 14:54:11 +02:00
parent 035c16a085
commit 97c7145791
2 changed files with 262 additions and 3 deletions

View File

@@ -12,9 +12,21 @@ import type { RawServerHandle } from '../setup.ts';
// auth-gating.test.ts covers. Same two things at stake, same reason no unit
// test can see them: the exact status-code/header wiring against a real
// spawned server (401 checked before session lookup, 404 on an unknown
// session, 403 on cross-identity), and whether streamableTransports /
// streamableIdentities actually drain on both cleanup paths
// (onsessionclosed - the DELETE path - and transport.onclose).
// session, 403 on cross-identity - on both POST and GET), and whether
// streamableTransports/streamableIdentities/streamableLastSeen actually
// drain on every teardown path: DELETE (onsessionclosed, then
// transport.onclose - redundant with each other on that one path), the idle
// reaper, and the per-identity session cap's eviction (the latter two only
// ever go through transport.onclose - see index.ts's own comment on that
// callback for why onsessionclosed does not cover them). The reaper and the
// cap exist because the SSE pair self-limits on an abrupt disconnect
// (res.on('close') drains its maps with no help from us) and /mcp does not:
// nothing tells the server a caller dropped its socket without DELETE, so an
// un-reaped session - transport, McpServer, and its 24 tools - would
// otherwise sit in memory until process exit. Both bounds are exercised
// below with env-shrunk thresholds (VECTOR_MCP_SESSION_IDLE_MS /
// VECTOR_MCP_SESSION_SWEEP_MS / VECTOR_MCP_MAX_SESSIONS_PER_IDENTITY), never
// the real multi-minute defaults.
//
// Mixed transport strategy, deliberately:
// - Case 1 uses the real SDK Client + StreamableHTTPClientTransport, to prove
@@ -213,6 +225,23 @@ describe('streamable HTTP gating - MCP_AUTH_TOKENS set', () => {
assert.ok(!r.body.includes(TOKEN_ALICE) && !r.body.includes(TOKEN_BOB), 'no token value echoed in the 401 body');
});
test('a real, valid session id with no Authorization header is still rejected with 401, not 403 or 404 (auth checked before session lookup)', async () => {
// Ordering pin, same class as auth-gating.test.ts's "POST /messages with
// no Authorization header is rejected with 401, not 400 or 404": uses a
// session id that genuinely exists (not a bogus one), so a passing result
// cannot be explained away as "it 404'd because the session doesn't
// exist" - it specifically proves the auth check runs, and short-circuits,
// before the session map is ever consulted.
const init = record(await postMcp(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` }, initializeRequest()));
assert.equal(init.status, 200);
const sessionId = init.headers['mcp-session-id'] as string | undefined;
assert.ok(sessionId);
const r = record(await postMcp(ctx.port, { 'mcp-session-id': sessionId! }, toolsListRequest()));
assert.equal(r.status, 401);
assert.equal(r.headers['www-authenticate'], 'Bearer');
});
test('cross-identity: a valid token for a different identity than the session owner is rejected with 403; the owner is accepted', async () => {
const init = record(await postMcp(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` }, initializeRequest()));
assert.equal(init.status, 200);
@@ -257,6 +286,27 @@ describe('streamable HTTP gating - MCP_AUTH_TOKENS set', () => {
noSession.destroy();
});
test('GET with a valid session but a different identity token is rejected with 403, independent of the POST cross-identity path', async () => {
// The cross-identity test above only ever exercises POST. The route's
// identity check runs before transport.handleRequest() regardless of
// method, so this pins that it is not accidentally POST-only wiring.
const init = record(await postMcp(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` }, initializeRequest()));
assert.equal(init.status, 200);
const sessionId = init.headers['mcp-session-id'] as string | undefined;
assert.ok(sessionId);
// GET's error responses (unlike its 200 open-stream response) are short,
// res.end()-terminated bodies - safe to read to completion with
// rawRequest, same as the POST cross-identity case.
const crossed = record(await rawRequest(ctx.port, 'GET', '/mcp', {
Authorization: `Bearer ${TOKEN_BOB}`,
'mcp-session-id': sessionId!,
Accept: 'text/event-stream',
}));
assert.equal(crossed.status, 403);
assert.equal(crossed.body, 'This session belongs to a different identity.');
});
test('DELETE terminates the session; a follow-up request with that session id 404s (the maps drain via onsessionclosed)', async () => {
const init = record(await postMcp(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` }, initializeRequest()));
assert.equal(init.status, 200);
@@ -277,6 +327,12 @@ describe('streamable HTTP gating - MCP_AUTH_TOKENS set', () => {
assert.equal(followUp.status, 404);
});
test('DELETE with no session id is rejected with 400', async () => {
const r = record(await rawRequest(ctx.port, 'DELETE', '/mcp', { Authorization: `Bearer ${TOKEN_ALICE}` }));
assert.equal(r.status, 400);
assert.equal(r.body, 'Missing mcp-session-id header');
});
test('no token value leaked into any captured response body, response header, or stderr', () => {
const stderr = ctx.stderr();
for (const body of seenBodies) {
@@ -378,3 +434,94 @@ describe('streamable HTTP gating - per-IP separation for anonymous callers', ()
assert.equal(crossedIdentity.status, 403, 'a session opened under one anonymous identity must reject a request whose recomputed identity differs');
});
});
describe('streamable HTTP gating - idle session reaper', () => {
let ctx: RawServerHandle;
before(async () => {
// Idle bound and sweep cadence both shrunk far below their real defaults
// (10 minutes / 60 seconds) so this test proves the reaper in under a
// second instead of sleeping anywhere near the production window. The
// worst case for when a sweep can first observe this session as idle is
// roughly idleMs + sweepMs after creation (a sweep tick can land just
// before the session is created); the wait below is 2x that worst case.
ctx = await spawnServer({ ...AUTH_ENV, VECTOR_MCP_SESSION_IDLE_MS: '400', VECTOR_MCP_SESSION_SWEEP_MS: '100' });
});
after(async () => {
await stopRawServer(ctx);
});
test('a session idle past the threshold is reaped on the next sweep; the maps drain the same way DELETE does', async () => {
const init = await postMcp(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` }, initializeRequest());
assert.equal(init.status, 200);
const sessionId = init.headers['mcp-session-id'] as string | undefined;
assert.ok(sessionId);
// No request of any kind against this session in between - lastSeen never
// bumps, so it goes idle starting from the moment initialize returned.
await new Promise((resolveWait) => setTimeout(resolveWait, 1000));
const followUp = await postMcp(
ctx.port,
{ Authorization: `Bearer ${TOKEN_ALICE}`, 'mcp-session-id': sessionId! },
toolsListRequest(),
);
assert.equal(followUp.status, 404, 'an idle-reaped session must 404 - streamableTransports/streamableIdentities drained, same observable proof as the DELETE case');
});
});
describe('streamable HTTP gating - per-identity session cap', () => {
let ctx: RawServerHandle;
before(async () => {
// Cap shrunk to 2 so the third session for the same identity is enough to
// force an eviction, well within a hermetic test's budget.
ctx = await spawnServer({ ...AUTH_ENV, VECTOR_MCP_MAX_SESSIONS_PER_IDENTITY: '2' });
});
after(async () => {
await stopRawServer(ctx);
});
test('opening a session beyond the per-identity cap evicts the oldest; the newest sessions survive', async () => {
const first = await postMcp(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` }, initializeRequest());
assert.equal(first.status, 200);
const firstId = first.headers['mcp-session-id'] as string | undefined;
assert.ok(firstId);
const second = await postMcp(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` }, initializeRequest());
assert.equal(second.status, 200);
const secondId = second.headers['mcp-session-id'] as string | undefined;
assert.ok(secondId);
assert.notEqual(secondId, firstId);
// alice is now at the cap (2 live sessions). A third session for the SAME
// identity must evict the oldest (first) before it is created.
const third = await postMcp(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` }, initializeRequest());
assert.equal(third.status, 200);
const thirdId = third.headers['mcp-session-id'] as string | undefined;
assert.ok(thirdId);
const firstFollowUp = await postMcp(
ctx.port,
{ Authorization: `Bearer ${TOKEN_ALICE}`, 'mcp-session-id': firstId! },
toolsListRequest(),
);
assert.equal(firstFollowUp.status, 404, 'the oldest session must have been evicted once the identity hit its session cap');
const secondFollowUp = await postMcp(
ctx.port,
{ Authorization: `Bearer ${TOKEN_ALICE}`, 'mcp-session-id': secondId! },
toolsListRequest(),
);
assert.equal(secondFollowUp.status, 200, 'the second-oldest session must still be alive');
const thirdFollowUp = await postMcp(
ctx.port,
{ Authorization: `Bearer ${TOKEN_ALICE}`, 'mcp-session-id': thirdId! },
toolsListRequest(),
);
assert.equal(thirdFollowUp.status, 200, 'the newest session must still be alive');
});
});