History

safeguard against session id leaking

Rhys Sullivan committed 6 months ago

Commit 54139751 parent c5475223 files · +108 −15Browse files

Unified · Split

3 files
  1. apps/cloud/src/mcp-flow.test.ts +45 −4
  2. apps/cloud/src/mcp-session.ts +33 −1
  3. apps/cloud/src/mcp.ts +30 −10
apps/cloud/src/mcp-flow.test.ts+45−4
@@ -251,10 +251,11 @@ describe("/mcp unknown session id", () => { · 250 unmodified lines
251251 describe("/mcp notification responses", () => {
252252 it("returns 202 with an empty body for notifications/initialized", async () => {
253253 const orgId = nextOrgId();
254+ const accountId = nextAccountId();
254255 await seedOrg(orgId);
255256
256257 const initializeResponse = await mcpPost({
257− bearer: makeTestBearer(nextAccountId(), orgId),
258+ bearer: makeTestBearer(accountId, orgId),
258259 body: INITIALIZE_REQUEST,
259260 });
260261 expect(initializeResponse.status).toBe(200);
@@ -262,7 +263,7 @@ describe("/mcp notification responses", () => { · 1 unmodified line
262263 expect(sessionId).toBeTruthy();
263264
264265 const notificationResponse = await mcpPost({
265− bearer: makeTestBearer(nextAccountId(), orgId),
266+ bearer: makeTestBearer(accountId, orgId),
266267 sessionId,
267268 body: INITIALIZED_NOTIFICATION,
268269 });
@@ -276,10 +277,11 @@ describe("/mcp notification responses", () => { · 7 unmodified lines
276277 describe("/mcp session restore", () => {
277278 it("restores an initialized SDK transport from durable storage", async () => {
278279 const orgId = nextOrgId();
280+ const accountId = nextAccountId();
279281 await seedOrg(orgId);
280282
281283 const initializeResponse = await mcpPost({
282− bearer: makeTestBearer(nextAccountId(), orgId),
284+ bearer: makeTestBearer(accountId, orgId),
283285 body: INITIALIZE_REQUEST,
284286 });
285287 expect(initializeResponse.status).toBe(200);
@@ -295,7 +297,7 @@ describe("/mcp session restore", () => { · 9 unmodified lines
295297 });
296298
297299 const response = await mcpPost({
298− bearer: makeTestBearer(nextAccountId(), orgId),
300+ bearer: makeTestBearer(accountId, orgId),
299301 sessionId,
300302 body: TOOLS_LIST_REQUEST,
301303 });
@@ -307,6 +309,45 @@ describe("/mcp session restore", () => { · 5 unmodified lines
307309 expect(body.jsonrpc).toBe("2.0");
308310 expect(body.result?.tools?.some((tool) => tool.name === "execute")).toBe(true);
309311 }, 15_000);
312+
313+ it("reproduces cross-account session reuse via leaked mcp-session-id", async () => {
314+ const victimOrgId = nextOrgId();
315+ const attackerOrgId = nextOrgId();
316+ const victimAccountId = nextAccountId();
317+ const attackerAccountId = nextAccountId();
318+ await seedOrg(victimOrgId);
319+
320+ const initializeResponse = await mcpPost({
321+ bearer: makeTestBearer(victimAccountId, victimOrgId),
322+ body: INITIALIZE_REQUEST,
323+ });
324+ expect(initializeResponse.status).toBe(200);
325+ const sessionId = initializeResponse.headers.get("mcp-session-id");
326+ expect(sessionId).toBeTruthy();
327+
328+ const ns = env.MCP_SESSION;
329+ const stub = ns.get(ns.idFromString(sessionId!));
330+ await runInDurableObject(stub, async (instance) => {
331+ await Effect.runPromise(
332+ (instance as unknown as { closeRuntime: () => Effect.Effect<void> }).closeRuntime(),
333+ );
334+ });
335+
336+ const response = await mcpPost({
337+ bearer: makeTestBearer(attackerAccountId, attackerOrgId),
338+ sessionId,
339+ body: TOOLS_LIST_REQUEST,
340+ });
341+
342+ expect(response.status).toBe(403);
343+ const body = (await response.json()) as {
344+ readonly jsonrpc: string;
345+ readonly error?: { readonly code: number; readonly message: string };
346+ };
347+ expect(body.jsonrpc).toBe("2.0");
348+ expect(body.error?.code).toBe(-32003);
349+ expect(body.error?.message).toMatch(/does not belong/i);
350+ }, 15_000);
310351 });
311352
312353 describe("McpSessionDO alarm lifecycle", () => {
apps/cloud/src/mcp-session.ts+33−1
@@ -55,6 +55,8 @@ const LONG_LIVED_DB_MAX_LIFETIME_SECONDS = 120; · 54 unmodified lines
5555 const TRANSPORT_STATE_KEY = "transport";
5656 const SESSION_META_KEY = "session-meta";
5757 const LAST_ACTIVITY_KEY = "last-activity-ms";
58+const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id";
59+const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id";
5860
5961 // ---------------------------------------------------------------------------
6062 // Errors
@@ -74,6 +76,9 @@ const jsonRpcError = (status: number, code: number, message: string) => · 13 unmodified lines
7476 headers: { "content-type": "application/json" },
7577 });
7678
79+const sessionOwnerMismatch = () =>
80+ jsonRpcError(403, -32003, "MCP session does not belong to the current bearer");
81+
7782 // W3C propagation across the worker→DO boundary. mcp.ts injects the worker's
7883 // `traceparent` and forwards incoming `tracestate` / `baggage` headers on
7984 // forwarded requests (and as a second arg to `init()`). We parse the context
@@ -371,6 +376,25 @@ export class McpSessionDO extends DurableObject { · 291 unmodified lines
371376 );
372377 }
373378
379+ private validateSessionOwner(request: Request): Effect.Effect<Response | null> {
380+ return Effect.gen(this, function* () {
381+ const sessionMeta = yield* this.loadSessionMeta();
382+ if (!sessionMeta) return null;
383+
384+ const accountId = request.headers.get(INTERNAL_ACCOUNT_ID_HEADER);
385+ const organizationId = request.headers.get(INTERNAL_ORGANIZATION_ID_HEADER);
386+ const matches =
387+ accountId === sessionMeta.userId &&
388+ organizationId === sessionMeta.organizationId;
389+
390+ yield* Effect.annotateCurrentSpan({
391+ "mcp.session.owner_match": matches,
392+ });
393+
394+ return matches ? null : sessionOwnerMismatch();
395+ }).pipe(Effect.withSpan("mcp.session.validate_owner"));
396+ }
397+
374398 private resolveAndStoreSessionMeta(token: McpSessionInit) {
375399 const self = this;
376400 return Effect.gen(function* () {
@@ -504,6 +528,14 @@ export class McpSessionDO extends DurableObject { · 127 unmodified lines
504528 }
505529
506530 private dispatchRequest(request: Request): Effect.Effect<Response> {
531+ return Effect.gen(this, function* () {
532+ const ownerError = yield* this.validateSessionOwner(request);
533+ if (ownerError) return ownerError;
534+ return yield* this.dispatchAuthorizedRequest(request);
535+ });
536+ }
537+
538+ private dispatchAuthorizedRequest(request: Request): Effect.Effect<Response> {
507539 if (!this.initialized || !this.transport) {
508540 if (request.method === "DELETE") {
509541 return this.clearSessionState().pipe(
@@ -514,7 +546,7 @@ export class McpSessionDO extends DurableObject { · 4 unmodified lines
514546 return Effect.gen(this, function* () {
515547 const restored = yield* this.restoreRuntimeFromStorage(request);
516548 if (restored === "restored") {
517− return yield* this.dispatchRequest(request);
549+ return yield* this.dispatchAuthorizedRequest(request);
518550 }
519551 return jsonRpcError(404, -32001, "Session timed out due to inactivity — please reconnect");
520552 });
apps/cloud/src/mcp.ts+30−10
@@ -32,6 +32,8 @@ const RESOURCE_ORIGIN = env.MCP_RESOURCE_ORIGIN ?? "https://executor.sh"; · 31 unmodified lines
3232 const jwks = createRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`));
3333
3434 const BEARER_PREFIX = "Bearer ";
35+const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id";
36+const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id";
3537
3638 const CORS_ALLOW_ORIGIN = { "access-control-allow-origin": "*" } as const;
3739
@@ -544,6 +546,13 @@ const withPropagationHeaders = ( · 506 unmodified lines
544546 return new Request(request, { headers });
545547 };
546548
549+const withVerifiedIdentityHeaders = (request: Request, token: VerifiedToken): Request => {
550+ const headers = new Headers(request.headers);
551+ headers.set(INTERNAL_ACCOUNT_ID_HEADER, token.accountId);
552+ headers.set(INTERNAL_ORGANIZATION_ID_HEADER, token.organizationId ?? "");
553+ return new Request(request, { headers });
554+};
555+
547556 const withMcpResponseHeaders = (response: Response): Response => {
548557 const headers = new Headers(response.headers);
549558 headers.set("access-control-allow-origin", "*");
@@ -560,12 +569,20 @@ const withMcpResponseHeaders = (response: Response): Response => { · 10 unmodified lines
560569 * with `HttpServerResponse.raw` lets streaming bodies (SSE) pass through
561570 * `HttpApp.toWebHandler`'s conversion unchanged.
562571 */
563−const forwardToExistingSession = (request: Request, sessionId: string, peek: boolean) =>
572+const forwardToExistingSession = (
573+ request: Request,
574+ sessionId: string,
575+ peek: boolean,
576+ token: VerifiedToken,
577+) =>
564578 Effect.gen(function* () {
565579 const ns = env.MCP_SESSION;
566580 const stub = ns.get(ns.idFromString(sessionId));
567581 const propagation = yield* currentPropagationHeaders(request);
568− const propagated = withPropagationHeaders(request, propagation);
582+ const propagated = withPropagationHeaders(
583+ withVerifiedIdentityHeaders(request, token),
584+ propagation,
585+ );
569586 const raw = yield* Effect.promise(
570587 () => stub.handleRequest(propagated) as Promise<Response>,
571588 ).pipe(
@@ -588,7 +605,7 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 16 unmodified lines
588605 }
589606
590607 const sessionId = request.headers.get("mcp-session-id");
591− if (sessionId) return yield* forwardToExistingSession(request, sessionId, true);
608+ if (sessionId) return yield* forwardToExistingSession(request, sessionId, true, token);
592609
593610 const ns = env.MCP_SESSION;
594611 const stub = ns.get(ns.newUniqueId());
@@ -600,7 +617,10 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 5 unmodified lines
600617 attributes: { "mcp.request.session_id_present": false },
601618 }),
602619 );
603− const propagated = withPropagationHeaders(request, propagation);
620+ const propagated = withPropagationHeaders(
621+ withVerifiedIdentityHeaders(request, token),
622+ propagation,
623+ );
604624 const raw = yield* Effect.promise(
605625 () => stub.handleRequest(propagated) as Promise<Response>,
606626 ).pipe(
@@ -615,16 +635,16 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 8 unmodified lines
615635 return HttpServerResponse.raw(withMcpResponseHeaders(annotated));
616636 });
617637
618−const dispatchGet = (request: Request) => {
638+const dispatchGet = (request: Request, token: VerifiedToken) => {
619639 const sessionId = request.headers.get("mcp-session-id");
620640 if (!sessionId) return Effect.succeed(jsonRpcError(400, -32000, "mcp-session-id header required for SSE"));
621− return forwardToExistingSession(request, sessionId, false);
641+ return forwardToExistingSession(request, sessionId, false, token);
622642 };
623643
624−const dispatchDelete = (request: Request) => {
644+const dispatchDelete = (request: Request, token: VerifiedToken) => {
625645 const sessionId = request.headers.get("mcp-session-id");
626646 if (!sessionId) return Effect.succeed(HttpServerResponse.empty({ status: 204 }));
627− return forwardToExistingSession(request, sessionId, true);
647+ return forwardToExistingSession(request, sessionId, true, token);
628648 };
629649
630650 // ---------------------------------------------------------------------------
@@ -680,9 +700,9 @@ export const mcpApp: Effect.Effect< · 49 unmodified lines
680700 case "POST":
681701 return yield* dispatchPost(request, token);
682702 case "GET":
683− return yield* dispatchGet(request);
703+ return yield* dispatchGet(request, token);
684704 case "DELETE":
685− return yield* dispatchDelete(request);
705+ return yield* dispatchDelete(request, token);
686706 default:
687707 return jsonRpcError(405, -32001, "Method not allowed");
688708 }