From e3bbf5321c6c0483e9662466e044545bb0e086ba Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 22:27:35 +0900 Subject: [PATCH] feat(outbound): route Clash fake-IP DNS answers through the configured proxy (#1748) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped re-implementation of PR #1748 per the campaign disposition (REDESIGN-SMALL, outbound-only): - resolvePublicAddresses gains an explicit allowBenchmarkAddresses opt-in: a HOSTNAME answer in 198.18.0.0/15 (IANA benchmark space, the Clash/Surge/Mihomo fake-IP DNS range) is accepted without marking the destination private. Literal 198.18.x URLs still reject, mixed answers containing any other non-public address still reject, and callers that do not pass the flag (image fetch, Lab fetch) keep rejecting — the SSRF widening the original PR had is avoided. - provider-outbound passes allowBenchmarkAddresses only when an outbound HTTP(S) proxy is configured, so the hostname rides the proxy CONNECT instead of pin-connecting to the fake IP. NO_PROXY corner documented. - 5 destination-policy cases + proxy integration cases. Credit: luvs01 (original PR #1748). --- src/lib/destination-policy.ts | 19 +++++- src/lib/provider-outbound.ts | 11 ++++ tests/destination-policy-resolved.test.ts | 52 ++++++++++++++++ tests/provider-outbound.test.ts | 73 +++++++++++++++++++++++ 4 files changed, 154 insertions(+), 1 deletion(-) diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index af68907365..75818af311 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -241,10 +241,18 @@ export function assessUrlDestination(url: string): UrlDestinationAssessment | nu * Returns the validated addresses so direct callers can pin the connect peer and * avoid a second, rebindable resolution. DNS failures remain fail-closed here; * the provider proxy wrapper alone may recognize that typed failure and degrade. + * + * `allowBenchmarkAddresses` is an explicit outbound-only opt-in for Clash/Surge/ + * Mihomo fake-IP DNS (IANA benchmark space 198.18.0.0/15, credit #1748): a hostname + * answer in that range is accepted without marking the destination private, so the + * caller can keep the hostname on its configured HTTP(S) proxy path. It applies to + * resolved answers only — a literal 198.18.x URL still rejects — and mixed answers + * that include any other non-public address still fail. Callers that do not pass it + * (image and Lab fetch) keep rejecting benchmark space. */ export async function resolvePublicAddresses( url: string, - options?: string | { context?: string; allowPrivateNetwork?: boolean }, + options?: string | { context?: string; allowPrivateNetwork?: boolean; allowBenchmarkAddresses?: boolean }, ): Promise<{ hostname: string; addresses: { address: string; family: number }[]; @@ -254,6 +262,7 @@ export async function resolvePublicAddresses( ? `${options.trim() || "image"} URL` : options?.context?.trim() || "image URL"; const privateNetworkAllowed = typeof options === "object" && options?.allowPrivateNetwork === true; + const benchmarkAllowed = typeof options === "object" && options?.allowBenchmarkAddresses === true; let hostname: string; try { hostname = normalizeHostname(new URL(url.trim()).hostname); @@ -294,6 +303,14 @@ export async function resolvePublicAddresses( const ipKind = isIP(address) || (family === 4 || family === 6 ? family : 0); const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null; if (!assessment || assessment.kind !== "public") { + // Hostname → 198.18.0.0/15 under the explicit opt-in is Clash/Surge/Mihomo + // fake-IP DNS, not a LAN provider. Accept it without allowPrivateNetwork and + // do not mark the destination private, so the caller's HTTP(S)_PROXY path + // still applies (credit #1748). + if (benchmarkAllowed && assessment?.kind === "private" && assessment.detail === "benchmark address") { + validatedAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) }); + continue; + } const allowedPrivateAddress = privateNetworkAllowed && assessment && (assessment.kind === "loopback" || assessment.kind === "private"); diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index 67b55b4706..ab8b1ceed7 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -148,6 +148,17 @@ async function providerOutboundRequest( resolved = await resolveAddresses(url, { context: "provider URL", allowPrivateNetwork: allowPrivate, + // Clash/Surge/Mihomo fake-IP DNS (198.18.0.0/15) answers are admitted only + // when an outbound proxy is configured: the hostname then rides the proxy as + // an ordinary CONNECT instead of failing as a private destination or being + // pin-connected to the fake-IP (credit #1748). Without a proxy, benchmark + // answers keep rejecting. Image/Lab fetch never passes this flag. + // Known corner: the opt-in arms on the GLOBAL proxy config, not per-host. If + // NO_PROXY excludes this host, Bun bypasses the proxy and direct-connects to + // the benchmark answer — non-routable space typically intercepted by the + // local fake-IP TUN, so not an SSRF widening, but the CONNECT claim does not + // hold for NO_PROXY-excluded hosts. + allowBenchmarkAddresses: proxyConfigured, }); } catch (error) { const dnsResolutionFailed = error instanceof DestinationDnsResolutionError diff --git a/tests/destination-policy-resolved.test.ts b/tests/destination-policy-resolved.test.ts index 2ae103c135..98a4db6827 100644 --- a/tests/destination-policy-resolved.test.ts +++ b/tests/destination-policy-resolved.test.ts @@ -200,4 +200,56 @@ describe("resolvePublicAddresses — caller-specific diagnostics", () => { expect(resolved.privateNetwork).toBe(true); expect(resolved.addresses).toEqual([{ address: "192.168.1.50", family: 4 }]); }); + + test("hostname Clash fake-IP answers are accepted only under the explicit benchmark opt-in (#1748)", async () => { + lookupMock.mockResolvedValueOnce([{ address: "198.18.56.214", family: 4 }]); + + const resolved = await resolvePublicAddresses( + "https://www.packyapi.com/v1/models", + { context: "provider URL", allowBenchmarkAddresses: true }, + ); + + expect(resolved.privateNetwork).toBe(false); + expect(resolved.addresses).toEqual([{ address: "198.18.56.214", family: 4 }]); + }); + + test("hostname Clash fake-IP answers still reject without the benchmark opt-in", async () => { + lookupMock.mockResolvedValueOnce([{ address: "198.18.56.214", family: 4 }]); + + await expect(resolvePublicAddresses( + "https://www.packyapi.com/v1/models", + { context: "provider URL" }, + )).rejects.toThrow("benchmark address (198.18.56.214)"); + }); + + test("benchmark opt-in mixed with RFC1918 still requires the private-network opt-in", async () => { + lookupMock.mockResolvedValueOnce([ + { address: "198.18.56.214", family: 4 }, + { address: "10.0.0.5", family: 4 }, + ]); + + await expect(resolvePublicAddresses( + "https://rebind.example.com/v1/models", + { context: "provider URL", allowBenchmarkAddresses: true }, + )).rejects.toThrow("private-network address (10.0.0.5)"); + }); + + test("benchmark opt-in does not admit a literal 198.18.x URL", async () => { + await expect(resolvePublicAddresses( + "https://198.18.56.214/v1/models", + { context: "provider URL", allowBenchmarkAddresses: true }, + )).rejects.toThrow("benchmark address"); + }); + + test("image/Lab fetch (no opt-in) still rejects hostnames resolving to 198.18.x (#1748 SSRF guard)", async () => { + lookupMock.mockResolvedValueOnce([{ address: "198.18.4.2", family: 4 }]); + await expect(resolvePublicAddresses("https://fakeip.example.com/img.png")) + .rejects.toThrow("image URL hostname fakeip.example.com resolves to benchmark address (198.18.4.2)"); + + lookupMock.mockResolvedValueOnce([{ address: "198.19.7.9", family: 4 }]); + await expect(resolvePublicAddresses( + "https://fakeip.example.com/v1/models", + { context: "Lab provider destination", allowPrivateNetwork: false }, + )).rejects.toThrow("benchmark address (198.19.7.9)"); + }); }); diff --git a/tests/provider-outbound.test.ts b/tests/provider-outbound.test.ts index 8f5e944f24..9ac18438ba 100644 --- a/tests/provider-outbound.test.ts +++ b/tests/provider-outbound.test.ts @@ -97,6 +97,79 @@ describe("provider outbound GET transport", () => { expect(captured.address).toBeUndefined(); }); + test("Clash fake-IP behind a configured proxy uses hostname CONNECT instead of NO_PROXY (#1748)", async () => { + const proxyUrl = "http://127.0.0.1:9"; + process.env.HTTPS_PROXY = proxyUrl; + process.env.https_proxy = proxyUrl; + process.env.NO_PROXY = "localhost,127.0.0.1,::1,[::1]"; + process.env.no_proxy = "localhost,127.0.0.1,::1,[::1]"; + const originalFetch = globalThis.fetch; + const fetchMock = mock(async (url: string | URL | Request, init?: RequestInit) => { + expect(String(url)).toBe("https://www.packyapi.com/v1/models"); + expect(init?.redirect).toBe("manual"); + return new Response('{"data":[{"id":"gpt-5.5"}]}', { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + globalThis.fetch = fetchMock; + try { + const { providerOutboundGet } = await import("../src/lib/provider-outbound"); + const resolveOptions: { allowBenchmarkAddresses?: boolean }[] = []; + const { dependencies, captured } = directDependencies(new Response(null, { status: 500 })); + const innerResolve = dependencies.resolveAddresses!; + dependencies.resolveAddresses = mock(async (url: string, options?: { allowBenchmarkAddresses?: boolean }) => { + resolveOptions.push({ allowBenchmarkAddresses: options?.allowBenchmarkAddresses }); + await innerResolve(url, options); + // What the real resolver returns for a fake-IP-only answer under the + // outbound benchmark opt-in: accepted, and NOT marked private. + return { + hostname: "www.packyapi.com", + addresses: [{ address: "198.18.56.214", family: 4 }], + privateNetwork: false, + }; + }) as ProviderOutboundDependencies["resolveAddresses"]; + + const response = await providerOutboundGet( + "packy", + { baseUrl: "https://www.packyapi.com/v1" }, + "https://www.packyapi.com/v1/models", + {}, + dependencies, + ); + + expect(await response.json()).toEqual({ data: [{ id: "gpt-5.5" }] }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(captured.address).toBeUndefined(); + // The wrapper enables the benchmark opt-in only because a proxy is configured. + expect(resolveOptions).toEqual([{ allowBenchmarkAddresses: true }]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("Clash fake-IP without a configured proxy is not granted the benchmark opt-in (#1748)", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundGet } = await import("../src/lib/provider-outbound"); + const resolveOptions: { allowBenchmarkAddresses?: boolean }[] = []; + const { dependencies, captured } = directDependencies(new Response(null, { status: 500 })); + dependencies.resolveAddresses = mock(async (_url: string, options?: { allowBenchmarkAddresses?: boolean }) => { + resolveOptions.push({ allowBenchmarkAddresses: options?.allowBenchmarkAddresses }); + // What the real resolver does without the opt-in: benchmark answers reject. + throw new Error("provider URL hostname www.packyapi.com resolves to benchmark address (198.18.56.214)"); + }) as ProviderOutboundDependencies["resolveAddresses"]; + + await expect(providerOutboundGet( + "packy", + { baseUrl: "https://www.packyapi.com/v1" }, + "https://www.packyapi.com/v1/models", + {}, + dependencies, + )).rejects.toThrow(/benchmark address/); + expect(captured.address).toBeUndefined(); + expect(resolveOptions).toEqual([{ allowBenchmarkAddresses: false }]); + }); + 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");