fix(security): eliminate cross-tenant server-ref misroute in elicitation - #57
Merged
Merged
Conversation
The "server reference" used by elicitation helpers (elicitSelection / elicitText / elicitConfirmation) was stored in a module-level `let _server` singleton in src/utils/server-ref.ts, set synchronously per request via setServerRef() and read back later via getServerRef() -- including after await gaps inside async tool handlers. In gateway (multi-tenant HTTP) mode, a fresh Server is created per request, so two concurrent tenant requests could race through the shared global and misroute an elicitation/ confirmation prompt down the wrong tenant's connection. Replaces the singleton with an AsyncLocalStorage<Server> context (runWithServerRef for per-request transports -- Node HTTP, Workers -- and bindServerRef for the single-session stdio transport), mirroring the credentialStore pattern already used in this repo for Syncro credential isolation. There is now zero module-level mutable server/transport state. Identical bug and fix pattern as halopsa-mcp#65 (the reference PR for this 6-repo remediation wave); adapted to this repo's index.ts/worker.ts control flow, which otherwise matched halopsa's shape. Adds tests/utils/server-ref.test.ts: a deterministic forced-interleave regression test (manually-resolved gate promise, not a timer) with per-tenant mock Server value assertions and negative cross-checks. Verified to fail with the predicted wrong-tenant symptom against a reinstated module-singleton implementation, and to pass against the ALS-based fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Member
Author
Warden — Gate 3 verification: GREEN (template replication, verified not blind-pasted)
Tests: CI green. Ran myself: 14 files / 151 tests green, matches dev's count. Negative control: reinstated the module singleton behind identical function names — failed immediately: Verdict: GREEN — ship. This closes the secondary-class Gate-3 queue — all 5 (atera, datto-rmm, itglue, superops, syncro) verified. |
|
🎉 This PR is included in version 1.4.2 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
P1 security fix — replicated from the reference PR halopsa-mcp#65 (6-repo remediation wave, task_1784935505749). The identical bug exists byte-for-byte in this repo's
src/utils/server-ref.ts.The bug
The "server reference" pattern used by MCP elicitation/confirmation helpers (
src/utils/elicitation.ts:elicitSelection/elicitText/elicitConfirmation) was implemented as a module-level singleton:setServerRef(server)was called synchronously insidecreateMcpServer()(src/mcp-server.ts), which — in gateway/HTTP mode — is invoked once per inbound request (src/index.ts,src/worker.ts).getServerRef()was then read back later, deep inside async tool handlers, after arbitraryawaitgaps (e.g. after awaiting a Syncro API call, right before sending an elicitation/confirmation prompt).Misroute mechanism: two concurrent tenant requests race through the shared global:
setServerRef(serverA), and starts awaiting async work (e.g. a Syncro API call inside a tool handler).setServerRef(serverB)— overwriting the shared ref.elicitConfirmation(...), which readsgetServerRef()— and gets server B.Same shared-mutable-state-across-await-gaps bug class as the credential-leak fixes (liongard-mcp#58, ninjaone-mcp#71) and the identical server-ref fix in halopsa-mcp#65, but in this repo's server/transport routing subsystem for elicitation, not credential/token caching.
The fix
Replaced the module-level singleton with an
AsyncLocalStorage<Server>context insrc/utils/server-ref.ts, mirroring this repo's existing per-request credential isolation pattern (credentialStoreinsrc/utils/credential-store.ts):runWithServerRef(server, fn)— bindsserverto the async context for the duration offn(including everything it awaits/schedules). Used for per-request transports:src/index.ts(Node HTTPhandleMcp, wrapping theserver.connect(transport).then(...)chain) andsrc/worker.ts(Cloudflare WorkershandleMcp), one call per inbound request.bindServerRef(server)— bindsserverfor the remainder of the current + all future async execution, viaAsyncLocalStorage.enterWith. Used only insrc/index.ts's stdio path, which is single-session (one process = one caller, no concurrent tenants to isolate from).getServerRef()— reads from the ALS context instead of a module variable.createMcpServer()(src/mcp-server.ts) no longer callssetServerRefat all — binding is now the caller's responsibility, at the point where the per-request (or per-session) scope is established.There is zero module-level mutable state holding a tenant/request-specific server or transport reference after this fix.
Repo shape vs. the reference PR
This repo's
index.tsandworker.tscontrol flow matched halopsa-mcp#65's shape closely enough to apply the identical wrapping pattern directly:src/index.ts's Node HTTP path uses the sameserver.connect(transport).then(() => transport.handleRequest(req, res)).then()-chain style as halopsa's pre-fix code, and the same stdio single-session startup shape — both wrapped exactly as in the reference PR (runWithServerRefaround the.then()chain;bindServerRefonce at stdio startup).src/worker.ts'shandleMcpalready usedasync/awaitthroughout (no.then()), so it was wrapped withrunWithServerRef(server, async () => { ... })around the full connect/handleRequest/close body — same approach as halopsa's Workers entrypoint, adapted to this repo's existing structure (no.then()needed).No structural deviation was required — the same three call sites (Node HTTP handler, Workers
fetch, stdio startup) needed the same three bindings.Test
tests/utils/server-ref.test.tsadds a regression test meeting the 3-part standard (forced interleave, per-tenant value assertions, no swallowed assertions):runWithServerRef(serverA, ...)) and suspends onawait gate.promise— simulating an in-flight Syncro API call. Tenant B's entire request (bind, elicit, resolve) then runs to completion while A is still suspended — exactly like a second concurrent HTTP request racing in. Only then is the gate resolved and A allowed to resume and callelicitConfirmation.Serverwith its ownelicitInputspy (taggedtenantId). The test asserts, by value, that tenant A's message was received by tenant A's mock (serverA.elicitInput) and tenant B's by tenant B's mock — plus explicit negative checks that A's message never reached B's mock and vice versa.awaiting both tenant promises to completion; none are inside a callback that could silently never fire.Verified the test actually catches the bug: temporarily reinstated a module-singleton implementation behind the same function names (
runWithServerRef/bindServerRef/getServerRef) and reran just this test file:— the exact predicted misroute symptom: tenant A's resumed continuation reads tenant B's server. Restored the ALS-based fix and reran the full suite: green.
Test plan
npm run typecheck— cleannpm run lint— cleannpm run build— cleannpm test— 151/151 passing (147 pre-existing + 4 new inserver-ref.test.ts)expected 'tenant-B' to be 'tenant-A')### Security(Keep a Changelog format), matching the detail level of halopsa-mcp#65🤖 Generated with Claude Code