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
18 changes: 11 additions & 7 deletions src/lib/provider-outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
resolvePublicAddresses,
} from "./destination-policy";
import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http";
import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env";
import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname } from "./proxy-env";
import { publicProviderBaseUrl } from "./provider-url";

type ProviderGetInit = Omit<RequestInit, "body" | "method" | "redirect">;
Expand Down Expand Up @@ -182,13 +182,17 @@ async function providerOutboundRequest(
return provider.fetch(url, { ...init, method, redirect: "manual" });
}
const parsed = postUrl ?? new URL(url);
const proxyConfigured = outboundProxyConfigured();
// Snapshot the scheme-matched proxy once, before the DNS await, so admission and transport
// below reason about the same value. `null` here means "no proxy fetch would actually use",
// even if some other proxy variable is set.
const effectiveProxy = effectiveProxyFor(parsed);
// The request leaves the DNS-pinned transport only when a proxy will actually carry it:
// a scheme-matched proxy variable that NO_PROXY does not exempt. A scheme-mismatched
// variable, a NO_PROXY match, or a non-SOCKS ALL_PROXY must not downgrade pinning or
// admit proxy-only DNS answers.
const proxyApplies = effectiveProxy !== null && !noProxyMatches(parsed);
const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false);
const allowMihomoIpv6FakeIp = (effectiveProxy !== null && !noProxyMatches(parsed))
const allowMihomoIpv6FakeIp = proxyApplies
|| transparentFakeIpException(url, parsed, isCanonicalUrl, name);
const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses;
const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet;
Expand All @@ -212,7 +216,7 @@ async function providerOutboundRequest(
// proof is on the final request URL — not the provider name — because an
// OAuth/forward name matches any baseUrl by design while the bearer is
// pinned to the registry destination independently.
allowBenchmarkAddresses: (proxyConfigured && !noProxyMatches(parsed))
allowBenchmarkAddresses: proxyApplies
|| transparentFakeIpException(url, parsed, isCanonicalUrl, name),
// Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted either when bound
// to a scheme-matched proxy (#3462) or under the TUN transparency exception for a
Expand All @@ -225,21 +229,21 @@ async function providerOutboundRequest(
if (!dnsResolutionFailed) {
throw new ProviderOutboundPolicyError(error instanceof Error ? error.message : "provider destination was blocked");
}
if (!proxyConfigured) throw error;
if (!proxyApplies) throw error;
warnProxyBoundaryOnce();
warnProxyDnsDegradationOnce();
return configuredOutboundFetch(url, { ...init, method, redirect: "manual" });
}
// A canonical TUN exception with no scheme-matched proxy must retain the
// validated address, even when an unrelated HTTP_PROXY/ALL_PROXY is present.
if (proxyConfigured && !resolved.privateNetwork && (effectiveProxy !== null || !allowMihomoIpv6FakeIp)) {
if (proxyApplies && !resolved.privateNetwork) {
warnProxyBoundaryOnce();
// When the Mihomo exception could have admitted an answer, pin the transport to the
// proxy the admission assumed instead of letting fetch re-infer it from the environment.
const proxy = (allowMihomoIpv6FakeIp && effectiveProxy) ? effectiveProxy : undefined;
return configuredOutboundFetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) });
}
if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) {
if (proxyApplies && resolved.privateNetwork) {
const hostname = normalizeProxyHostname(parsed.hostname);
throw new Error(
`provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`,
Expand Down
31 changes: 27 additions & 4 deletions src/lib/proxy-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,30 @@ export function outboundProxyConfigured(
return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env));
}

/**
* The value when `raw` is a proxy URL Bun fetch can actually use, else null.
* Bun rejects unparseable values and non-http(s) schemes (UnsupportedProxyProtocol),
* so admitting them as "the proxy that applies" would only downgrade DNS pinning.
*/
function usableHttpProxyUrl(raw: string | undefined): string | null {
if (!raw) return null;
try {
const scheme = new URL(raw).protocol;
return scheme === "http:" || scheme === "https:" ? raw : null;
} catch {
return null;
}
}

/**
* The proxy URL selected by configured outbound fetch for `url`, or null when none applies.
*
* Bun selects by scheme: `HTTPS_PROXY` for `https:` targets, `HTTP_PROXY` for `http:`.
* A SOCKS5 `ALL_PROXY` is selected by the explicit wrapper first; other ALL_PROXY
* schemes remain excluded because the native HTTP fetch does not honor them.
* A SOCKS5 `ALL_PROXY` is selected by the explicit wrapper first. A non-SOCKS
* `ALL_PROXY` is still honoured by the native fetch for plain `http:` targets on
* POSIX (the e2e suite proves the request reaches the proxy there); on Windows the
* native fetch does not consult `ALL_PROXY` at all, and for `https:` targets the
* SOCKS wrapper remains the only `ALL_PROXY` route.
* Presence of *some* proxy variable (`outboundProxyConfigured`) is not that guarantee.
*/
export function effectiveProxyFor(
Expand All @@ -107,8 +125,13 @@ export function effectiveProxyFor(
// The installed SOCKS wrapper takes this route before Bun sees scheme proxies.
const socksProxy = socks5ProxyFromEnv(env);
if (socksProxy) return socksProxy;
const value = env[key]?.trim() || env[key.toLowerCase()]?.trim();
return value ? value : null;
const value = usableHttpProxyUrl(env[key]?.trim() || env[key.toLowerCase()]?.trim());
if (value) return value;
if (url.protocol === "http:" && process.platform !== "win32") {
const allProxy = usableHttpProxyUrl(env.ALL_PROXY?.trim() || env.all_proxy?.trim());
if (allProxy) return allProxy;
}
return null;
}

export function isSocks5ProxyUrl(proxy: string): boolean {
Expand Down
103 changes: 102 additions & 1 deletion tests/providers/provider-outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,95 @@ describe("provider outbound GET transport", () => {
}
});

test("scheme-mismatched proxy variables keep the DNS-pinned transport", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = mock(async () => new Response("unexpected", { status: 500 })) as typeof fetch;
globalThis.fetch = fetchMock;
try {
for (const { url, proxyKey } of [
{ url: "http://provider.example/v1/models", proxyKey: "HTTPS_PROXY" },
{ url: "https://provider.example/v1/models", proxyKey: "HTTP_PROXY" },
] as const) {
for (const key of proxyKeys) delete process.env[key];
process.env[proxyKey] = "http://127.0.0.1:9";
const { providerOutboundGet } = await import("../../src/lib/provider-outbound");
const resolveOptions: { allowBenchmarkAddresses?: boolean }[] = [];
const { dependencies, captured } = directDependencies(new Response(null, { status: 204 }));
dependencies.resolveAddresses = mock(async (_url: string, options?: { allowBenchmarkAddresses?: boolean }) => {
resolveOptions.push({ allowBenchmarkAddresses: options?.allowBenchmarkAddresses });
return {
hostname: "provider.example",
addresses: [{ address: "93.184.216.34", family: 4 }],
privateNetwork: false,
};
}) as ProviderOutboundDependencies["resolveAddresses"];

const response = await providerOutboundGet(
"custom",
{ baseUrl: new URL(url).origin + "/v1" },
url,
{},
dependencies,
);

expect(response.status).toBe(204);
expect(captured.address).toBe("93.184.216.34");
expect(resolveOptions).toEqual([{ allowBenchmarkAddresses: false }]);
}
expect(fetchMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});

test("a NO_PROXY match keeps the request on the DNS-pinned transport", async () => {
for (const key of proxyKeys) delete process.env[key];
process.env.HTTPS_PROXY = "http://127.0.0.1:9";
process.env.NO_PROXY = "provider.example";
const originalFetch = globalThis.fetch;
const fetchMock = mock(async () => new Response("unexpected", { status: 500 })) as typeof fetch;
globalThis.fetch = fetchMock;
try {
const { providerOutboundGet } = await import("../../src/lib/provider-outbound");
const { dependencies, captured } = directDependencies(new Response(null, { status: 204 }));

const response = await providerOutboundGet(
"custom",
{ baseUrl: "https://provider.example/v1" },
"https://provider.example/v1/models",
{},
dependencies,
);

expect(response.status).toBe(204);
expect(captured.address).toBe("93.184.216.34");
expect(fetchMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});

test("a scheme-mismatched proxy variable does not demand NO_PROXY for private providers", async () => {
for (const key of proxyKeys) delete process.env[key];
process.env.HTTP_PROXY = "http://127.0.0.1:9";
const { providerOutboundGet } = await import("../../src/lib/provider-outbound");
const { dependencies, captured } = directDependencies(new Response(null, { status: 200 }), {
privateNetwork: true,
address: "192.168.1.50",
});

const response = await providerOutboundGet(
"ollama-lan",
{ baseUrl: "https://ollama.lan:11434/v1", allowPrivateNetwork: true },
"https://ollama.lan:11434/v1/models",
{},
dependencies,
);

expect(response.status).toBe(200);
expect(captured.address).toBe("192.168.1.50");
});

test("built-in ollama admits loopback discovery without an explicit allowPrivateNetwork flag (#758)", async () => {
for (const key of proxyKeys) delete process.env[key];
const { providerOutboundGet } = await import("../../src/lib/provider-outbound");
Expand Down Expand Up @@ -544,7 +633,7 @@ describe("#3462 Mihomo IPv6 fake-IP admission is gated on the scheme-matched pro
});

describe("effectiveProxyFor picks the variable Bun fetch actually honours", () => {
test("scheme-matched selection; HTTP ALL_PROXY is never consulted", async () => {
test("scheme-matched selection; HTTP ALL_PROXY only counts for http: targets", async () => {
const { effectiveProxyFor } = await import("../../src/lib/proxy-env");
const https = new URL("https://opencode.ai/zen/v1/models");
const http = new URL("http://ollama.lan:11434/v1/models");
Expand All @@ -555,6 +644,18 @@ describe("effectiveProxyFor picks the variable Bun fetch actually honours", () =
expect(effectiveProxyFor(https, { ALL_PROXY: "socks5://127.0.0.1:1080" })).toBe("socks5://127.0.0.1:1080");
expect(effectiveProxyFor(http, { HTTP_PROXY: "http://p:5" })).toBe("http://p:5");
expect(effectiveProxyFor(http, { HTTPS_PROXY: "http://p:6" })).toBeNull();
// Bun's native fetch honours a non-SOCKS ALL_PROXY for plain http: targets on
// POSIX but not on Windows; https: targets only ever use the socks5 wrapper.
expect(effectiveProxyFor(http, { ALL_PROXY: "http://p:7" }))
.toBe(process.platform === "win32" ? null : "http://p:7");
expect(effectiveProxyFor(http, { ALL_PROXY: "ftp://p:8" })).toBeNull();
expect(effectiveProxyFor(http, { ALL_PROXY: "http://" })).toBeNull();
// A malformed or non-http(s) scheme-matched variable is not a proxy Bun fetch
// can use either: it must not count as "the proxy that applies".
expect(effectiveProxyFor(http, { HTTP_PROXY: "http://" })).toBeNull();
expect(effectiveProxyFor(http, { HTTP_PROXY: "not a url" })).toBeNull();
expect(effectiveProxyFor(https, { HTTPS_PROXY: "http://" })).toBeNull();
expect(effectiveProxyFor(https, { HTTPS_PROXY: "socks5://p:9" })).toBeNull();
expect(effectiveProxyFor(https, { HTTPS_PROXY: " " })).toBeNull();
expect(effectiveProxyFor(new URL("ftp://x/"), { HTTPS_PROXY: "http://p:7", HTTP_PROXY: "http://p:7" })).toBeNull();
});
Expand Down
Loading