diff --git a/internal/util/netsafe/netsafe.go b/internal/util/netsafe/netsafe.go index 3122940e4..689425d65 100644 --- a/internal/util/netsafe/netsafe.go +++ b/internal/util/netsafe/netsafe.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net" + "net/netip" "regexp" "strings" "time" @@ -15,9 +16,40 @@ import ( // ordinary connection failure. var ErrPrivateAddressBlocked = errors.New("blocked private/internal address") +// Ranges Go's net.IP predicates do not treat as internal. The transition +// mechanisms here are deprecated (RFC 7526) or local-use, so none carry public traffic. +var blockedPrefixes = []netip.Prefix{ + netip.MustParsePrefix("100.64.0.0/10"), // CGNAT (RFC 6598) + netip.MustParsePrefix("2002::/16"), // 6to4 (RFC 3056) + netip.MustParsePrefix("2001::/32"), // Teredo (RFC 4380) + netip.MustParsePrefix("64:ff9b:1::/48"), // NAT64 local-use (RFC 8215) + netip.MustParsePrefix("fec0::/10"), // site-local (RFC 3879) +} + +// Judged by the IPv4 it embeds rather than blocked outright: on a DNS64 network +// every public IPv4 host resolves into this prefix (RFC 6052 mandates /96 here). +var nat64WellKnown = netip.MustParsePrefix("64:ff9b::/96") + func IsBlockedIP(ip net.IP) bool { - return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || - ip.IsLinkLocalMulticast() || ip.IsUnspecified() + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() { + return true + } + addr, ok := netip.AddrFromSlice(ip) + if !ok { + return false + } + addr = addr.Unmap() + for _, prefix := range blockedPrefixes { + if prefix.Contains(addr) { + return true + } + } + if nat64WellKnown.Contains(addr) { + embedded := addr.As16() + return IsBlockedIP(net.IP(embedded[12:16])) + } + return false } type allowPrivateCtxKey struct{} diff --git a/internal/util/netsafe/netsafe_test.go b/internal/util/netsafe/netsafe_test.go index 2fe9bcd5b..f17737da9 100644 --- a/internal/util/netsafe/netsafe_test.go +++ b/internal/util/netsafe/netsafe_test.go @@ -23,6 +23,19 @@ func TestIsBlockedIP(t *testing.T) { {"8.8.8.8", false}, {"1.1.1.1", false}, {"2606:4700:4700::1111", false}, + // IPv6 transition prefixes tunnel an arbitrary IPv4 destination that + // Go's net.IP predicates do not see through (GHSA-cfpf-wmjp-gh6c). + {"2002:7f00:0001::1", true}, // 6to4 -> 127.0.0.1 + {"2002:a9fe:a9fe::1", true}, // 6to4 -> 169.254.169.254 + {"64:ff9b::7f00:1", true}, // NAT64 well-known -> 127.0.0.1 + {"64:ff9b::a9fe:a9fe", true}, // NAT64 well-known -> 169.254.169.254 + {"64:ff9b:1::a9fe:a9fe", true}, // NAT64 local-use + {"2001:0:dead:beef::80ff:fffe", true}, // Teredo -> 127.0.0.1 + {"100.64.0.1", true}, // CGNAT + {"::ffff:100.64.0.1", true}, // CGNAT via 4-in-6 + {"fec0::1", true}, // site-local + {"64:ff9b::8.8.8.8", false}, // NAT64 to a public host stays reachable + {"2001:db8::1", false}, // documentation prefix is not Teredo } for _, c := range cases { t.Run(c.ip, func(t *testing.T) {