From 24664f2c5a2a6b047c862d52106e063ff2b4eb37 Mon Sep 17 00:00:00 2001 From: claytontavaresdan Date: Mon, 28 Sep 2026 14:12:15 +0700 Subject: [PATCH] fix(proxy): hold strictProxy when no proxy resolves --- open-sse/utils/proxyFetch.js | 18 +++++ src/lib/network/connectionProxy.js | 12 +++ tests/unit/strict-proxy-enforcement.test.js | 87 +++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 tests/unit/strict-proxy-enforcement.test.js diff --git a/open-sse/utils/proxyFetch.js b/open-sse/utils/proxyFetch.js index b5403fde..d7705c77 100644 --- a/open-sse/utils/proxyFetch.js +++ b/open-sse/utils/proxyFetch.js @@ -351,6 +351,24 @@ export async function proxyAwareFetch(url, options = {}, proxyOptions = null) { } } + // Strict mode means "never leave over the direct IP". Reaching here with a + // proxy configured but unresolved is exactly that case — an inactive or + // empty pool, or every proxy removed — so refuse instead of silently + // exposing the real address (#4333). The catch blocks above only cover a + // proxy that was actually tried. + // + // Gate on a proxy being *intended*: callers like the Qoder executor set + // strictProxy to mean "do not replay this request directly if the proxy + // fails" (a replayed COSY signature returns 403), not "a proxy is required". + // With nothing configured they must keep working. + const proxyIntended = proxyOptions?.proxyPoolId + || proxyOptions?.enabled === true + || proxyOptions?.connectionProxyEnabled === true + || !!normalizeString(proxyOptions?.url ?? proxyOptions?.connectionProxyUrl); + if (proxyOptions?.strictProxy === true && proxyIntended) { + throw new Error("[ProxyFetch] Proxy required but none resolved (strictProxy=true)"); + } + // got-scraping disabled — use native fetch directly // (Re-enable per-host by wrapping with tryGotScrapingFetch when needed) return originalFetch(url, options); diff --git a/src/lib/network/connectionProxy.js b/src/lib/network/connectionProxy.js index 9ecd2535..9d8846ba 100644 --- a/src/lib/network/connectionProxy.js +++ b/src/lib/network/connectionProxy.js @@ -77,6 +77,12 @@ export async function resolveConnectionProxyConfig( const legacy = normalizeLegacyProxy(providerSpecificData); + // A strict pool must keep its guarantee even when the pool itself is not + // usable (inactive, or saved without a url). Otherwise the unusable-pool + // path below reports strictProxy:false and the request silently leaves + // over the direct IP — the leak strict mode exists to prevent (#4333). + let poolStrictProxy = false; + /** * ----------------------------- * Proxy Pool Resolution @@ -93,6 +99,8 @@ export async function resolveConnectionProxyConfig( proxyPool.isActive === true && proxyUrl; + poolStrictProxy = proxyPool?.strictProxy === true; + if (isValidPool) { /** * Vercel/Cloudflare relay proxies use base URL rewriting @@ -148,6 +156,8 @@ export async function resolveConnectionProxyConfig( proxyPoolId: proxyPoolId || null, proxyPool: null, + strictProxy: poolStrictProxy, + ...legacy, }; } @@ -163,6 +173,8 @@ export async function resolveConnectionProxyConfig( proxyPoolId: proxyPoolId || null, proxyPool: null, + strictProxy: poolStrictProxy, + ...legacy, }; } catch (error) { diff --git a/tests/unit/strict-proxy-enforcement.test.js b/tests/unit/strict-proxy-enforcement.test.js new file mode 100644 index 00000000..81f46d03 --- /dev/null +++ b/tests/unit/strict-proxy-enforcement.test.js @@ -0,0 +1,87 @@ +// #4333: "Strict Proxy" did not hold. With a strict pool assigned and every +// proxy in it dead, requests still went out over the direct IP — the exact +// leak the setting exists to prevent. +// +// Two halves, one per layer: +// +// 1. resolveConnectionProxyConfig drops strictProxy whenever the pool is not +// usable (inactive, or saved with an empty proxyUrl). isValidPool gates the +// only two returns that carry strictProxy, so an unusable strict pool falls +// through to the legacy/none branches, which report strictProxy:false. +// +// 2. proxyAwareFetch only honours strictProxy inside the catch of a proxy +// attempt. When no proxy URL resolves there is nothing to try, so it +// reaches the trailing `return originalFetch(url, options)` and connects +// directly. +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/models", () => ({ + getProxyPoolById: vi.fn(), +})); + +const { getProxyPoolById } = await import("@/models"); +const { resolveConnectionProxyConfig } = await import("../../src/lib/network/connectionProxy.js"); +const { proxyAwareFetch } = await import("../../open-sse/utils/proxyFetch.js"); + +describe("strict pool keeps strictProxy when the pool is unusable (#4333)", () => { + it("keeps strictProxy for an inactive strict pool", async () => { + getProxyPoolById.mockResolvedValue({ + id: "p1", isActive: false, proxyUrl: "http://127.0.0.1:7890", strictProxy: true, + }); + const cfg = await resolveConnectionProxyConfig({ proxyPoolId: "p1" }); + expect(cfg.strictProxy).toBe(true); + }); + + it("keeps strictProxy for a strict pool saved without a proxy url", async () => { + getProxyPoolById.mockResolvedValue({ + id: "p2", isActive: true, proxyUrl: "", strictProxy: true, + }); + const cfg = await resolveConnectionProxyConfig({ proxyPoolId: "p2" }); + expect(cfg.strictProxy).toBe(true); + }); + + it("still reports strictProxy:false for a non-strict pool", async () => { + getProxyPoolById.mockResolvedValue({ + id: "p3", isActive: false, proxyUrl: "http://127.0.0.1:7890", strictProxy: false, + }); + const cfg = await resolveConnectionProxyConfig({ proxyPoolId: "p3" }); + expect(cfg.strictProxy).toBe(false); + }); + + it("still reports strictProxy:false when no pool is assigned", async () => { + const cfg = await resolveConnectionProxyConfig({}); + expect(cfg.strictProxy).toBe(false); + }); +}); + +describe("strictProxy refuses a direct connection (#4333)", () => { + it("throws when a pool is assigned but no proxy url resolved", async () => { + await expect( + proxyAwareFetch("https://api.example.com/v1/chat", {}, { proxyPoolId: "p1", strictProxy: true }), + ).rejects.toThrow(/strictProxy/); + }); + + it("throws when the pool is enabled but carries an empty url", async () => { + await expect( + proxyAwareFetch("https://api.example.com/v1/chat", {}, { enabled: true, url: "", strictProxy: true }), + ).rejects.toThrow(/strictProxy/); + }); + + it("does not block a caller that sets strictProxy with no proxy configured", async () => { + // The Qoder executor passes strictProxy:true to mean "do not replay this + // request directly if the proxy fails" — a replayed COSY signature gets a + // 403. With nothing configured it must still reach the network. + await expect( + proxyAwareFetch("https://nonexistent.invalid/v1/chat", {}, { strictProxy: true }), + ).rejects.not.toThrow(/strictProxy/); + }); + + it("does not block a request when strictProxy is off", async () => { + // No proxy, not strict: the call is allowed to reach the network layer. + // It fails on DNS here, which is fine — what matters is that the refusal + // is NOT the strictProxy guard. + await expect( + proxyAwareFetch("https://nonexistent.invalid/v1/chat", {}, { strictProxy: false }), + ).rejects.not.toThrow(/strictProxy/); + }); +});