From 1dc1c0f392a29a1927e0fe6c42b365419e686bcd Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:11:52 +0100 Subject: [PATCH] Extend private-IP guard to 6to4/Teredo addresses The SSRF guard already decoded NAT64-embedded IPv4 targets but treated 6to4 and Teredo - two other IPv6 transition address families that also embed an IPv4 destination - as ordinary public addresses. Decode 6to4 the same way as NAT64, recursing into the classifier on the embedded IPv4. Block Teredo wholesale instead, since its embedded IPv4 is obfuscated via bitwise NOT and can't be reliably decoded from the address alone. Co-Authored-By: Claude Sonnet 5 --- pkg/networking/utilities.go | 51 +++++++++++++++++++++++++------- pkg/networking/utilities_test.go | 16 ++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/pkg/networking/utilities.go b/pkg/networking/utilities.go index bf2c7aeebc..74a76f218c 100644 --- a/pkg/networking/utilities.go +++ b/pkg/networking/utilities.go @@ -72,8 +72,18 @@ const ( // is blocked wholesale via privateIPBlocks to avoid a false-negative bypass. var nat64Prefixes []*net.IPNet -// embeddedIPv4 returns the IPv4 address embedded in the low 32 bits of a NAT64 -// address if ip falls inside a NAT64 translation prefix, or nil otherwise. +// sixToFourPrefix is the 6to4 prefix 2002::/16 (RFC 3056, deprecated by +// RFC 7526). A 6to4 address embeds a full IPv4 address in bits 16-47 +// (2002:aabb:ccdd::/48 encodes aa.bb.cc.dd), so — like NAT64 — its true +// reachability is determined by the embedded IPv4, not by the (global-unicast) +// IPv6 form. Unlike the NAT64 remainder above, this embedding is exact and +// reversible for the whole prefix, so it is decoded rather than blocked +// wholesale (blocking 2002::/16 outright would also reject legitimate public +// 6to4 addresses). +var sixToFourPrefix *net.IPNet + +// embeddedIPv4 returns the IPv4 address embedded in a NAT64 or 6to4 address +// if ip falls inside one of those translation prefixes, or nil otherwise. func embeddedIPv4(ip net.IP) net.IP { v6 := ip.To16() if v6 == nil || ip.To4() != nil { @@ -84,6 +94,9 @@ func embeddedIPv4(ip net.IP) net.IP { return net.IPv4(v6[12], v6[13], v6[14], v6[15]) } } + if sixToFourPrefix.Contains(ip) { + return net.IPv4(v6[2], v6[3], v6[4], v6[5]) + } return nil } @@ -109,6 +122,11 @@ func init() { // to its embedded IPv4 below; this catch-all blocks the remaining // non-/96 embeddings, which cannot be decoded from the address alone. "64:ff9b:1::/48", + // Teredo (RFC 4380). The embedded client IPv4 lives in the low 32 bits + // but is obfuscated via bitwise NOT (RFC 4380 §4), so — unlike NAT64 and + // 6to4 — it cannot be reliably decoded from the address alone. Blocked + // wholesale to avoid a false-negative bypass. + "2001::/32", } { _, block, err := net.ParseCIDR(cidr) if err != nil { @@ -126,20 +144,31 @@ func init() { } nat64Prefixes = append(nat64Prefixes, block) } + const sixToFourCIDR = "2002::/16" + _, sixToFourBlock, err := net.ParseCIDR(sixToFourCIDR) + if err != nil { + panic(fmt.Errorf("parse error on %q: %w", sixToFourCIDR, err)) + } + sixToFourPrefix = sixToFourBlock } // IsPrivateIP reports whether ip is a private, loopback, link-local, // unspecified, or otherwise reserved/non-public address. // -// NAT64-translated addresses are evaluated by the IPv4 address they embed: a -// NAT64 address whose low 32 bits map to a private/link-local IPv4 (e.g. -// 64:ff9b:1::a9fe:a9fe -> 169.254.169.254, the cloud metadata endpoint) is -// treated as private, because behind a NAT64 gateway it reaches exactly that -// internal IPv4, while NAT64 addresses embedding a genuinely public IPv4 remain -// allowed. This /96 decoding covers the well-known 64:ff9b::/96 (RFC 6052) and -// the 64:ff9b:1::/96 sub-prefix of the RFC 8215 local-use range; the rest of -// 64:ff9b:1::/48 uses a non-/96 embedding that cannot be decoded from the -// address alone and is blocked wholesale (see privateIPBlocks). +// NAT64- and 6to4-translated addresses are evaluated by the IPv4 address they +// embed: a NAT64 address whose low 32 bits map to a private/link-local IPv4 +// (e.g. 64:ff9b:1::a9fe:a9fe -> 169.254.169.254, the cloud metadata endpoint) +// is treated as private, because behind a NAT64 gateway it reaches exactly +// that internal IPv4, while NAT64 addresses embedding a genuinely public IPv4 +// remain allowed. This /96 decoding covers the well-known 64:ff9b::/96 +// (RFC 6052) and the 64:ff9b:1::/96 sub-prefix of the RFC 8215 local-use +// range; the rest of 64:ff9b:1::/48 uses a non-/96 embedding that cannot be +// decoded from the address alone and is blocked wholesale (see +// privateIPBlocks). 6to4 addresses (2002::/16, RFC 3056) are decoded the same +// way, extracting the IPv4 from bits 16-47 (e.g. 2002:a9fe:a9fe:: -> +// 169.254.169.254). Teredo addresses (2001::/32, RFC 4380) obfuscate their +// embedded IPv4 via bitwise NOT and so cannot be decoded from the address +// alone; they are blocked wholesale (see privateIPBlocks). func IsPrivateIP(ip net.IP) bool { if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return true diff --git a/pkg/networking/utilities_test.go b/pkg/networking/utilities_test.go index 0402764514..68b4456cef 100644 --- a/pkg/networking/utilities_test.go +++ b/pkg/networking/utilities_test.go @@ -363,6 +363,22 @@ func TestIsPrivateIP(t *testing.T) { // decoded, so it is blocked wholesale even when the low 32 bits look // public (would otherwise be a false-negative SSRF bypass). {"NAT64 local-use non-/96 blocked", "64:ff9b:1:1::8.8.8.8", true}, + + // 6to4 (RFC 3056): classified by the embedded IPv4, decoded from bits 16-47. + {"6to4 -> IMDS link-local", "2002:a9fe:a9fe::", true}, + {"6to4 -> RFC1918 192.168.x", "2002:c0a8:0101::", true}, + {"6to4 -> loopback", "2002:7f00:0001::", true}, + // 6to4 embedding a genuinely public IPv4 must stay allowed, proving the + // prefix is decoded rather than blocked wholesale. + {"6to4 -> public", "2002:0808:0808::", false}, + // Just outside the 6to4 prefix: no embedded-IPv4 decoding applies. + {"just outside 6to4 prefix", "2003::", false}, + + // Teredo (RFC 4380): the embedded client IPv4 is obfuscated via bitwise + // NOT and cannot be decoded from the address alone, so the whole prefix + // is blocked regardless of what the low 32 bits would decode to. + {"Teredo simple address", "2001::1", true}, + {"Teredo address decoding to a public-looking IPv4", "2001:0:4136:e378:8000:63bf:3fff:fdf6", true}, } for _, tt := range tests {