diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index d552b78065..10dabbf8bf 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -107,6 +107,44 @@ function ipv6Hextets(hostname: string): number[] | null { /** RFC 6052 §2.1 well-known NAT64 prefix, 64:ff9b::/96, as its six leading hextets. */ const NAT64_WELL_KNOWN_PREFIX = [0x64, 0xff9b, 0, 0, 0, 0] as const; +/** + * `0:0:0:0:ffff:0::/96` — the explicit-zero spelling of a mapped IPv4 that some DNS resolvers + * return, e.g. `::ffff:0:c612:1b` for `198.18.0.27`. + * + * This is deliberately NOT taught to `classifyIpv6`. Under RFC 4291 the mapped prefix is + * `::ffff:0:0/96`, so `::ffff:0:c612:1b` is a reserved address whose tail merely LOOKS like an + * IPv4 — it is not equivalent to `198.18.0.27`. Treating the two as equal in the general + * classifier would admit `::ffff:0:5db8:d822` (tail `93.184.216.34`) as a public destination, + * which is the merge blocker a maintainer raised on #2812. + * + * The reported symptom is narrower than that equivalence: on a fake-IP resolver the answer + * assesses as `non-global address`, so the `allowBenchmarkAddresses` exception — which exists + * precisely for Clash/Surge/Mihomo fake-IP — could never be reached for this spelling. The fix + * therefore lives inside that opt-in, and only for a tail that is itself in `198.18.0.0/15`. + */ +const EXPLICIT_ZERO_MAPPED_PREFIX = [0, 0, 0, 0, 0xffff, 0] as const; + +/** + * True when this DNS answer may pass the `allowBenchmarkAddresses` opt-in. + * + * Ordinary benchmark answers (IPv4 `198.18/19`, canonical `::ffff:198.18.0.27`, and the NAT64 + * form) already carry `detail: "benchmark address"` and pass through the first branch. The + * second branch adds ONLY the explicit-zero spelling, and only when its embedded quad is itself + * a benchmark address — so a public, loopback, private, or metadata-looking tail is refused. + */ +function isBenchmarkDnsAnswer(address: string, assessment: DestinationAssessment | null): boolean { + if (assessment?.kind === "private" && assessment.detail === "benchmark address") return true; + if (isIP(address) !== 6) return false; + if (assessment?.kind !== "private" || assessment.detail !== "non-global address") return false; + const hextets = ipv6Hextets(normalizeHostname(address)); + if (!hextets) return false; + if (!EXPLICIT_ZERO_MAPPED_PREFIX.every((group, index) => hextets[index] === group)) return false; + const hi = hextets[6]!; + const lo = hextets[7]!; + const embedded = classifyIpv4(`${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`); + return embedded.kind === "private" && embedded.detail === "benchmark address"; +} + function firstIpv6Hextet(hostname: string): number | null { const head = hostname.split(":")[0]; if (!head) return 0; @@ -303,11 +341,7 @@ export async function providerDestinationResolvedError( const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null; if (!assessment || assessment.kind === "public") continue; // Clash fake-IP only: 198.18/19 benchmark detail. Mixed dangerous sets still reject. - if ( - options?.allowBenchmarkAddresses - && assessment.kind === "private" - && assessment.detail === "benchmark address" - ) { + if (options?.allowBenchmarkAddresses && isBenchmarkDnsAnswer(address, assessment)) { continue; } if (assessment.kind === "metadata") return `baseUrl hostname ${hostname} resolves to a blocked metadata endpoint (${address})`; @@ -406,7 +440,7 @@ export async function resolvePublicAddresses( // 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") { + if (benchmarkAllowed && isBenchmarkDnsAnswer(address, assessment)) { validatedAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) }); continue; } @@ -430,4 +464,3 @@ export async function resolvePublicAddresses( export async function assertUrlResolvesPublic(url: string): Promise { await resolvePublicAddresses(url); } - diff --git a/tests/destination-policy-resolved.test.ts b/tests/destination-policy-resolved.test.ts index 207f73ec8c..0269dfc457 100644 --- a/tests/destination-policy-resolved.test.ts +++ b/tests/destination-policy-resolved.test.ts @@ -296,3 +296,89 @@ describe("providerDestinationConfigError — NAT64 well-known prefix (RFC 6052)" expect(providerDestinationConfigError("p", provider("https://[2001:db8::1]/v1"))).toContain("documentation"); }); }); + +/** + * Issue #2810: a fake-IP resolver answers `::ffff:0:c612:1b` — the explicit-zero spelling of + * `198.18.0.27`. That assesses as `non-global address`, never `benchmark address`, so the + * `allowBenchmarkAddresses` opt-in could not reach it and Clash/Surge users behind fake-IP were + * refused. + * + * The fix is deliberately NOT an equivalence in `classifyIpv6`. Under RFC 4291 the mapped prefix + * is `::ffff:0:0/96`, so `::ffff:0::` is a RESERVED address whose tail merely looks like + * an IPv4. Declaring them equal would admit `::ffff:0:5db8:d822` (tail `93.184.216.34`) as a + * public destination — the blocker a maintainer raised on #2812. Both directions are pinned here. + */ +describe("#2810 explicit-zero mapped benchmark answers under the fake-IP opt-in", () => { + const OPT_IN = { context: "p", allowBenchmarkAddresses: true } as const; + + test("the reported answer is accepted and stays non-private", async () => { + lookupMock.mockResolvedValueOnce([{ address: "::ffff:0:c612:1b", family: 6 }]); + const resolved = await resolvePublicAddresses("https://api.example.com/v1", OPT_IN); + expect(resolved.addresses).toEqual([{ address: "::ffff:0:c612:1b", family: 6 }]); + expect(resolved.privateNetwork).toBe(false); + }); + + test("both benchmark range boundaries are accepted", async () => { + // 198.18.0.0 and 198.19.255.255 + for (const address of ["::ffff:0:c612:0", "::ffff:0:c613:ffff"]) { + lookupMock.mockResolvedValueOnce([{ address, family: 6 }]); + const resolved = await resolvePublicAddresses("https://api.example.com/v1", OPT_IN); + expect(resolved.addresses).toEqual([{ address, family: 6 }]); + } + }); + + test("THE BLOCKER: a public-looking tail is still refused", async () => { + // ::ffff:0:5db8:d822 has the tail 93.184.216.34. If the classifier treated the explicit-zero + // form as a mapped IPv4, this reserved address would be admitted as a public destination. + lookupMock.mockResolvedValueOnce([{ address: "::ffff:0:5db8:d822", family: 6 }]); + await expect(resolvePublicAddresses("https://api.example.com/v1", OPT_IN)).rejects.toThrow("non-global"); + }); + + test("loopback-, metadata-, and out-of-range tails are refused", async () => { + const refused = [ + "::ffff:0:7f00:1", // 127.0.0.1 + "::ffff:0:a9fe:a9fe", // 169.254.169.254 + "::ffff:0:c611:ffff", // 198.17.255.255, just below the range + "::ffff:0:c614:0", // 198.20.0.0, just above the range + "::ffff:0:a00:5", // 10.0.0.5 + ]; + for (const address of refused) { + lookupMock.mockResolvedValueOnce([{ address, family: 6 }]); + await expect(resolvePublicAddresses("https://api.example.com/v1", OPT_IN)).rejects.toThrow("non-global"); + } + }); + + test("without the opt-in the reported answer is refused", async () => { + lookupMock.mockResolvedValueOnce([{ address: "::ffff:0:c612:1b", family: 6 }]); + await expect(resolvePublicAddresses("https://api.example.com/v1", "p")).rejects.toThrow("non-global"); + }); + + test("a literal URL is still refused, opt-in or not", () => { + // The opt-in is a DNS-answer exception. A user-configured literal never reaches it. + expect(providerDestinationConfigError("p", provider("https://[::ffff:0:c612:1b]/v1"))) + .toContain("non-global"); + expect(providerDestinationConfigError("p", provider("https://[::ffff:0:5db8:d822]/v1"))) + .toContain("non-global"); + }); + + test("a prefix that is one hextet off is not decoded", async () => { + lookupMock.mockResolvedValueOnce([{ address: "::ffff:1:c612:1b", family: 6 }]); + await expect(resolvePublicAddresses("https://api.example.com/v1", OPT_IN)).rejects.toThrow("non-global"); + }); + + test("one accepted answer cannot smuggle a private companion answer", async () => { + lookupMock.mockResolvedValueOnce([ + { address: "::ffff:0:c612:1b", family: 6 }, + { address: "10.0.0.5", family: 4 }, + ]); + await expect(resolvePublicAddresses("https://api.example.com/v1", OPT_IN)).rejects.toThrow(); + }); + + test("the canonical spelling and ordinary IPv4 benchmark answers still work", async () => { + for (const address of ["::ffff:198.18.0.27", "198.18.0.27"]) { + lookupMock.mockResolvedValueOnce([{ address, family: address.includes(":") ? 6 : 4 }]); + const resolved = await resolvePublicAddresses("https://api.example.com/v1", OPT_IN); + expect(resolved.privateNetwork).toBe(false); + } + }); +});