Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 37 additions & 11 deletions src/config/proxy-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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();
}
4 changes: 3 additions & 1 deletion structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 36 additions & 3 deletions tests/server/proxy-env.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading