Skip to content

Commit 45ba141

Browse files
Keep credentials out of the MCP connection pool's cache key (#1573)
The pool key describing a remote MCP session's identity embedded the connection's resolved credential values, and the headers and query params those same secrets had already been rendered into. That key is retained as a Map key for the pool's lifetime, so the secret stayed readable in process memory long after the call that needed it, with nothing left to read it. Hash the whole serialised identity instead. Equal identities still produce equal keys, so reuse is unchanged, and a rotated token, a different rendered auth header or a query-param credential each still dial a fresh session. Hashing everything rather than the fields known to be sensitive means a field added later is covered without anyone having to remember it carries a secret. SHA-256 rather than a cheap hash on purpose: a collision would mean reusing a connection authenticated as somebody else. Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent e8e97c4 commit 45ba141

3 files changed

Lines changed: 207 additions & 12 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
**The MCP connection pool no longer keeps credentials in its cache key**
6+
7+
A pooled remote MCP session is looked up by a key describing the connection's identity, and that key included the connection's resolved credential values — plus the headers and query params those same secrets had already been rendered into. The key is retained as a `Map` key for the pool's lifetime, so the secret stayed readable in process memory long after the call that needed it had finished, with nothing left to read it.
8+
9+
The key is now the SHA-256 digest of that identity rather than the identity itself. Reuse is unchanged, because equal identities still produce equal keys, and separation is unchanged too: a rotated access token, a different rendered auth header and a credential carried in a query param each still dial a fresh session instead of reusing one authenticated as somebody else. Hashing the whole identity rather than only the fields known to be sensitive means a field added later is covered without anyone having to remember it carries a secret.
10+
11+
Nothing reads the key back — the pool only compares it, and it reaches no log, span or error message — so nothing observable changes.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// ---------------------------------------------------------------------------
2+
// MCP connection-pool key
3+
//
4+
// The key decides which pooled session a call may reuse, and it is retained as
5+
// a `Map` key for the POOL's lifetime — much longer than the call that produced
6+
// it. Two things therefore have to hold at once, and they pull in opposite
7+
// directions:
8+
//
9+
// * it must still SEPARATE identities — a different credential value, or a
10+
// different rendered auth header, must never reuse somebody else's
11+
// authenticated session;
12+
// * it must not RETAIN the credential — the secret that distinguishes two
13+
// identities must not survive in the key that distinguishes them.
14+
//
15+
// A digest satisfies both. These tests pin both halves, because a change that
16+
// satisfied only the second (say, dropping the credential from the key) would
17+
// look like a privacy improvement and be a session-hijack bug.
18+
//
19+
// The pool itself composes on top: it is a `Map` keyed by this string, so
20+
// "different key" ⇒ "different session" is the pool's own property, covered in
21+
// `connection-pool.test.ts`.
22+
// ---------------------------------------------------------------------------
23+
24+
import { describe, expect, it } from "@effect/vitest";
25+
import { Effect } from "effect";
26+
27+
import { connectionPoolKey } from "./plugin";
28+
import type { ConnectorInput } from "./connection";
29+
30+
const SECRET = "sk-live-poolkey-Zq7!x-SECRET";
31+
const OTHER_SECRET = "sk-live-poolkey-Zq7!x-ROTATED";
32+
33+
type RemoteInput = Extract<ConnectorInput, { readonly transport: "remote" }>;
34+
35+
const remoteInput = (overrides: Partial<RemoteInput> = {}): RemoteInput => ({
36+
transport: "remote",
37+
endpoint: "https://mcp.example.com/sse",
38+
remoteTransport: "streamable-http",
39+
headers: { authorization: `Bearer ${SECRET}` },
40+
...overrides,
41+
});
42+
43+
describe("MCP connection-pool key", () => {
44+
it.effect("is a bare SHA-256 digest — no plaintext rides along", () =>
45+
Effect.gen(function* () {
46+
const key = yield* connectionPoolKey(remoteInput(), "bearer", { token: SECRET });
47+
48+
// Asserted positively as well as negatively: "does not contain the
49+
// secret" alone would still pass for a key that appended the digest to
50+
// the plaintext identity.
51+
expect(key).toMatch(/^[0-9a-f]{64}$/);
52+
expect(key).not.toContain(SECRET);
53+
expect(key).not.toContain(`Bearer ${SECRET}`);
54+
expect(key).not.toContain("mcp.example.com");
55+
}),
56+
);
57+
58+
it.effect("the same identity keeps producing the same key, so reuse is unchanged", () =>
59+
Effect.gen(function* () {
60+
const first = yield* connectionPoolKey(remoteInput(), "bearer", { token: SECRET });
61+
const second = yield* connectionPoolKey(remoteInput(), "bearer", { token: SECRET });
62+
63+
expect(first).toBe(second);
64+
}),
65+
);
66+
67+
it.effect("a rotated credential value produces a different key", () =>
68+
Effect.gen(function* () {
69+
// The case this field exists for: a refreshed access token must dial a
70+
// fresh session rather than reuse one authenticated with the old token.
71+
const before = yield* connectionPoolKey(remoteInput({ headers: {} }), "bearer", {
72+
token: SECRET,
73+
});
74+
const after = yield* connectionPoolKey(remoteInput({ headers: {} }), "bearer", {
75+
token: OTHER_SECRET,
76+
});
77+
78+
expect(after).not.toBe(before);
79+
}),
80+
);
81+
82+
it.effect("a different rendered auth header produces a different key", () =>
83+
Effect.gen(function* () {
84+
// `buildConnectorInput` renders apikey placements onto `headers`, so the
85+
// same secret reaches the key by a second route. Separation has to hold
86+
// there too.
87+
const mine = yield* connectionPoolKey(
88+
remoteInput({ headers: { authorization: `Bearer ${SECRET}` } }),
89+
"bearer",
90+
{},
91+
);
92+
const theirs = yield* connectionPoolKey(
93+
remoteInput({ headers: { authorization: `Bearer ${OTHER_SECRET}` } }),
94+
"bearer",
95+
{},
96+
);
97+
98+
expect(theirs).not.toBe(mine);
99+
}),
100+
);
101+
102+
it.effect("a credential carried in a query param separates too", () =>
103+
Effect.gen(function* () {
104+
// Servers that authenticate via `?token=` put the secret here instead.
105+
const mine = yield* connectionPoolKey(
106+
remoteInput({ headers: {}, queryParams: { token: SECRET } }),
107+
"query",
108+
{},
109+
);
110+
const theirs = yield* connectionPoolKey(
111+
remoteInput({ headers: {}, queryParams: { token: OTHER_SECRET } }),
112+
"query",
113+
{},
114+
);
115+
116+
expect(theirs).not.toBe(mine);
117+
expect(mine).not.toContain(SECRET);
118+
}),
119+
);
120+
121+
it.effect("insertion order does not split one identity into two", () =>
122+
Effect.gen(function* () {
123+
// `sortedRecord` exists for this: a key that changed with property order
124+
// would silently dial a new session per call and never reuse anything.
125+
const oneWay = yield* connectionPoolKey(
126+
remoteInput({ headers: { authorization: `Bearer ${SECRET}`, "x-team": "acme" } }),
127+
"bearer",
128+
{ token: SECRET, region: "eu" },
129+
);
130+
const otherWay = yield* connectionPoolKey(
131+
remoteInput({ headers: { "x-team": "acme", authorization: `Bearer ${SECRET}` } }),
132+
"bearer",
133+
{ region: "eu", token: SECRET },
134+
);
135+
136+
expect(otherWay).toBe(oneWay);
137+
}),
138+
);
139+
140+
it.effect("a different endpoint or template separates identities", () =>
141+
Effect.gen(function* () {
142+
const base = yield* connectionPoolKey(remoteInput(), "bearer", { token: SECRET });
143+
const otherEndpoint = yield* connectionPoolKey(
144+
remoteInput({ endpoint: "https://mcp.other.example.com/sse" }),
145+
"bearer",
146+
{ token: SECRET },
147+
);
148+
const otherTemplate = yield* connectionPoolKey(remoteInput(), "apikey", { token: SECRET });
149+
150+
expect(otherEndpoint).not.toBe(base);
151+
expect(otherTemplate).not.toBe(base);
152+
}),
153+
);
154+
});

