Skip to content
Merged
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
47 changes: 40 additions & 7 deletions src/lib/destination-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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})`;
Expand Down Expand Up @@ -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;
}
Expand All @@ -430,4 +464,3 @@ export async function resolvePublicAddresses(
export async function assertUrlResolvesPublic(url: string): Promise<void> {
await resolvePublicAddresses(url);
}

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