From 3d0862632fcb06a687c45de374d83fb965865cb4 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Tue, 11 Aug 2026 23:23:10 +0200 Subject: [PATCH] fix(networking): classify Teredo and 6to4 Block Teredo addresses and classify 6to4 routes by their embedded IPv4 to preserve the outbound SSRF guard. Normalize localhost recognition through the DNS-free loopback predicate. --- networking/utilities.go | 45 +++++++++++++++------------- networking/utilities_test.go | 57 ++++++++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 23 deletions(-) diff --git a/networking/utilities.go b/networking/utilities.go index 7f72e3b..29baf9b 100644 --- a/networking/utilities.go +++ b/networking/utilities.go @@ -70,8 +70,12 @@ var ErrPrivateIPAddress = errors.New("the provided URL redirects to a private IP // 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 identifies 6to4 addresses, whose embedded IPv4 address +// occupies bytes 2 through 5 of the IPv6 address (RFC 3056 ยง2). +var sixToFourPrefix *net.IPNet + +// embeddedIPv4 returns the IPv4 address embedded in a NAT64 or 6to4 address, +// or nil otherwise. func embeddedIPv4(ip net.IP) net.IP { v6 := ip.To16() if v6 == nil || ip.To4() != nil { @@ -82,6 +86,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 } @@ -93,6 +100,7 @@ func init() { "172.16.0.0/12", // RFC1918 "192.168.0.0/16", // RFC1918 "169.254.0.0/16", // RFC3927 link-local + "2001::/32", // RFC4380 Teredo (embedded client IPv4 is obfuscated) "::1/128", // IPv6 loopback "fe80::/10", // IPv6 link-local "fc00::/7", // IPv6 unique local addr @@ -124,20 +132,23 @@ func init() { } nat64Prefixes = append(nat64Prefixes, block) } + _, block, err := net.ParseCIDR("2002::/16") + if err != nil { + panic(fmt.Errorf("parse error on 6to4 prefix: %w", err)) + } + sixToFourPrefix = block } // 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 translated address whose embedded IPv4 is private or link-local is +// treated as private, because it reaches that internal IPv4 through its +// translator. NAT64 addresses embed the IPv4 in their low 32 bits; 6to4 +// addresses embed it in bytes 2 through 5. Teredo addresses are blocked +// wholesale because their embedded client IPv4 is obfuscated and cannot be +// safely derived from the IPv6 literal. func IsPrivateIP(ip net.IP) bool { if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return true @@ -282,16 +293,10 @@ const ( hostLoopbackV6 = "[::1]" ) -// IsLocalhost checks if a host is a loopback address (for development). -// Recognised forms: "localhost", "localhost:", "127.0.0.1", "127.0.0.1:", -// "[::1]", "[::1]:". +// IsLocalhost checks whether a host is localhost or a literal loopback address. +// It accepts plain-host and host:port forms without resolving hostnames. func IsLocalhost(host string) bool { - return strings.HasPrefix(host, hostLocalhost+":") || - strings.HasPrefix(host, hostLoopbackV4+":") || - strings.HasPrefix(host, hostLoopbackV6+":") || - host == hostLocalhost || - host == hostLoopbackV4 || - host == hostLoopbackV6 + return IsLoopbackHost(host) } // IsLoopbackHost reports whether the Host header value refers to a loopback diff --git a/networking/utilities_test.go b/networking/utilities_test.go index efcc16d..d0fd90f 100644 --- a/networking/utilities_test.go +++ b/networking/utilities_test.go @@ -183,11 +183,26 @@ func TestIsLocalhost(t *testing.T) { input: "127.0.0.1:65535", expected: true, }, + { + name: "127.0.0.2 without port", + input: "127.0.0.2", + expected: true, + }, + { + name: "127.0.0.2 with port", + input: "127.0.0.2:8080", + expected: true, + }, { name: "IPv6 localhost without port", input: hostLoopbackV6, expected: true, }, + { + name: "bare IPv6 localhost", + input: "::1", + expected: true, + }, { name: "IPv6 localhost with port", input: testLoopbackV6WithPort, @@ -293,17 +308,17 @@ func TestIsLocalhost(t *testing.T) { { name: "case insensitive localhost", input: "LOCALHOST", - expected: false, // Current implementation is case sensitive + expected: true, }, { name: "case insensitive localhost with port", input: "LOCALHOST:8080", - expected: false, // Current implementation is case sensitive + expected: true, }, { name: "mixed case localhost", input: "LocalHost", - expected: false, // Current implementation is case sensitive + expected: true, }, { name: "localhost with spaces", @@ -361,6 +376,17 @@ func TestIsPrivateIP(t *testing.T) { {"documentation TEST-NET-3", "203.0.113.1", true}, {"public IPv4", testPublicIPv4, false}, {"public IPv6", "2001:db8::1", false}, + {"global IPv6 outside blocked ranges", "2606:4700:4700::1111", false}, + + // Teredo (RFC 4380) obfuscates the client IPv4, so the whole prefix is blocked. + {"Teredo example", "2001:0000:4136:e378:8000:63bf:3fff:fdd2", true}, + {"another Teredo address", "2001:0:1:2:3:4:5:6", true}, + + // 6to4 (RFC 3056): classify bytes 2 through 5 as the embedded IPv4. + {"6to4 -> IMDS link-local", "2002:a9fe:a9fe::", true}, + {"6to4 -> RFC1918 10.x", "2002:0a00:0001::", true}, + {"6to4 -> loopback", "2002:7f00:0001::", true}, + {"6to4 -> public", "2002:0808:0808::", false}, // Unspecified / "this host" / reserved ranges (defense-in-depth). {"unspecified IPv4", "0.0.0.0", true}, @@ -474,6 +500,31 @@ func TestAddressReferencesPrivateIp(t *testing.T) { address: "[64:ff9b::8.8.8.8]:443", expectError: false, }, + { + name: "6to4 to public IPv4 with port", + address: "[2002:0808:0808::]:443", + expectError: false, + }, + { + name: "Teredo with port", + address: "[2001:0000:4136:e378:8000:63bf:3fff:fdd2]:443", + expectError: true, + }, + { + name: "6to4 to IMDS link-local with port", + address: "[2002:a9fe:a9fe::]:443", + expectError: true, + }, + { + name: "6to4 to RFC1918 with port", + address: "[2002:0a00:0001::]:443", + expectError: true, + }, + { + name: "6to4 to loopback with port", + address: "[2002:7f00:0001::]:443", + expectError: true, + }, { name: "unspecified IPv4 with port", address: "0.0.0.0:80",