1 Commits
Author SHA1 Message Date
clawbot faad959bea Refuse local-use NAT64 addresses, and IPv4-compatible ones in blocked_networks (closes #299)
check / check (push) Waiting to run
The check that keeps pixa from fetching internal addresses let through
64:ff9b:1::/48, the local-use NAT64 range, and matched an address in the
IPv4-compatible form (::/96) against blocked_networks only as IPv6, so
::203.0.113.5 passed with 203.0.113.0/24 listed. The local-use NAT64
range is now blocked next to 64:ff9b::/96, and an address in ::/96 is
refused when blocked_networks holds it as written or the IPv4 address it
holds. The mapped form stays matched as IPv4 only, as Go keeps plain
IPv4 addresses in that form; README.md says so.

Model: opus-5-5
2026-10-16 06:52:17 +02:00
4 changed files with 49 additions and 28 deletions
+4 -1
View File
@@ -694,7 +694,10 @@ Key settings in more detail:
and generator pages. Default: empty and generator pages. Default: empty
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection, added - `blocked_networks` — list of CIDR ranges to refuse for SSRF protection, added
to the always-enforced built-in ranges (loopback, private, link-local, CGNAT, to the always-enforced built-in ranges (loopback, private, link-local, CGNAT,
benchmark, NAT64, and the like); an invalid CIDR aborts startup benchmark, NAT64, and the like); an invalid CIDR aborts startup. An IPv6
address in the IPv4-mapped form (`::ffff:a.b.c.d`) is matched only as the IPv4
address it holds, so an IPv6 range such as `::/0` refuses neither it nor a
plain IPv4 address
- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front of - `trusted_proxies` — list of CIDR ranges of the reverse proxies in front of
pixa. `X-Forwarded-For` is believed only when the direct peer falls inside one pixa. `X-Forwarded-For` is believed only when the direct peer falls inside one
of these ranges; the logged and login-recorded client address is then the of these ranges; the logged and login-recorded client address is then the
+10 -7
View File
@@ -32,13 +32,16 @@ P2: security: per-origin rate limiting
- 2026-10-16 P1 security bug: the check that keeps pixa from fetching internal - 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 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 refuses `64:ff9b::/96`, and refuses an IPv6 address in the IPv4-compatible
form (`::/96`) against `blocked_networks` by the IPv4 address it holds, as it form (`::/96`) when `blocked_networks` contains either the address as written
already matched the mapped form (`::ffff:a.b.c.d`) (closes #299). A source or the IPv4 address it holds (closes #299). A source host such as
host such as `[64:ff9b:1::a00:1]` was fetched, which on a network that runs `[64:ff9b:1::a00:1]` was fetched, which on a network that runs NAT64 on that
NAT64 on that range reaches the IPv4 address it holds, here `10.0.0.1`; and range reaches the IPv4 address it holds, here `10.0.0.1`; and with
with `203.0.113.0/24` in `blocked_networks`, `[::ffff:203.0.113.5]` was `203.0.113.0/24` in `blocked_networks`, `[::ffff:203.0.113.5]` was refused but
refused but `[::203.0.113.5]` was fetched. Both now answer 403. `[::203.0.113.5]` was fetched. Both now answer 403. The mapped form
(`::ffff:a.b.c.d`) is still matched only as the IPv4 address it holds: Go
keeps a plain IPv4 address in that form, and an IPv6 entry such as `::/0` must
not refuse every IPv4 address.
- 2026-10-16 P1 security bug: the check that keeps pixa from fetching internal - 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 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 as `::127.0.0.1` or `::10.0.0.1`, by the IPv4 address in its last four bytes
+13 -5
View File
@@ -599,23 +599,31 @@ func isPrivateIP(ip net.IP) bool {
} }
// isBlockedIP reports whether ip is refused, either by the built-in // isBlockedIP reports whether ip is refused, either by the built-in
// internal-range check or by one of the operator-supplied prefixes. // internal-range check or by one of the operator-supplied prefixes. An
// IPv4-compatible address (in ::/96) is refused when a prefix contains the
// address as written or the IPv4 address it holds. An IPv4-mapped address (in
// ::ffff:0:0/96) is matched only as the IPv4 address it holds: Go keeps a
// plain IPv4 address in that form too, and an IPv6 prefix such as ::/0 must
// not refuse every IPv4 address.
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool { func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
if isPrivateIP(ip) { if isPrivateIP(ip) {
return true return true
} }
// An IPv4-compatible or IPv4-mapped IPv6 address is matched as the IPv4 addr, ok := netip.AddrFromSlice(ip)
// address it holds
addr, ok := netip.AddrFromSlice(ipv4CompatibleToIPv4(ip))
if !ok { if !ok {
return true return true
} }
addr = addr.Unmap() addr = addr.Unmap()
// ip read as IPv4 where it is or holds an IPv4 address, otherwise the
// zero netip.Addr, which no prefix contains. Matching asIPv4 as well as
// addr refuses more only for an IPv4-compatible address.
asIPv4, _ := netip.AddrFromSlice(ipv4CompatibleToIPv4(ip).To4())
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool { return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
return prefix.Contains(addr) return prefix.Contains(addr) || prefix.Contains(asIPv4)
}) })
} }
+22 -15
View File
@@ -589,19 +589,26 @@ func TestImageRoutesRefuseIPv6SourceHostHoldingInternalIPv4Address(t *testing.T)
} }
// TestImageRoutesCheckIPv4AddressInIPv6SourceHost requests ipv6SourcePath on // TestImageRoutesCheckIPv4AddressInIPv6SourceHost requests ipv6SourcePath on
// IPv6 source hosts that hold an IPv4 address, through a /v1/image/ URL with a // source hosts that hold an IPv4 address, through a /v1/image/ URL with a
// signature and through an encrypted URL, with the fetcher pixad uses. // signature and through an encrypted URL, with the fetcher pixad uses.
// [64:ff9b:1::a00:1], in the local-use NAT64 range (64:ff9b:1::/48, RFC 8215), // [64:ff9b:1::a00:1], in the local-use NAT64 range (64:ff9b:1::/48, RFC 8215),
// answers 403. [::203.0.113.5] and [::ffff:203.0.113.5] hold a public IPv4 // answers 403. [::203.0.113.5] holds the public address 203.0.113.5 in the
// address in the IPv4-compatible form (::/96) and in the IPv4-mapped form // IPv4-compatible form (::/96) and answers 403 when blocked_networks lists
// (::ffff:0:0/96). Each answers 403 when blocked_networks lists // 203.0.113.0/24 or ::/96. [::ffff:203.0.113.5] holds it in the IPv4-mapped
// 203.0.113.0/24, and when it lists the range of its own form. When // form (::ffff:0:0/96) and answers 403 when blocked_networks lists
// blocked_networks is empty [::203.0.113.5] is not refused: the fetcher tries // 203.0.113.0/24. Go keeps the plain address 203.0.113.5 in the mapped form
// to connect to it, finds nothing there, and the request fails with another // too, and an IPv6 range must not refuse it: with ::/0 listed it is not
// status. // refused. Neither is [::203.0.113.5] with blocked_networks empty. Where a
// host is not refused, the fetcher tries to connect to it, finds nothing
// there, and the request fails with another status.
func TestImageRoutesCheckIPv4AddressInIPv6SourceHost(t *testing.T) { func TestImageRoutesCheckIPv4AddressInIPv6SourceHost(t *testing.T) {
t.Parallel() t.Parallel()
const (
ipv4CompatibleHost = "[::203.0.113.5]"
ipv4MappedHost = "[::ffff:203.0.113.5]"
)
tests := []struct { tests := []struct {
name string name string
sourceHost string sourceHost string
@@ -610,19 +617,19 @@ func TestImageRoutesCheckIPv4AddressInIPv6SourceHost(t *testing.T) {
}{ }{
{"local-use NAT64 form of 10.0.0.1", "[64:ff9b:1::a00:1]", nil, true}, {"local-use NAT64 form of 10.0.0.1", "[64:ff9b:1::a00:1]", nil, true},
{"IPv4-compatible form of an address in blocked_networks", {"IPv4-compatible form of an address in blocked_networks",
"[::203.0.113.5]", ipv4CompatibleHost,
[]netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, true}, []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, true},
{"IPv4-compatible form with ::/96 in blocked_networks", {"IPv4-compatible form with ::/96 in blocked_networks",
"[::203.0.113.5]", ipv4CompatibleHost,
[]netip.Prefix{netip.MustParsePrefix("::/96")}, true}, []netip.Prefix{netip.MustParsePrefix("::/96")}, true},
{"IPv4-mapped form of an address in blocked_networks", {"IPv4-mapped form of an address in blocked_networks",
"[::ffff:203.0.113.5]", ipv4MappedHost,
[]netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, true}, []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, true},
{"IPv4-mapped form with ::ffff:0:0/96 in blocked_networks", {"plain IPv4 address with ::/0 in blocked_networks",
"[::ffff:203.0.113.5]", "203.0.113.5",
[]netip.Prefix{netip.MustParsePrefix("::ffff:0:0/96")}, true}, []netip.Prefix{netip.MustParsePrefix("::/0")}, false},
{"IPv4-compatible form of an address with blocked_networks empty", {"IPv4-compatible form of an address with blocked_networks empty",
"[::203.0.113.5]", nil, false}, ipv4CompatibleHost, nil, false},
} }
for _, tt := range tests { for _, tt := range tests {