From 37469917f1eb45a26aa6d611599db0316076d177 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:26:39 +0900 Subject: [PATCH 1/2] fix(proxy): keep loopback traffic outside inherited SOCKS --- src/config/proxy-env.ts | 44 +++++++++++++++++++++++++--------- structure/config.md | 4 +++- tests/server/proxy-env.test.ts | 25 ++++++++++++++++--- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts index adbfe5f6176..e9285e93266 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,7 @@ export function applyProxyEnvWith( let proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined; if (!proxy) { if (rawProxy !== undefined) warnProxyConfigDiscardOnce("proxy"); + if (ambientProxyStateExists()) mergeNoProxyEntries(); configureSocks5Fetch(); return; } @@ -178,9 +210,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 +229,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..30ff4c9dcce 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,31 @@ 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("merges configured comma-separated noProxy entries", () => { From 7dd72d1d61adc9bf2af65a1e5951a6f697e605f9 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:03:44 +0900 Subject: [PATCH 2/2] fix(proxy): pin non-loopback SOCKS ownership and document the ambient noProxy scope --- src/config/proxy-env.ts | 4 ++++ tests/server/proxy-env.test.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts index e9285e93266..f096cd3885a 100644 --- a/src/config/proxy-env.ts +++ b/src/config/proxy-env.ts @@ -161,6 +161,10 @@ 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; diff --git a/tests/server/proxy-env.test.ts b/tests/server/proxy-env.test.ts index 30ff4c9dcce..ca1016b16cf 100644 --- a/tests/server/proxy-env.test.ts +++ b/tests/server/proxy-env.test.ts @@ -242,6 +242,20 @@ describe("applyProxyEnv", () => { 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", () => { applyProxyEnv(configWithProxy("http://proxy.corp:8080", "internal.example,10.0.0.0/8")); expect(process.env.NO_PROXY).toBe("internal.example,10.0.0.0/8,localhost,127.0.0.1,::1,[::1]");