Refuse local-use NAT64 addresses, and IPv4-compatible ones in blocked_networks (closes #299)
check / check (push) Canceled after 0s

64:ff9b:1::/48 joins the built-in blocked ranges next to 64:ff9b::/96.
isBlockedIP now reads an address in ::/96 as the IPv4 address in its
last four bytes before matching blocked_networks, as it already unmapped
the ::ffff: form. The reading isPrivateIP gained for ::/96 moved into
ipv4CompatibleToIPv4, which both call.

Model: opus-5-5
This commit is contained in:
2026-10-16 03:43:55 +00:00
parent 04d9a6bf37
commit 294474a2cd
2 changed files with 34 additions and 13 deletions
+9
View File
@@ -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
+25 -13
View File
@@ -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.