From 42d28e2f2aa19567c6fee8ec90755cc103a4b148 Mon Sep 17 00:00:00 2001 From: iaj6 Date: Tue, 9 Jun 2026 10:09:25 -0400 Subject: [PATCH] fix(ssrf): block 6to4 and NAT64 IPv6 wrappers of private IPv4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isBlockedIpv6 didn't decode two IPv6 transition forms that embed an IPv4 address, so an attacker could smuggle a private/reserved/metadata target past the v6 check: - 6to4 (2002::/16): embedded v4 in hextets 1-2, e.g. 2002:a9fe:a9fe:: → 169.254.169.254 (cloud metadata), 2002:7f00:1:: → 127.0.0.1. - NAT64 well-known prefix (64:ff9b::/96): embedded v4 in the low 32 bits, e.g. 64:ff9b::7f00:1 → 127.0.0.1. Both previously fell through to "allowed". Decode the embedded IPv4 and range-check it via isBlockedIpv4 — consistent with the existing ::ffff: IPv4-mapped handling, so a 6to4/NAT64 wrapper of a PUBLIC v4 (e.g. 2002:808:808::) stays allowed while a wrapper of a private/reserved v4 is blocked. A comment documents the residual NAT64 network-specific-prefix limitation (not generically decodable). The DNS-error fail-open in assertResolvesToPublic is deliberately unchanged. Tests: 6to4 + NAT64 wrappers of loopback/metadata/private (hex and dotted forms) now blocked; public wrappers still allowed; bracketed IP-literal hosts rejected by validateOutboundUrl. Full suite 1263 pass, lint clean, build green. Review: adversarial 2-lens (correctness false-neg/pos + completeness of other embedding vectors) — 0 real problems; Teredo/ISATAP/6bone confirmed not reachable from a server fetch with no tunnel interface (deferred as hygiene). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web/src/lib/__tests__/ssrf.test.ts | 15 +++++++++++ packages/web/src/lib/ssrf.ts | 30 +++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/web/src/lib/__tests__/ssrf.test.ts b/packages/web/src/lib/__tests__/ssrf.test.ts index 9b542c4..7e03bea 100644 --- a/packages/web/src/lib/__tests__/ssrf.test.ts +++ b/packages/web/src/lib/__tests__/ssrf.test.ts @@ -49,6 +49,16 @@ describe("isBlockedIp", () => { "0:ffff::7f00:1", "::ffff:1:7f00:1", "::1:0:0:0", + // 6to4 (2002::/16) wrapping a private/reserved v4 in hextets 1-2. + "2002:7f00:1::", // → 127.0.0.1 + "2002:a9fe:a9fe::", // → 169.254.169.254 (metadata) + "2002:c0a8:101::", // → 192.168.1.1 + "2002:a00:1::", // → 10.0.0.1 + // NAT64 well-known prefix (64:ff9b::/96) wrapping a private/reserved v4. + "64:ff9b::7f00:1", // → 127.0.0.1, hex form + "64:ff9b::a9fe:a9fe", // → 169.254.169.254 (metadata) + "64:ff9b::c0a8:101", // → 192.168.1.1 + "64:ff9b::127.0.0.1", // → 127.0.0.1, dotted form ])("blocks private/reserved IPv6 %s", (ip) => { expect(isBlockedIp(ip)).toBe(true); }); @@ -57,6 +67,9 @@ describe("isBlockedIp", () => { "2001:4860:4860::8888", "2606:4700:4700::1111", "::ffff:8.8.8.8", // IPv4-mapped PUBLIC address stays allowed + "2002:808:808::", // 6to4 wrapping PUBLIC 8.8.8.8 stays allowed + "64:ff9b::808:808", // NAT64 wrapping PUBLIC 8.8.8.8, hex form + "64:ff9b::8.8.8.8", // NAT64 wrapping PUBLIC 8.8.8.8, dotted form ])("allows public IPv6 %s", (ip) => { expect(isBlockedIp(ip)).toBe(false); }); @@ -79,6 +92,8 @@ describe("validateOutboundUrl (sync, no DNS)", () => { "http://10.0.0.5/x", "https://192.168.1.1/x", "http://[::1]/x", + "http://[2002:a9fe:a9fe::]/latest/meta-data/", // 6to4 → 169.254.169.254 + "http://[64:ff9b::7f00:1]/x", // NAT64 → 127.0.0.1 "not a url", ])("rejects %s", (url) => { expect(validateOutboundUrl(url).ok).toBe(false); diff --git a/packages/web/src/lib/ssrf.ts b/packages/web/src/lib/ssrf.ts index 4b7df18..1a8355b 100644 --- a/packages/web/src/lib/ssrf.ts +++ b/packages/web/src/lib/ssrf.ts @@ -132,6 +132,36 @@ function isBlockedIpv6(ip: string): boolean { return isBlockedIpv4(hextetsToIpv4(g(6), g(7))); } + // 6to4 (2002::/16): the embedded IPv4 sits in hextets 1-2. Without decoding, + // an attacker smuggles a private/metadata v4 past the v6 check, e.g. + // 2002:a9fe:a9fe:: → 169.254.169.254 or 2002:7f00:1:: → 127.0.0.1. Decode and + // range-check the embedded v4, exactly like the IPv4-mapped form above (so a + // 6to4 wrapper of a public v4 stays allowed for consistency). + if (g(0) === 0x2002) { + return isBlockedIpv4(hextetsToIpv4(g(1), g(2))); + } + // NAT64 well-known prefix (64:ff9b::/96): the embedded IPv4 sits in the low + // 32 bits (hextets 6-7). Same smuggling risk, e.g. 64:ff9b::7f00:1 → + // 127.0.0.1. Decode and range-check the embedded v4. + // + // Known limitation: only the well-known prefix is decodable here. NAT64 + // network-specific prefixes (RFC 6052 — a local-use 64:ff9b:1::/48 or an + // operator-chosen /32../96 with the v4 at a prefix-dependent offset) can't be + // detected generically without knowing the operator's prefix, so a wrapper of + // a private v4 under a custom NSP is only a risk in a deployment that actually + // runs such a NAT64 gateway. The pre-fetch DNS re-resolution is the backstop + // for hostnames; raw NSP literals under a custom prefix are not covered. + if ( + g(0) === 0x0064 && + g(1) === 0xff9b && + g(2) === 0 && + g(3) === 0 && + g(4) === 0 && + g(5) === 0 + ) { + return isBlockedIpv4(hextetsToIpv4(g(6), g(7))); + } + // Any remaining address with a zero leading hextet lives in the special-use // ::/16 space — deprecated, reserved, or a malformed IPv4-mapped variant // (e.g. ::ffff:0:7f00:1, 0:ffff::7f00:1) whose 0xffff marker landed off the