diff --git a/internal/tools/net.go b/internal/tools/net.go index 9f38d62..c0e7752 100644 --- a/internal/tools/net.go +++ b/internal/tools/net.go @@ -1,35 +1,111 @@ package tools import ( + "encoding/binary" "net" "net/url" ) -// cgnatRange is the CGNAT shared address space (RFC 6598), not covered by net.IP.IsPrivate(). -// CGNAT (Carrier-Grade NAT) is a technique used by ISPs to conserve IPv4 addresses. Instead of assigning a unique -// public IP to every customer, the ISP places many customers behind a shared NAT, then gives them all addresses -// from the reserved 100.64.0.0/10 range (RFC 6598) on their internal network. -var cgnatRange = func() *net.IPNet { - _, cidr, _ := net.ParseCIDR("100.64.0.0/10") +var ( + // cgnatRange is the CGNAT shared address space (RFC 6598), not covered by net.IP.IsPrivate(). + // CGNAT (Carrier-Grade NAT) is a technique used by ISPs to conserve IPv4 addresses. Instead of assigning a unique + // public IP to every customer, the ISP places many customers behind a shared NAT, then gives them all addresses + // from the reserved 100.64.0.0/10 range (RFC 6598) on their internal network. + cgnatRange = mustCIDR("100.64.0.0/10") + + // IPv6 transition prefixes that embed an IPv4 destination. Go's net.IP.Is* family + // does not decode these, so an IPv6 literal of one of these forms can carry a + // private/link-local IPv4 destination past the stdlib checks. See golang/go#79925. + nat64WellKnown = mustCIDR("64:ff9b::/96") // RFC 6052 + nat64LocalUse = mustCIDR("64:ff9b:1::/48") // RFC 8215 + sixToFour = mustCIDR("2002::/16") // RFC 3056 + teredo = mustCIDR("2001::/32") // RFC 4380 + ipv4Compatible = mustCIDR("::/96") // RFC 4291 §2.5.5.1 + // IPv4-mapped IPv6 (::ffff:0:0/96, RFC 4291 §2.5.5.2) is normalised by net.IP.To4, + // so the stdlib Is* checks above already see the embedded IPv4 - no decode needed. + + // Direct IPv6 prefixes outside the scope of Go's stdlib Is* family. + deprecatedSiteLocal = mustCIDR("fec0::/10") // RFC 3879 / RFC 4291 §2.5.7 — deprecated, still routable on dual-stack hosts + documentationPrefix = mustCIDR("2001:db8::/32") // RFC 3849 — documentation only, must not appear in real traffic +) + +// MustCIDR is a helper for use in global var initialisation. +func mustCIDR(s string) *net.IPNet { + _, cidr, _ := net.ParseCIDR(s) + return cidr -}() +} // IsInternalIP checks if the given IP address is an internal IP address (e.g., loopback, private, link-local, or multicast). -// IsLoopback — 127.0.0.0/8, ::1 -// IsPrivate — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 -// IsLinkLocalUnicast — 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254) -// IsLinkLocalMulticast — 224.0.0.0/24, ff02::/16 -// IsUnspecified — 0.0.0.0, :: -// IsMulticast — 224.0.0.0/4, ff00::/8 -// CGNAT — 100.64.0.0/10 (RFC 6598) (Carrier-Grade NAT) +// IsLoopback - 127.0.0.0/8, ::1 +// IsPrivate - 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 +// IsLinkLocalUnicast - 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254) +// IsLinkLocalMulticast - 224.0.0.0/24, ff02::/16 +// IsUnspecified - 0.0.0.0, :: +// IsMulticast - 224.0.0.0/4, ff00::/8 +// CGNAT - 100.64.0.0/10 (RFC 6598) (Carrier-Grade NAT) +// IPv6 transition forms - NAT64 (RFC 6052/8215), 6to4 (RFC 3056), Teredo (RFC 4380), +// IPv4-compatible (RFC 4291) - re-checked against their embedded IPv4. func IsInternalIP(ip net.IP) bool { - return ip.IsLoopback() || + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() || - cgnatRange.Contains(ip) + cgnatRange.Contains(ip) || + deprecatedSiteLocal.Contains(ip) || + documentationPrefix.Contains(ip) { + return true + } + + if embeddedV4, ok := embeddedIPv4(ip); ok { + return IsInternalIP(embeddedV4) + } + + return false +} + +// embeddedIPv4 returns the IPv4 destination encoded in ip, if ip is an IPv6 form +// documented to carry one. Without this, an IPv6 literal like 64:ff9b::a9fe:a9fe +// (NAT64 wrapping 169.254.169.254) bypasses the stdlib Is* checks above. +func embeddedIPv4(ip net.IP) (net.IP, bool) { + // Skip addresses that are already IPv4 (4-byte or IPv4-mapped IPv6) - those are + // covered by the stdlib Is* checks via To4 normalisation. Re-entering here would + // recurse infinitely, because To16 turns an IPv4 back into ::ffff:. + if ip.To4() != nil { + return nil, false + } + + ip16 := ip.To16() + if ip16 == nil || len(ip16) != net.IPv6len { + return nil, false + } + + switch { + case nat64WellKnown.Contains(ip16), nat64LocalUse.Contains(ip16), + ipv4Compatible.Contains(ip16): + // Last 32 bits are the embedded IPv4. + return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15]).To4(), true + case sixToFour.Contains(ip16): + // Bits 16..47 are the embedded IPv4. + return net.IPv4(ip16[2], ip16[3], ip16[4], ip16[5]).To4(), true + case teredo.Contains(ip16): + // Bits 96..127 are the embedded IPv4 XOR'd with 0xFFFFFFFF. + x := binary.BigEndian.Uint32(ip16[12:16]) ^ 0xFFFFFFFF + b := make([]byte, 4) + binary.BigEndian.PutUint32(b, x) + return net.IPv4(b[0], b[1], b[2], b[3]).To4(), true + case ip16[10] == 0x5e && ip16[11] == 0xfe: + // ISATAP (RFC 5214) - interface identifier ends with :5efe:. The /64 + // prefix is not fixed (any subnet can carry ISATAP), so match structurally + // on bytes 10-11 and treat bytes 12-15 as the embedded IPv4. Must run after + // the fixed-prefix cases above (Teredo can legitimately have 5efe in bytes + // 10-11; its embedding takes precedence). + return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15]).To4(), true + } + + return nil, false } // IsValidLinkURL checks if the provided string is a valid URL with http or https scheme and a non-empty hostname. diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index e587c1b..81126ab 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -19,11 +19,27 @@ func TestIsInternalIP(t *testing.T) { "224.0.0.1", // multicast "100.64.0.1", // CGNAT start "100.127.255.255", // CGNAT end + // IPv6 transition forms embedding an internal IPv4 destination — golang/go#79925. + "64:ff9b::a9fe:a9fe", // NAT64 well-known (RFC 6052) wrapping 169.254.169.254 + "64:ff9b:1::a9fe:a9fe", // NAT64 local-use (RFC 8215) wrapping 169.254.169.254 + "2002:a9fe:a9fe::", // 6to4 (RFC 3056) wrapping 169.254.169.254 + "::a9fe:a9fe", // IPv4-compatible IPv6 (RFC 4291) wrapping 169.254.169.254 + "64:ff9b::7f00:1", // NAT64 wrapping 127.0.0.1 + "2002:0a00:0001::", // 6to4 wrapping 10.0.0.1 + "::ffff:169.254.169.254", // IPv4-mapped (also caught by stdlib via To4) + "::5efe:a9fe:a9fe", // ISATAP (RFC 5214) wrapping 169.254.169.254 + "2001:db8::5efe:7f00:1", // ISATAP under a documentation prefix wrapping 127.0.0.1 + "fec0::1", // deprecated site-local (RFC 3879 / RFC 4291 §2.5.7) + "2001:db8::1", // documentation prefix (RFC 3849) + "2001:db8::5efe:0808:0808", // documentation prefix (blocked regardless of embedded IPv4) } external := []string{ "8.8.8.8", "1.1.1.1", - "100.128.0.1", // just outside CGNAT range + "100.128.0.1", // just outside CGNAT range + "2001:4860:4860::8888", // Google public DNS over IPv6 + "2002:0808:0808::", // 6to4 wrapping 8.8.8.8 (public IPv4) + "64:ff9b::0808:0808", // NAT64 wrapping 8.8.8.8 (public IPv4) } for _, s := range internal {