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