diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index e4c20b5fc4..427a8b1e3a 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -143,7 +143,9 @@ so opencodex cannot pin or verify that peer. This is an explicit security limita Private/local destinations require `allowPrivateNetwork: true` and, when an outbound proxy is active, a matching `NO_PROXY` entry. Loopback is added automatically; list each LAN host explicitly because CIDR entries are not interpreted. The matcher supports exact hosts, domain suffixes, optional ports, -bracketed IPv6, and `*`; for example, list `192.168.1.50` explicitly. Metadata and link-local +bracketed IPv6, and `*`; for example, list `192.168.1.50` explicitly. Hostname answers that resolve +only to Clash/Surge/Mihomo fake-IP space (`198.18.0.0/15`) are not treated as private destinations +and keep using the outbound proxy. Metadata and link-local destinations stay blocked. Diagnostic requests reject redirects and report a credential-stripped target. Ordinary provider request redirect review remains separate from this diagnostic guard. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 1d2a8c10c9..c60d755934 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -122,7 +122,7 @@ API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者 当 `HTTP_PROXY`、`HTTPS_PROXY` 或 `ALL_PROXY` 生效时,这些操作会继续使用 Bun 的原生 fetch。URL 和字面量地址检查仍会执行,但最终路由、DNS 解析结果和对端由代理决定,因此 opencodex 无法固定或验证该对端。这是一个明确的安全限制。 -私有/本地目标需要 `allowPrivateNetwork: true`,并且在出站代理启用时,还需要匹配的 `NO_PROXY` 条目。回环地址会自动加入;每个 LAN 主机都必须显式列出,因为 CIDR 条目不会被解释。匹配器支持精确主机、域后缀、可选端口、带方括号的 IPv6 以及 `*`;例如,应显式列出 `192.168.1.50`。元数据和链路本地目标仍会被阻止。诊断请求会拒绝重定向,并报告一个已剥离凭据的目标。普通提供者请求的重定向审查仍然独立于这个诊断保护。 +私有/本地目标需要 `allowPrivateNetwork: true`,并且在出站代理启用时,还需要匹配的 `NO_PROXY` 条目。回环地址会自动加入;每个 LAN 主机都必须显式列出,因为 CIDR 条目不会被解释。匹配器支持精确主机、域后缀、可选端口、带方括号的 IPv6 以及 `*`;例如,应显式列出 `192.168.1.50`。主机名若只解析到 Clash/Surge/Mihomo fake-IP 网段(`198.18.0.0/15`),不会被当成私有目标,仍走出站代理。元数据和链路本地目标仍会被阻止。诊断请求会拒绝重定向,并报告一个已剥离凭据的目标。普通提供者请求的重定向审查仍然独立于这个诊断保护。 ## Codex 账户池 diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index af68907365..d1be4f4b29 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -108,6 +108,14 @@ function classifyIpv6(hostname: string): DestinationAssessment { return { kind: "private", detail: "non-global address" }; } +/** Clash / Surge / Mihomo fake-IP DNS uses IANA benchmark space 198.18.0.0/15. */ +export function isBenchmarkAddress(address: string): boolean { + const hostname = normalizeHostname(address); + if (isIP(hostname) !== 4) return false; + const assessment = classifyIpv4(hostname); + return assessment.kind === "private" && assessment.detail === "benchmark address"; +} + function assessDestination(baseUrl: string): DestinationAssessment | null { try { const parsed = new URL(baseUrl.trim()); @@ -294,6 +302,13 @@ 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 is Clash/Surge/Mihomo fake-IP DNS, not a LAN + // provider. Accept it without allowPrivateNetwork and do not mark the + // destination private, so outbound can still take the HTTP(S)_PROXY path. + if (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..ce3ee591bb 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types"; import { assessUrlDestination, DestinationDnsResolutionError, + isBenchmarkAddress, providerAllowsPrivateNetwork, providerDestinationConfigError, resolvePublicAddresses, @@ -160,7 +161,12 @@ async function providerOutboundRequest( warnProxyDnsDegradationOnce(); return globalThis.fetch(url, { ...init, method, redirect: "manual" }); } - if (proxyConfigured && !resolved.privateNetwork) { + const clashFakeIpOnly = resolved.addresses.length > 0 + && resolved.addresses.every(address => isBenchmarkAddress(address.address)); + // Clash fake-IP (198.18.0.0/15) is a local DNS artifact. Send it through the + // configured HTTP(S) proxy as a hostname CONNECT — the same path a public + // destination takes. Requiring NO_PROXY would pin-connect to the fake-IP. + if (proxyConfigured && (!resolved.privateNetwork || clashFakeIpOnly)) { warnProxyBoundaryOnce(); return globalThis.fetch(url, { ...init, method, redirect: "manual" }); } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 1db9a6fe1d..166ca72577 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -9,6 +9,8 @@ ALL_PROXY, and NO_PROXY semantics remain authoritative. The wrapper classifies s only a typed DNS-resolution failure degrades to proxy resolution; every literal, metadata, and resolved-address policy error still rejects. Proxy mode logs once that the proxy-selected peer cannot be pinned. Private destinations additionally require allowPrivateNetwork plus NO_PROXY. +Hostname answers that are only Clash/Surge/Mihomo fake-IP space (198.18.0.0/15) are not treated as +private destinations and stay on the proxy path. Both paths reject redirects and expose only credential-stripped final-address guidance. This phase does not cover ordinary requests, streaming, retries, or per-hop redirect review on those paths. diff --git a/tests/destination-policy-resolved.test.ts b/tests/destination-policy-resolved.test.ts index 2ae103c135..92f69291cd 100644 --- a/tests/destination-policy-resolved.test.ts +++ b/tests/destination-policy-resolved.test.ts @@ -200,4 +200,28 @@ 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 without marking the destination private", async () => { + lookupMock.mockResolvedValueOnce([{ address: "198.18.56.214", family: 4 }]); + + const resolved = await resolvePublicAddresses( + "https://www.packyapi.com/v1/models", + { context: "provider URL" }, + ); + + expect(resolved.privateNetwork).toBe(false); + expect(resolved.addresses).toEqual([{ address: "198.18.56.214", family: 4 }]); + }); + + test("hostname Clash fake-IP 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" }, + )).rejects.toThrow("private-network address (10.0.0.5)"); + }); }); diff --git a/tests/provider-outbound.test.ts b/tests/provider-outbound.test.ts index 8f5e944f24..c09575a90f 100644 --- a/tests/provider-outbound.test.ts +++ b/tests/provider-outbound.test.ts @@ -97,6 +97,45 @@ describe("provider outbound GET transport", () => { expect(captured.address).toBeUndefined(); }); + test("Clash fake-IP behind a configured proxy uses hostname CONNECT instead of NO_PROXY", 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 { dependencies, captured } = directDependencies(new Response(null, { status: 500 }), { + privateNetwork: true, + address: "198.18.56.214", + }); + + const response = await providerOutboundGet( + "packy", + { baseUrl: "https://www.packyapi.com/v1", allowPrivateNetwork: true }, + "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(); + } finally { + globalThis.fetch = originalFetch; + } + }); + 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");