diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 1f0e16f007..318d762e3c 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -1020,18 +1020,34 @@ async function getProviderSearchPool(provider: string): Promise { // #4421) -- and back. A connection created via the bare generic type // (e.g. "openai-compatible-chat") must still be found when the chat path // looks up the concrete node id, and vice versa. + // + // #10434: both bridging directions MUST require the derived type to be + // unambiguous (exactly one provider node of that type) before falling + // back to a generic-type match -- an explicit ownership check, not just + // a string-format coincidence. This mirrors the exact rule already + // enforced by selectProviderNodeForConnection() for connection CREATION + // (src/lib/db/providerNodeSelect.ts, #4421): "only when exactly one such + // node exists, so an ambiguous type never silently picks the wrong + // node". Without this guard on the generic->concrete direction, a bare + // generic-type lookup would pool in EVERY node sharing that derived + // type, including a connection scoped (via its own providerSpecificData + // baseUrl/headers) to one specific node -- leaking that node's + // credentials/upstream URL into a lookup for a different, unrelated + // node of the same generic type. const derivedType = nodeTypeFromId(nodeId); if (derivedType && derivedType !== nodeId) { const typeIsUnambiguous = nodeTypes.get(derivedType) === 1; - if (nodeId === provider || nodeId === canonicalProvider || nodeId === canonicalAlias) { - if (typeIsUnambiguous) searchPool.add(derivedType); - } - if ( - derivedType === provider || - derivedType === canonicalProvider || - derivedType === canonicalAlias - ) { - searchPool.add(nodeId); + if (typeIsUnambiguous) { + if (nodeId === provider || nodeId === canonicalProvider || nodeId === canonicalAlias) { + searchPool.add(derivedType); + } + if ( + derivedType === provider || + derivedType === canonicalProvider || + derivedType === canonicalAlias + ) { + searchPool.add(nodeId); + } } } } diff --git a/tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts b/tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts index 40df04c963..cf1a72ad7e 100644 --- a/tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts +++ b/tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts @@ -190,3 +190,54 @@ test("the bridge does not make unrelated generic types findable", async () => { // A different generic type (responses, not chat) must stay unrelated. assert.equal(await auth.getProviderCredentials("openai-compatible-responses"), null); }); + +// #10434 -- the ambiguity guard added for #10085 was only applied to the +// concrete-id -> generic-type direction (`getProviderSearchPool`'s first +// bridging branch). The generic-type -> concrete-id direction (second +// branch) added every node sharing the derived type to the search pool +// UNCONDITIONALLY, with no ambiguity check. `selectProviderNodeForConnection` +// (src/lib/db/providerNodeSelect.ts, #4421) already established the +// project-wide rule for this exact generic-type fallback: "only when exactly +// one such node exists, so an ambiguous type never silently picks the wrong +// node". `getProviderSearchPool` must apply that SAME rule symmetrically in +// both directions -- otherwise a bare generic-type lookup (e.g. resolved by +// some caller without a concrete node id) silently pools in a connection +// that is scoped to one specific node's baseUrl/headers, sending traffic to +// the wrong upstream with the wrong credentials whenever a second node of +// the same generic type exists. +test( + "a bare generic-type lookup must not leak a node-scoped connection when the " + + "type is ambiguous across multiple nodes (#10434)", + async () => { + await resetStorage(); + await seedNode(); + await seedSecondNode(); + // Connection is scoped to node A specifically (stored under A's concrete + // uuid id, with A's own baseUrl) -- NOT under the bare generic type. + await providersDb.createProviderConnection({ + provider: NODE_ID, + authType: "apikey", + apiKey: "sk-test-10434-node-a", + name: "test-compat-10434-node-a", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" }, + }); + + // A lookup by the BARE generic type (no concrete node id) must not + // resolve to node A's connection: two nodes (A and B) share the derived + // type "openai-compatible-chat", so the generic type is ambiguous and + // must not silently pick node A's credentials/baseUrl. + const creds = await auth.getProviderCredentials("openai-compatible-chat"); + + assert.equal( + creds, + null, + "a bare generic-type lookup resolved to node A's node-scoped connection even " + + "though the type is ambiguous (node B also derives 'openai-compatible-chat') -- " + + "this can route a request meant for a different node through node A's baseUrl " + + "and credentials." + ); + } +);