From 220a9048edc9e6715c0c4cf7f1388e26a016293e Mon Sep 17 00:00:00 2001 From: gaoran1209 Date: Fri, 28 Aug 2026 12:47:42 +0800 Subject: [PATCH] fix(security): decode IPv4-mapped IPv6 with an explicit zero group classifyIpv6's hex IPv4-mapped branch matched only ::ffff::, so the equivalent ::ffff:0:: spelling fell through to the generic "non-global address" tail instead of reaching classifyIpv4. Two consequences: - Wrapped loopback, private, and metadata addresses (::ffff:0:7f00:1, ::ffff:0:c0a8:1, ::ffff:0:a9fe:a9fe) skipped IPv4 classification and were reported with a generic detail rather than their precise one. - The benchmark-address opt-in in resolvePublicAddresses is gated on detail === "benchmark address", so fake-IP answers in this form could never be admitted. Provider discovery failed on Clash/Surge/Mihomo fake-IP DNS even with a correctly configured HTTP(S)_PROXY (#2810). Making the zero group optional tightens classification rather than loosening it: the wrapped private forms above are now blocked with their precise detail, and forms with more than two hex groups are still rejected. Refs #2810 --- src/lib/destination-policy.ts | 9 +++- tests/destination-policy-resolved.test.ts | 62 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index 46b2aa91ba..86f0f5e525 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -82,7 +82,13 @@ function classifyIpv6(hostname: string): DestinationAssessment { // Decode hex IPv4-mapped IPv6: ::ffff:7f00:1 → 127.0.0.1 // The dotted-decimal regex above only matches ::ffff:127.0.0.1; without this, // hex form bypasses all private/loopback checks (hextet is 0 → classified "public"). - const hexMapped = hostname.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); + // Resolvers may also emit the equivalent form with an explicit zero group, e.g. + // ::ffff:0:c612:1b for 198.18.0.27 (observed from Clash/Surge/Mihomo fake-IP DNS). + // Without the optional group that spelling missed this branch and fell through to the + // generic "non-global address" tail, which both hid wrapped loopback/private/metadata + // addresses from classifyIpv4 and made the benchmark-address opt-in in + // resolvePublicAddresses unreachable (issue #2810). + const hexMapped = hostname.match(/^::ffff:(?:0{1,4}:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); if (hexMapped) { const hi = Number.parseInt(hexMapped[1], 16); const lo = Number.parseInt(hexMapped[2], 16); @@ -377,4 +383,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 98a4db6827..1dc21cc8bf 100644 --- a/tests/destination-policy-resolved.test.ts +++ b/tests/destination-policy-resolved.test.ts @@ -253,3 +253,65 @@ describe("resolvePublicAddresses — caller-specific diagnostics", () => { )).rejects.toThrow("benchmark address (198.19.7.9)"); }); }); + +describe("classifyIpv6 — IPv4-mapped with an explicit zero group (#2810)", () => { + // Resolvers may spell an IPv4-mapped address as ::ffff:0:: instead of + // ::ffff::. Both decode to the same IPv4, so both must reach classifyIpv4. + // Before the fix the zero-group form fell through to the "non-global address" tail, + // which hid wrapped loopback/private/metadata addresses from IPv4 classification and + // left the benchmark opt-in in resolvePublicAddresses unreachable behind fake-IP DNS. + + test("a wrapped benchmark address is classified as benchmark, not non-global", () => { + for (const host of ["::ffff:0:c612:1b", "::ffff:c612:1b"]) { + expect(providerDestinationConfigError("p", provider(`https://[${host}]/v1`))) + .toContain("benchmark address"); + } + }); + + test("a wrapped loopback, private, or metadata IPv4 stays blocked with its precise detail", () => { + const cases: [string, string][] = [ + ["::ffff:0:7f00:1", "loopback address"], + ["::ffff:0:a00:1", "private-network address"], + ["::ffff:0:c0a8:1", "private-network address"], + // 169.254.169.254 is the cloud metadata IP, so it lands on the stronger blocklist + // rather than the generic link-local rule. + ["::ffff:0:a9fe:a9fe", "blocked metadata endpoint"], + ]; + for (const [host, detail] of cases) { + expect(providerDestinationConfigError("p", provider(`https://[${host}]/v1`))).toContain(detail); + } + }); + + test("a wrapped public IPv4 is still accepted", () => { + expect(providerDestinationConfigError("p", provider("https://[::ffff:0:5db8:d822]/v1"))).toBeNull(); + }); + + test("more than two hex groups after ::ffff: are not decoded", () => { + expect(providerDestinationConfigError("p", provider("https://[::ffff:0:0:c612:1b]/v1"))) + .toContain("non-global"); + }); + + test("provider discovery admits the zero-group fake-IP answer under the benchmark opt-in", async () => { + lookupMock.mockResolvedValueOnce([{ address: "::ffff:0:c612:1b", family: 6 }]); + const resolved = await resolvePublicAddresses( + "https://fakeip.example.com/v1/models", + { context: "provider URL", allowBenchmarkAddresses: true }, + ); + // Not marked private, so the caller keeps its HTTP(S)_PROXY route (#1748). + expect(resolved.privateNetwork).toBe(false); + }); + + test("without the opt-in the same answer is still rejected", async () => { + lookupMock.mockResolvedValueOnce([{ address: "::ffff:0:c612:1b", family: 6 }]); + await expect(resolvePublicAddresses("https://fakeip.example.com/img.png")) + .rejects.toThrow("benchmark address"); + }); + + test("a wrapped loopback answer is never admitted by the benchmark opt-in", async () => { + lookupMock.mockResolvedValueOnce([{ address: "::ffff:0:7f00:1", family: 6 }]); + await expect(resolvePublicAddresses( + "https://rebind.example.com/v1/models", + { context: "provider URL", allowBenchmarkAddresses: true }, + )).rejects.toThrow("loopback address"); + }); +});