diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts index adbfe5f6176..f096cd3885a 100644 --- a/src/config/proxy-env.ts +++ b/src/config/proxy-env.ts @@ -98,6 +98,37 @@ function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements } } +// Loopback only has a proxy to bypass when the environment already carries proxy state. +// Writing NO_PROXY into a proxy-free process is itself a proxy-env mutation that callers +// observe (the lab sandbox rejects any of these keys as a forbidden leak), so the +// early-return merge runs only when one is already present. +const PROXY_STATE_ENV_KEYS = [ + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "all_proxy", "no_proxy", +] as const; + +function ambientProxyStateExists(): boolean { + for (const key of PROXY_STATE_ENV_KEYS) { + const value = process.env[key]; + if (value !== undefined && value !== "") return true; + } + return false; +} + +function mergeNoProxyEntries(configured: string[] = []): void { + const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? ""; + const entries = existing.split(",").map(s => s.trim()).filter(Boolean); + const seen = new Set(entries.map(entry => entry.toLowerCase())); + for (const host of [...configured, "localhost", "127.0.0.1", "::1", "[::1]"]) { + const key = host.toLowerCase(); + if (!seen.has(key)) { + entries.push(host); + seen.add(key); + } + } + process.env.NO_PROXY = entries.join(","); +} + /** * Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports * such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY @@ -130,6 +161,11 @@ export function applyProxyEnvWith( let proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined; if (!proxy) { if (rawProxy !== undefined) warnProxyConfigDiscardOnce("proxy"); + // Ambient-proxy path: only loopback bypasses are appended. A configured noProxy is + // deliberately NOT merged here — with no config.proxy the operator's bypass list has + // no declared proxy to apply against, and merging it would silently widen direct + // egress beyond the loopback fix this branch exists for. + if (ambientProxyStateExists()) mergeNoProxyEntries(); configureSocks5Fetch(); return; } @@ -178,9 +214,6 @@ export function applyProxyEnvWith( if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy; } } - const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? ""; - const entries = existing.split(",").map(s => s.trim()).filter(Boolean); - const seen = new Set(entries.map(e => e.toLowerCase())); // Configured entries first, then loopback: loopback is unconditional, so appending it last // keeps it present even when the operator lists a loopback host themselves. const raw = config.noProxy; @@ -200,13 +233,6 @@ export function applyProxyEnvWith( const configured = configuredEntries .map(entry => entry.trim()) .filter(Boolean); - for (const host of [...configured, "localhost", "127.0.0.1", "::1", "[::1]"]) { - const key = host.toLowerCase(); - if (!seen.has(key)) { - entries.push(host); - seen.add(key); - } - } - process.env.NO_PROXY = entries.join(","); + mergeNoProxyEntries(configured); configureSocks5Fetch(); } diff --git a/structure/config.md b/structure/config.md index 12cf9acbd10..3a8f573bb29 100644 --- a/structure/config.md +++ b/structure/config.md @@ -542,7 +542,9 @@ Stored Direct substitution follows the [credential identity contract](providers/ configuration. An explicit SOCKS5 or SOCKS5h URL selects ALL_PROXY and removes stale scheme-proxy variables; HTTP(S) settings retain their existing environment precedence. Activation keeps the existing Windows auto-discovery path and loopback -NO_PROXY entries. When the environment no longer selects SOCKS, activation +NO_PROXY entries; the no-configured-proxy return merges them only when the environment +already carries proxy state, leaving a proxy-free process untouched. When the +environment no longer selects SOCKS, activation restores the native fetch; removing a saved field alone does not erase inherited process environment variables. SOCKS4 is rejected instead of being advertised as a working transport. diff --git a/tests/server/proxy-env.test.ts b/tests/server/proxy-env.test.ts index 340454dcfb6..ca1016b16cf 100644 --- a/tests/server/proxy-env.test.ts +++ b/tests/server/proxy-env.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createServer } from "node:http"; import { applyProxyEnv } from "../../src/config"; -import { resolveProxyRoute, configureSocks5Fetch } from "../../src/lib/proxy-env"; +import { configuredOutboundFetch, resolveProxyRoute, configureSocks5Fetch } from "../../src/lib/proxy-env"; import type { OcxConfig } from "../../src/types"; const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "OCX_TEST_PROXY_REF", "OCX_TEST_NO_PROXY_REF"] as const; @@ -215,12 +215,45 @@ describe("applyProxyEnv with values the schema does not constrain", () => { }); describe("applyProxyEnv", () => { - test("no-op when config.proxy is unset", () => { + test("writes no proxy state into an environment that has none", () => { + applyProxyEnv(configWithProxy()); + for (const key of PROXY_ENV_KEYS) { + expect(process.env[key]).toBeUndefined(); + } + }); + + test("keeps mandatory loopback exclusions when config.proxy is unset", () => { process.env.NO_PROXY = "operator-owned.example"; applyProxyEnv(configWithProxy(undefined, "internal.example")); expect(process.env.HTTP_PROXY).toBeUndefined(); expect(process.env.HTTPS_PROXY).toBeUndefined(); - expect(process.env.NO_PROXY).toBe("operator-owned.example"); + expect(process.env.NO_PROXY).toBe("operator-owned.example,localhost,127.0.0.1,::1,[::1]"); + }); + + test.each(["ALL_PROXY", "all_proxy"])("inherited SOCKS %s cannot intercept loopback fetches", async key => { + process.env[key] = "socks5://untrusted-proxy.invalid:1080"; + applyProxyEnv(configWithProxy()); + let directCalls = 0; + const response = await configuredOutboundFetch("http://127.0.0.1:11434/v1/chat/completions", undefined, async () => { + directCalls += 1; + return new Response("direct"); + }); + expect(await response.text()).toBe("direct"); + expect(directCalls).toBe(1); + }); + + test.each(["ALL_PROXY", "all_proxy"])("inherited SOCKS %s still owns non-loopback fetches", async key => { + process.env[key] = "socks5://untrusted-proxy.invalid:1080"; + applyProxyEnv(configWithProxy()); + // The bypass must be scoped to loopback only: a non-loopback URL still routes through + // the inherited SOCKS proxy, which fails here because the proxy is unreachable. The + // direct fallback must NOT be consulted — if it were, the bypass leaked. + let directCalls = 0; + await expect(configuredOutboundFetch("http://api.example.com/v1/chat/completions", undefined, async () => { + directCalls += 1; + return new Response("direct"); + })).rejects.toThrow(); + expect(directCalls).toBe(0); }); test("merges configured comma-separated noProxy entries", () => {