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
9 changes: 7 additions & 2 deletions src/lib/destination-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the non-mapped prefix out of the public path

::ffff:0:<hi>:<lo> is not equivalent to IPv4-mapped ::ffff:<hi>:<lo>: for example, the former expands to 0:0:0:0:ffff:0:5db8:d822, so the kernel connects to that reserved IPv6 address rather than 93.184.216.34. When its final 32 bits resemble a public IPv4 address, this regex now classifies the entire reserved IPv6 destination as public, causing provider validation and untrusted image/Lab DNS checks to accept an address that previously failed as non-global. Preserve this form as non-global by default and recognize its benchmark tail only inside the explicitly proxy-gated allowBenchmarkAddresses exception.

Useful? React with 👍 / 👎.

if (hexMapped) {
const hi = Number.parseInt(hexMapped[1], 16);
const lo = Number.parseInt(hexMapped[2], 16);
Expand Down Expand Up @@ -377,4 +383,3 @@ export async function resolvePublicAddresses(
export async function assertUrlResolvesPublic(url: string): Promise<void> {
await resolvePublicAddresses(url);
}

62 changes: 62 additions & 0 deletions tests/destination-policy-resolved.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<hi>:<lo> instead of
// ::ffff:<hi>:<lo>. 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");
});
});
Loading