diff --git a/TODO.md b/TODO.md index 5009531..58098f7 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,15 @@ P2: security: per-origin rate limiting # Completed Steps +- 2026-10-16 P1 security bug: the check that keeps pixa from fetching internal + addresses refuses the local-use NAT64 range (`64:ff9b:1::/48`, RFC 8215) as it + refuses `64:ff9b::/96`, and matches an IPv6 address in the IPv4-compatible + form (`::/96`) against `blocked_networks` by the IPv4 address it holds, as it + already matched the mapped form (`::ffff:a.b.c.d`) (closes #299). A source + host such as `[64:ff9b:1::a00:1]` was fetched, which on a network that runs + NAT64 on that range reaches the IPv4 address it holds, here `10.0.0.1`; and + with `203.0.113.0/24` in `blocked_networks`, `[::ffff:203.0.113.5]` was + refused but `[::203.0.113.5]` was fetched. Both now answer 403. - 2026-10-16 P1 security bug: the check that keeps pixa from fetching internal addresses judges an IPv6 address in the IPv4-compatible form (`::/96`), such as `::127.0.0.1` or `::10.0.0.1`, by the IPv4 address in its last four bytes diff --git a/internal/httpfetcher/httpfetcher.go b/internal/httpfetcher/httpfetcher.go index 53d5b20..97db365 100644 --- a/internal/httpfetcher/httpfetcher.go +++ b/internal/httpfetcher/httpfetcher.go @@ -59,15 +59,17 @@ const ( // builtinBlockedPrefixes are internal or special-use ranges that Go's // net.IP predicates (IsPrivate, IsLinkLocalUnicast, and the like) do not // already cover. They are always blocked, in addition to any -// operator-supplied networks. IPv4-mapped IPv6 addresses are unmapped -// before matching, so these IPv4 ranges are caught in both forms. +// operator-supplied networks. IPv4-mapped and IPv4-compatible IPv6 +// addresses are read as the IPv4 address they hold before matching, so +// these IPv4 ranges are caught in all three forms. // //nolint:gochecknoglobals // immutable built-in blocklist var builtinBlockedPrefixes = []netip.Prefix{ - netip.MustParsePrefix("100.64.0.0/10"), // RFC 6598 CGNAT / carrier-grade NAT - netip.MustParsePrefix("192.0.0.0/24"), // RFC 6890 IETF protocol assignments - netip.MustParsePrefix("198.18.0.0/15"), // RFC 2544 benchmarking range - netip.MustParsePrefix("64:ff9b::/96"), // RFC 6052 NAT64 (maps onto IPv4) + netip.MustParsePrefix("100.64.0.0/10"), // RFC 6598 CGNAT / carrier-grade NAT + netip.MustParsePrefix("192.0.0.0/24"), // RFC 6890 IETF protocol assignments + netip.MustParsePrefix("198.18.0.0/15"), // RFC 2544 benchmarking range + netip.MustParsePrefix("64:ff9b::/96"), // RFC 6052 NAT64 (maps onto IPv4) + netip.MustParsePrefix("64:ff9b:1::/48"), // RFC 8215 local-use NAT64 (maps onto IPv4) } // Fetcher errors. @@ -543,12 +545,8 @@ func isPrivateIP(ip net.IP) bool { return true } - // An IPv4-compatible IPv6 address (in ::/96, such as ::127.0.0.1) holds - // an IPv4 address in its last four bytes and is judged as that address - if addr, ok := netip.AddrFromSlice(ip); ok && - netip.MustParsePrefix("::/96").Contains(addr) { - return isPrivateIP(ip[12:]) - } + // An IPv4-compatible IPv6 address is judged as the IPv4 address it holds + ip = ipv4CompatibleToIPv4(ip) // Check for loopback if ip.IsLoopback() { @@ -607,7 +605,9 @@ func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool { return true } - addr, ok := netip.AddrFromSlice(ip) + // An IPv4-compatible or IPv4-mapped IPv6 address is matched as the IPv4 + // address it holds + addr, ok := netip.AddrFromSlice(ipv4CompatibleToIPv4(ip)) if !ok { return true } @@ -619,6 +619,18 @@ func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool { }) } +// ipv4CompatibleToIPv4 returns the IPv4 address that an IPv4-compatible IPv6 +// address (in ::/96, such as ::127.0.0.1) holds in its last four bytes, and +// any other address unchanged. +func ipv4CompatibleToIPv4(ip net.IP) net.IP { + addr, ok := netip.AddrFromSlice(ip) + if ok && netip.MustParsePrefix("::/96").Contains(addr) { + return ip[12:] + } + + return ip +} + // ssrfSafeDialer validates IP addresses against the built-in blocked ranges // before connecting. New wraps dialSSRFSafe with the operator-supplied // blocklist; this entry point enforces the built-in ranges alone.