packages/plugins/mcp/src/sdk/plugin.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
IntegrationSlug,
1515
mergeAuthTemplates,
1616
OAuthClientSlug,
17+
sha256Hex,
1718
tool,
1819
ToolResult,
1920
type AuthMethodDescriptor,
@@ -643,20 +644,45 @@ const sortedRecord = (
643644
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
644645
);
645646

646-
const connectionPoolKey = (
647+
/** The pooled remote connection's identity, as an opaque digest.
648+
*
649+
* HASHED, not carried in the clear, because three of the fields below hold a
650+
* live credential: `values` is the connection's resolved secret inputs, and
651+
* `headers` / `queryParams` are those SAME secrets already rendered onto the
652+
* outbound request by `buildConnectorInput`. The result is retained as a `Map`
653+
* key for the POOL's lifetime (`connection-pool.ts`), which outlives by far the
654+
* call that needed the secret — so a plaintext key leaves credentials sitting
655+
* in process memory with no reader.
656+
*
657+
* Hashing the WHOLE serialized identity rather than only the fields known to
658+
* be sensitive keeps equality exactly (same identity → same digest, so reuse is
659+
* unchanged) and keeps any field added later covered without anyone having to
660+
* remember it carries a secret. Nothing reads the key back: the pool only ever
661+
* compares it, and it reaches no log, span or error message.
662+
*
663+
* SHA-256 rather than a cheap non-cryptographic hash on purpose. A collision
664+
* means reusing a connection authenticated as somebody else, so the hash has to
665+
* be one an attacker who controls their own credential values cannot aim.
666+
*
667+
* Exported for tests (not re-exported from `sdk/index.ts`, so this widens no
668+
* public API): the retention property is a property of the KEY, and asserting
669+
* it through pool behaviour alone would not see it. */
670+
export const connectionPoolKey = (
647671
input: Extract<ConnectorInput, { readonly transport: "remote" }>,
648672
template: string,
649673
values: Record<string, string | null>,
650-
): string =>
651-
JSON.stringify({
652-
endpoint: input.endpoint,
653-
transport: input.transport,
654-
remoteTransport: input.remoteTransport,
655-
headers: sortedRecord(input.headers),
656-
queryParams: sortedRecord(input.queryParams),
657-
template,
658-
values: sortedRecord(values),
659-
});
674+
): Effect.Effect<string> =>
675+
sha256Hex(
676+
JSON.stringify({
677+
endpoint: input.endpoint,
678+
transport: input.transport,
679+
remoteTransport: input.remoteTransport,
680+
headers: sortedRecord(input.headers),
681+
queryParams: sortedRecord(input.queryParams),
682+
template,
683+
values: sortedRecord(values),
684+
}),
685+
);
660686

661687
// ---------------------------------------------------------------------------
662688
// Declared auth methods — project the stored MCP config into the catalog's
@@ -1330,7 +1356,11 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => {
13301356
const connector: McpConnector = createMcpConnector(connectorInput);
13311357
const poolKey =
13321358
connectorInput.transport === "remote"
1333-
? connectionPoolKey(connectorInput, String(credential.template), credential.values)
1359+
? yield* connectionPoolKey(
1360+
connectorInput,
1361+
String(credential.template),
1362+
credential.values,
1363+
)
13341364
: undefined;
13351365

13361366
const connectionRef = {

0 commit comments

Comments
 (0)