diff --git a/fetchguard/ip.go b/fetchguard/ip.go index 06b77ab..681c201 100644 --- a/fetchguard/ip.go +++ b/fetchguard/ip.go @@ -22,16 +22,26 @@ var privateV6 = []netip.Prefix{ netip.MustParsePrefix("fc00::/7"), } +var ( + nat64WellKnownPrefix = netip.MustParsePrefix("64:ff9b::/96") + nat64LocalUsePrefix = netip.MustParsePrefix("64:ff9b:1::/48") + sixToFourPrefix = netip.MustParsePrefix("2002::/16") +) + // isReservedOrPrivate classifies addr against the PHP private/loopback/ -// reserved/CGNAT table. The caller must pass an already-Unmap()-ed address -// (fetch.go's dial hook); this function does not Unmap. +// reserved/CGNAT table, including IPv4 embedded in supported IPv6 transition +// formats. func isReservedOrPrivate(addr netip.Addr) bool { if !addr.IsValid() { return true } + addr = addr.Unmap() if addr.IsMulticast() || addr.IsUnspecified() { return true } + if embedded, ok := embeddedTransitionIPv4(addr); ok { + return isReservedOrPrivate(embedded) + } table := privateV4 if !addr.Is4() { table = privateV6 @@ -43,3 +53,26 @@ func isReservedOrPrivate(addr netip.Addr) bool { } return false } + +// embeddedTransitionIPv4 extracts IPv4 from the transition formats supported +// by fetchguard. A recognized but malformed RFC 6052 /48 address returns an +// invalid address with ok=true so the classifier fails closed. +func embeddedTransitionIPv4(addr netip.Addr) (netip.Addr, bool) { + if !addr.Is6() { + return netip.Addr{}, false + } + b := addr.As16() + switch { + case nat64WellKnownPrefix.Contains(addr): + return netip.AddrFrom4([4]byte{b[12], b[13], b[14], b[15]}), true + case nat64LocalUsePrefix.Contains(addr): + if b[8] != 0 { + return netip.Addr{}, true + } + return netip.AddrFrom4([4]byte{b[6], b[7], b[9], b[10]}), true + case sixToFourPrefix.Contains(addr): + return netip.AddrFrom4([4]byte{b[2], b[3], b[4], b[5]}), true + default: + return netip.Addr{}, false + } +}