diff --git a/library/helpers/addIPv4TransitionAddresses.test.ts b/library/helpers/addIPv4TransitionAddresses.test.ts new file mode 100644 index 000000000..acc1a7908 --- /dev/null +++ b/library/helpers/addIPv4TransitionAddresses.test.ts @@ -0,0 +1,45 @@ +import * as t from "tap"; +import { addIPv4TransitionAddresses } from "./addIPv4TransitionAddresses"; + +t.test("it adds transition mechanism addresses for IPv4 entries", async (t) => { + t.same(addIPv4TransitionAddresses(["127.0.0.1"]), [ + "127.0.0.1", + "64:ff9b::127.0.0.1/128", + "64:ff9b:1::127.0.0.1/128", + "::127.0.0.1/128", + "2002:7f00:1::/48", + ]); +}); + +t.test("it adjusts the prefix length for an IPv4 CIDR range", async (t) => { + t.same(addIPv4TransitionAddresses(["10.0.0.0/8"]), [ + "10.0.0.0/8", + "64:ff9b::10.0.0.0/104", + "64:ff9b:1::10.0.0.0/104", + "::10.0.0.0/104", + "2002:a00:0::/24", + ]); +}); + +t.test("it handles empty array", async (t) => { + t.same(addIPv4TransitionAddresses([]), []); +}); + +t.test("it handles only IPv6 addresses", async (t) => { + t.same(addIPv4TransitionAddresses(["2001:db8::1", "::1"]), [ + "2001:db8::1", + "::1", + ]); +}); + +t.test("it handles a mix of IPv4 and IPv6 addresses", async (t) => { + t.same(addIPv4TransitionAddresses(["2001:db8::1", "127.0.0.1", "::1"]), [ + "2001:db8::1", + "127.0.0.1", + "::1", + "64:ff9b::127.0.0.1/128", + "64:ff9b:1::127.0.0.1/128", + "::127.0.0.1/128", + "2002:7f00:1::/48", + ]); +}); diff --git a/library/helpers/addIPv4TransitionAddresses.ts b/library/helpers/addIPv4TransitionAddresses.ts new file mode 100644 index 000000000..a23026f98 --- /dev/null +++ b/library/helpers/addIPv4TransitionAddresses.ts @@ -0,0 +1,18 @@ +import { mapIPv4To6to4 } from "./mapIPv4To6to4"; +import { mapIPv4WithIPv6Prefix } from "./mapIPv4WithIPv6Prefix"; + +/** + * Adds IPv4-in-IPv6 transition mechanism versions for all IPv4 addresses in the array. + */ +export function addIPv4TransitionAddresses(ips: string[]): string[] { + const ipv4Addresses = ips.filter((ip) => !ip.includes(":")); + + return ips.concat( + ipv4Addresses.flatMap((ip) => [ + mapIPv4WithIPv6Prefix(ip, "64:ff9b::", 96), // Well-Known NAT64 prefix (RFC 6052) + mapIPv4WithIPv6Prefix(ip, "64:ff9b:1::", 96), // Local-Use NAT64 prefix (RFC 8215) + mapIPv4WithIPv6Prefix(ip, "::", 96), // IPv4-compatible IPv6 address (RFC 4291) + mapIPv4To6to4(ip), // 6to4 (RFC 3056) + ]) + ); +} diff --git a/library/helpers/mapIPv4To6to4.test.ts b/library/helpers/mapIPv4To6to4.test.ts new file mode 100644 index 000000000..97b07b186 --- /dev/null +++ b/library/helpers/mapIPv4To6to4.test.ts @@ -0,0 +1,21 @@ +import * as t from "tap"; +import { mapIPv4To6to4 } from "./mapIPv4To6to4"; + +t.test("it maps a single IPv4 address to its 6to4 form", async (t) => { + t.equal(mapIPv4To6to4("127.0.0.1"), "2002:7f00:1::/48"); + t.equal(mapIPv4To6to4("10.0.0.1"), "2002:a00:1::/48"); + t.equal(mapIPv4To6to4("192.168.0.1"), "2002:c0a8:1::/48"); + t.equal(mapIPv4To6to4("8.8.8.8"), "2002:808:808::/48"); +}); + +t.test("it adjusts the prefix length for an IPv4 CIDR range", async (t) => { + t.equal(mapIPv4To6to4("10.0.0.0/8"), "2002:a00:0::/24"); + t.equal(mapIPv4To6to4("192.168.0.0/16"), "2002:c0a8:0::/32"); +}); + +t.test("it does not pad hex groups that have leading zeros", async (t) => { + // JS drops leading zeros when converting to hex (e.g. 1 -> "1", not "0001"), + // which is still valid, unambiguous IPv6 group notation. + t.equal(mapIPv4To6to4("0.1.0.1"), "2002:1:1::/48"); + t.equal(mapIPv4To6to4("0.0.0.0/0"), "2002:0:0::/16"); +}); diff --git a/library/helpers/mapIPv4To6to4.ts b/library/helpers/mapIPv4To6to4.ts new file mode 100644 index 000000000..bcecfde39 --- /dev/null +++ b/library/helpers/mapIPv4To6to4.ts @@ -0,0 +1,15 @@ +import { splitCIDR } from "./splitCIDR"; + +/** + * Maps an IPv4 address to its 6to4 (RFC 3056) IPv6 representation. + * 6to4 embeds the IPv4 address as two hex groups right after the 2002:: prefix. + * e.g. mapIPv4To6to4("10.0.0.1") -> "2002:a00:1::/128" + * e.g. mapIPv4To6to4("10.0.0.0/8") -> "2002:a00:0::/24" + */ +export function mapIPv4To6to4(ip: string): string { + const [address, suffix] = splitCIDR(ip); + const octets = address.split(".").map(Number); + const first = ((octets[0] << 8) | octets[1]).toString(16); + const second = ((octets[2] << 8) | octets[3]).toString(16); + return `2002:${first}:${second}::/${suffix + 16}`; +} diff --git a/library/helpers/mapIPv4WithIPv6Prefix.test.ts b/library/helpers/mapIPv4WithIPv6Prefix.test.ts new file mode 100644 index 000000000..6f32dcaec --- /dev/null +++ b/library/helpers/mapIPv4WithIPv6Prefix.test.ts @@ -0,0 +1,21 @@ +import * as t from "tap"; +import { mapIPv4WithIPv6Prefix } from "./mapIPv4WithIPv6Prefix"; + +t.test("it embeds a single IPv4 address behind a prefix", async (t) => { + t.equal( + mapIPv4WithIPv6Prefix("127.0.0.1", "64:ff9b::", 96), + "64:ff9b::127.0.0.1/128" + ); + t.equal(mapIPv4WithIPv6Prefix("10.0.0.1", "::", 96), "::10.0.0.1/128"); +}); + +t.test("it adjusts the prefix length for an IPv4 CIDR range", async (t) => { + t.equal( + mapIPv4WithIPv6Prefix("10.0.0.0/8", "64:ff9b::", 96), + "64:ff9b::10.0.0.0/104" + ); + t.equal( + mapIPv4WithIPv6Prefix("192.168.0.0/16", "64:ff9b:1::", 96), + "64:ff9b:1::192.168.0.0/112" + ); +}); diff --git a/library/helpers/mapIPv4WithIPv6Prefix.ts b/library/helpers/mapIPv4WithIPv6Prefix.ts new file mode 100644 index 000000000..98de8dc56 --- /dev/null +++ b/library/helpers/mapIPv4WithIPv6Prefix.ts @@ -0,0 +1,15 @@ +import { splitCIDR } from "./splitCIDR"; + +/** + * Embeds an IPv4 address as the trailing bits of a fixed-length IPv6 prefix. + * Used for transition mechanisms that place the IPv4 address at the end of the + * address, e.g. NAT64 (64:ff9b::/96) and IPv4-compatible addresses (::/96). + */ +export function mapIPv4WithIPv6Prefix( + ip: string, + prefix: string, + prefixBitLength: number +): string { + const [address, suffix] = splitCIDR(ip); + return `${prefix}${address}/${suffix + prefixBitLength}`; +} diff --git a/library/helpers/splitCIDR.test.ts b/library/helpers/splitCIDR.test.ts new file mode 100644 index 000000000..5eff47b5f --- /dev/null +++ b/library/helpers/splitCIDR.test.ts @@ -0,0 +1,23 @@ +import * as t from "tap"; +import { splitCIDR } from "./splitCIDR"; + +t.test("it splits an IP with a CIDR suffix", async (t) => { + t.same(splitCIDR("10.0.0.0/8"), ["10.0.0.0", 8]); + t.same(splitCIDR("192.168.0.0/16"), ["192.168.0.0", 16]); + t.same(splitCIDR("2001:db8::/32"), ["2001:db8::", 32]); +}); + +t.test( + "it defaults to a single-host prefix when no suffix is present", + async (t) => { + t.same(splitCIDR("127.0.0.1"), ["127.0.0.1", 32]); + t.same(splitCIDR("::1"), ["::1", 128]); + } +); + +t.test("it handles boundary suffix values", async (t) => { + t.same(splitCIDR("0.0.0.0/0"), ["0.0.0.0", 0]); + t.same(splitCIDR("::/0"), ["::", 0]); + t.same(splitCIDR("127.0.0.1/32"), ["127.0.0.1", 32]); + t.same(splitCIDR("::1/128"), ["::1", 128]); +}); diff --git a/library/helpers/splitCIDR.ts b/library/helpers/splitCIDR.ts new file mode 100644 index 000000000..52ee499a5 --- /dev/null +++ b/library/helpers/splitCIDR.ts @@ -0,0 +1,12 @@ +/** + * Splits an IP address into its address part and prefix length. + * e.g. "10.0.0.0/8" -> ["10.0.0.0", 8], "127.0.0.1" -> ["127.0.0.1", 32] + * e.g. "::1" -> ["::1", 128] + */ +export function splitCIDR(ip: string): [string, number] { + if (!ip.includes("/")) { + return [ip, ip.includes(":") ? 128 : 32]; + } + const [address, suffix] = ip.split("/"); + return [address, Number.parseInt(suffix, 10)]; +} diff --git a/library/vulnerabilities/ssrf/containsPrivateIPAddress.test.ts b/library/vulnerabilities/ssrf/containsPrivateIPAddress.test.ts index b224dfdf1..b787367ad 100644 --- a/library/vulnerabilities/ssrf/containsPrivateIPAddress.test.ts +++ b/library/vulnerabilities/ssrf/containsPrivateIPAddress.test.ts @@ -34,6 +34,14 @@ const publicIPs = [ "::ffff:1.2.3.4", "::ffff:172.1.2.3", "::ffff:192.145.0.0", + "64:ff9b::8.8.8.8", + "64:ff9b:1::8.8.8.8", + "2002:808:808::", + "::8.8.8.8", + + // NAT64-encoded addresses just outside the 10.0.0.0/8 private range + "64:ff9b::9ff:ffff", // 9.255.255.255 + "64:ff9b::b00:0", // 11.0.0.0 ]; const privateIPs = [ @@ -163,6 +171,32 @@ const privateIPs = [ // Alibaba Cloud metadata service "100.100.100.200", + + // NAT64 (RFC 6052) encoding private IPv4 addresses + "64:ff9b::7f00:1", // 127.0.0.1 + "64:ff9b::a00:0", // 10.0.0.0 (start of 10.0.0.0/8) + "64:ff9b::a00:1", // 10.0.0.1 + "64:ff9b::a00:203", // 10.0.2.3 (inside 10.0.0.0/8, not the network address) + "64:ff9b::c0a8:1", // 192.168.0.1 + "64:ff9b::a9fe:a9fe", // 169.254.169.254 (cloud metadata) + + // NAT64 Local-Use prefix (RFC 8215) encoding private IPv4 addresses + "64:ff9b:1::7f00:1", // 127.0.0.1 + "64:ff9b:1::a00:1", // 10.0.0.1 + "64:ff9b:1::c0a8:1", // 192.168.0.1 + "64:ff9b:1::a9fe:a9fe", // 169.254.169.254 (cloud metadata) + + // 6to4 (RFC 3056) encoding private IPv4 addresses + "2002:7f00:1::", // 127.0.0.1 + "2002:a00:1::", // 10.0.0.1 + "2002:c0a8:1::", // 192.168.0.1 + "2002:a9fe:a9fe::", // 169.254.169.254 (cloud metadata) + + // IPv4-compatible IPv6 addresses (RFC 4291) encoding private IPv4 addresses + "::7f00:1", // 127.0.0.1 + "::a00:1", // 10.0.0.1 + "::c0a8:1", // 192.168.0.1 + "::a9fe:a9fe", // 169.254.169.254 (cloud metadata) ]; const invalidIPs = [ diff --git a/library/vulnerabilities/ssrf/isPrivateIP.ts b/library/vulnerabilities/ssrf/isPrivateIP.ts index d5f756e4a..24e126168 100644 --- a/library/vulnerabilities/ssrf/isPrivateIP.ts +++ b/library/vulnerabilities/ssrf/isPrivateIP.ts @@ -1,4 +1,5 @@ import { addIPv4MappedAddresses } from "../../helpers/addIPv4MappedAddresses"; +import { addIPv4TransitionAddresses } from "../../helpers/addIPv4TransitionAddresses"; import { IPMatcher } from "../../helpers/ip-matcher/IPMatcher"; const PRIVATE_IP_RANGES = [ @@ -36,6 +37,7 @@ const PRIVATE_IPV6_RANGES = [ // Small list, frequently accessed: add IPv4-mapped versions at creation time for fast lookups const privateIp = new IPMatcher([ ...addIPv4MappedAddresses(PRIVATE_IP_RANGES), + ...addIPv4TransitionAddresses(PRIVATE_IP_RANGES), ...PRIVATE_IPV6_RANGES, ]);