History

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.

Sergio Fernández committed 2 months ago

Commit 45ba1411 parent e8e97c43 files · +207 −12Browse files

Unified · Split

3 files
  1. .changeset/mcp-pool-key-credential-retention.md +11 −0
  2. packages/plugins/mcp/src/sdk/connection-pool-key.test.ts +154 −0
  3. packages/plugins/mcp/src/sdk/plugin.ts +42 −12
.changeset/mcp-pool-key-credential-retention.md+11−0
@@ -0,0 +1,11 @@
1---
2"executor": patch
3---
4
5**The MCP connection pool no longer keeps credentials in its cache key**
6
7A 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
9The 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
11Nothing reads the key back — the pool only compares it, and it reaches no log, span or error message — so nothing observable changes.
packages/plugins/mcp/src/sdk/connection-pool-key.test.ts+154−0
@@ -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
24import { describe, expect, it } from "@effect/vitest";
25import { Effect } from "effect";
26
27import { connectionPoolKey } from "./plugin";
28import type { ConnectorInput } from "./connection";
29
30const SECRET = "sk-live-poolkey-Zq7!x-SECRET";
31const OTHER_SECRET = "sk-live-poolkey-Zq7!x-ROTATED";
32
33type RemoteInput = Extract<ConnectorInput, { readonly transport: "remote" }>;
34
35const 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
43describe("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+42−12
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
14 IntegrationSlug,14 IntegrationSlug,
15 mergeAuthTemplates,15 mergeAuthTemplates,
16 OAuthClientSlug,16 OAuthClientSlug,
17 sha256Hex,
17 tool,18 tool,
18 ToolResult,19 ToolResult,
19 type AuthMethodDescriptor,20 type AuthMethodDescriptor,
@@ -643,20 +644,45 @@ const sortedRecord = ( · 623 unmodified lines
643 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),644 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
644 );645 );
645 646
646const 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. */
670export const connectionPoolKey = (
647 input: Extract<ConnectorInput, { readonly transport: "remote" }>,671 input: Extract<ConnectorInput, { readonly transport: "remote" }>,
648 template: string,672 template: string,
649 values: Record<string, string | null>,673 values: Record<string, string | null>,
650): string =>674): Effect.Effect<string> =>
651 JSON.stringify({675 sha256Hex(
652 endpoint: input.endpoint,676 JSON.stringify({
653 transport: input.transport,677 endpoint: input.endpoint,
654 remoteTransport: input.remoteTransport,678 transport: input.transport,
655 headers: sortedRecord(input.headers),679 remoteTransport: input.remoteTransport,
656 queryParams: sortedRecord(input.queryParams),680 headers: sortedRecord(input.headers),
657 template,681 queryParams: sortedRecord(input.queryParams),
658 values: sortedRecord(values),682 template,
659 });683 values: sortedRecord(values),
684 }),
685 );
660 686
661// ---------------------------------------------------------------------------687// ---------------------------------------------------------------------------
662// Declared auth methods — project the stored MCP config into the catalog's688// Declared auth methods — project the stored MCP config into the catalog's
@@ -1330,7 +1356,11 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 667 unmodified lines
1330 const connector: McpConnector = createMcpConnector(connectorInput);1356 const connector: McpConnector = createMcpConnector(connectorInput);
1331 const poolKey =1357 const poolKey =
1332 connectorInput.transport === "remote"1358 connectorInput.transport === "remote"
1333 ? connectionPoolKey(connectorInput, String(credential.template), credential.values)1359 ? yield* connectionPoolKey(
1360 connectorInput,
1361 String(credential.template),
1362 credential.values,
1363 )
1334 : undefined;1364 : undefined;
1335 1365
1336 const connectionRef = {1366 const connectionRef = {