From 28557418dbd619e368da1bb934a6a16bc96b0376 Mon Sep 17 00:00:00 2001 From: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:44:13 -0700 Subject: [PATCH] fix(proxy): add combo scope to fail-closed proxy guard (#13551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(proxy): add combo scope to fail-closed proxy guard (fixes #13469) The hasBlockingProxyAssignment guard only checked account, provider, and global scopes. Combo-scoped proxy assignments were not checked, so a fully dead combo pool fell through to direct egress — leaking the host IP. - Add combo scope to the SQL guard query - Add optional comboName parameter to hasBlockingProxyAssignment - A dead combo pool now blocks egress like the other three scopes * fix(proxy): thread comboName through safeResolveProxy to the combo-scope guard (#13469) hasBlockingProxyAssignment() gained a comboName parameter and a combo-scope SQL clause, but its only caller, safeResolveProxy() in chatHelpers.ts, never passed it — the clause always bound NULL and never matched a real combo scope_id, so a fully dead combo-scoped proxy pool still fell through to direct egress. Thread comboName from handleSingleModelChat (where it is already in scope) through safeResolveProxy into the guard, and add tests covering both the guard predicate and the end-to-end wiring. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Koosha Pari Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- src/lib/db/proxies/guards.ts | 13 ++++- src/sse/handlers/chat.ts | 7 ++- src/sse/handlers/chatHelpers.ts | 5 +- .../proxy-assigned-unavailable-6246.test.ts | 56 +++++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/lib/db/proxies/guards.ts b/src/lib/db/proxies/guards.ts index 1032e5b47c..3265e432cc 100644 --- a/src/lib/db/proxies/guards.ts +++ b/src/lib/db/proxies/guards.ts @@ -23,7 +23,11 @@ export function isGlobalProxyEnabled(db: ReturnType): bool * #6246 fail-closed guard for a connection with an assigned dead proxy pool. * Explicitly disabling proxying globally or for the connection allows direct egress. */ -export function hasBlockingProxyAssignment(connectionId: string, providerId?: string): boolean { +export function hasBlockingProxyAssignment( + connectionId: string, + providerId?: string, + comboName?: string | null +): boolean { try { const db = getDbInstance(); if (!isGlobalProxyEnabled(db)) return false; @@ -33,16 +37,19 @@ export function hasBlockingProxyAssignment(connectionId: string, providerId?: st .get(connectionId) as { provider?: string | null; proxy_enabled?: number } | undefined; if (conn && conn.proxy_enabled === 0) return false; const provider = conn?.provider ?? providerId ?? null; + // #13469: include combo scope — a fully dead combo-scoped pool must not + // fall through to direct egress. const dead = db .prepare( `SELECT 1 FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE ((a.scope = 'account' AND a.scope_id = ?) OR (a.scope = 'provider' AND a.scope_id = ?) - OR (a.scope = 'global')) + OR (a.scope = 'global') + OR (a.scope = 'combo' AND a.scope_id = ?)) AND NOT ${PROXY_ALIVE_PREDICATE} LIMIT 1` ) - .get(connectionId, provider); + .get(connectionId, provider, comboName ?? null); return !!dead; } catch { return false; diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index bca9f349d2..a377853d1b 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -1935,7 +1935,12 @@ async function handleSingleModelChat( } let proxyInfo; try { - proxyInfo = await safeResolveProxy(credentials.connectionId, apiKeyInfo?.id, provider); + proxyInfo = await safeResolveProxy( + credentials.connectionId, + apiKeyInfo?.id, + provider, + comboName + ); } catch (error) { releaseOAuthSession(); throw error; diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index d86e3fc242..841b1db11e 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -991,7 +991,8 @@ export function decideProxyResolutionFailure( export async function safeResolveProxy( connectionId: string, apiKeyId?: string, - providerId?: string + providerId?: string, + comboName?: string | null ) { try { const resolved = await resolveProxyForConnection(connectionId, apiKeyId, providerId); @@ -1001,7 +1002,7 @@ export async function safeResolveProxy( // opts back into direct). Explicit "proxy off" is not a leak (see the guard). if ( !(resolved as { proxy?: unknown } | null)?.proxy && - hasBlockingProxyAssignment(connectionId, providerId) + hasBlockingProxyAssignment(connectionId, providerId, comboName) ) { return decideProxyResolutionFailure( Object.assign( diff --git a/tests/unit/proxy-assigned-unavailable-6246.test.ts b/tests/unit/proxy-assigned-unavailable-6246.test.ts index 0f829b25e8..de73573f44 100644 --- a/tests/unit/proxy-assigned-unavailable-6246.test.ts +++ b/tests/unit/proxy-assigned-unavailable-6246.test.ts @@ -25,6 +25,7 @@ process.env.API_KEY_SECRET = "test-secret"; const core = await import("../../src/lib/db/core.ts"); const proxiesDb = await import("../../src/lib/db/proxies.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); +const { safeResolveProxy } = await import("../../src/sse/handlers/chatHelpers.ts"); async function resetStorage() { core.resetDbInstance(); @@ -140,6 +141,61 @@ test("BLOCKS: a dead GLOBAL proxy assignment blocks any connection", async () => ); }); +test("BLOCKS: a dead COMBO-scoped proxy assignment when the combo name is threaded through (#13469)", async () => { + await resetStorage(); + const connId = await makeConnection(); + const proxy = await proxiesDb.createProxy({ + name: "Dead combo proxy", + type: "http", + host: "127.0.0.1", + port: 9006, + }); + await proxiesDb.updateProxy(proxy!.id, { status: "inactive" }); + await proxiesDb.assignProxyToScope("combo", "my-combo", proxy!.id); + + assert.equal( + proxiesDb.hasBlockingProxyAssignment(connId, "openai", "my-combo"), + true, + "a dead combo-scoped proxy must block when the combo name is passed" + ); + assert.equal( + proxiesDb.hasBlockingProxyAssignment(connId, "openai", "some-other-combo"), + false, + "a dead combo-scoped proxy must NOT block a request routed under a different combo" + ); + assert.equal( + proxiesDb.hasBlockingProxyAssignment(connId, "openai"), + false, + "without a combo name (e.g. a non-combo request), the combo-scoped assignment is irrelevant" + ); +}); + +test("WIRING #13469: safeResolveProxy fails closed end-to-end for a dead combo-scoped pool", async () => { + await resetStorage(); + const connId = await makeConnection(); + const proxy = await proxiesDb.createProxy({ + name: "Dead combo proxy (wiring)", + type: "http", + host: "127.0.0.1", + port: 9007, + }); + await proxiesDb.updateProxy(proxy!.id, { status: "inactive" }); + await proxiesDb.assignProxyToScope("combo", "wired-combo", proxy!.id); + + await assert.rejects( + () => safeResolveProxy(connId, undefined, "openai", "wired-combo"), + (err: unknown) => (err as { code?: string }).code === "PROXY_ASSIGNED_UNAVAILABLE", + "safeResolveProxy must thread comboName through to the guard and block instead of leaking direct egress" + ); + + const resolved = await safeResolveProxy(connId, undefined, "openai"); + assert.deepEqual( + resolved, + { proxy: null, level: "direct", levelId: null }, + "the combo scope must not affect a plain (non-combo) request on the same connection" + ); +}); + test("BLOCKS: a dead no-auth provider proxy assignment", async () => { await resetStorage(); const proxy = await proxiesDb.createProxy({