mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 22:02:19 +03:00
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:
committed by
GitHub
parent
2dae6df518
commit
28557418db
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user