fix(proxy): add combo scope to fail-closed proxy guard (#13551)

* 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 <koosha@phenotype.ai>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Koosha Paridehpour
2026-09-17 06:44:13 -07:00
committed by GitHub
parent 2dae6df518
commit 28557418db
4 changed files with 75 additions and 6 deletions

View File

@@ -23,7 +23,11 @@ export function isGlobalProxyEnabled(db: ReturnType<typeof getDbInstance>): 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;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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({