4 Commits
Author SHA1 Message Date
sneak 8fceeca202 Match blocked_networks against both forms of an address holding IPv4
check / check (push) Canceled after 0s
isBlockedIP refuses an address when a blocked_networks entry contains
it as an IPv6 address or contains the IPv4 address it holds, for the
mapped (::ffff:0:0/96) and the IPv4-compatible (::/96) form alike, so
an IPv6 entry refuses everything it refused on next. ipv6AndIPv4Forms
gives the two forms. Go keeps a plain IPv4 address in the mapped form,
so an entry covering ::ffff:0:0/96, such as ::/0, now refuses every
IPv4 address too.

Model: opus-5-5
2026-10-16 04:14:20 +00:00
sneak 4900837068 Test blocked_networks on both forms of an IPv6 source host holding IPv4
Both image routes: [::203.0.113.5] must answer 403 when blocked_networks
lists ::/96, and [::ffff:203.0.113.5] when it lists ::ffff:0:0/96 or
203.0.113.0/24. The ::/96 and ::ffff:0:0/96 cases fail on this branch:
isBlockedIP matches such an address only by the IPv4 address it holds.

Model: opus-5-5
2026-10-16 04:14:20 +00:00
sneak 294474a2cd 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
2026-10-16 03:43:55 +00:00
sneak 04d9a6bf37 Test the local-use NAT64 range and blocked_networks on IPv6 source hosts
check / check (push) Canceled after 0s
Both image routes, with the fetcher pixad builds from the config:
[64:ff9b:1::a00:1] must answer 403, and [::203.0.113.5] must answer 403
when blocked_networks lists 203.0.113.0/24 and must not be refused when
the list is empty. The first two fail on next: both addresses are
fetched.

Model: opus-5-5
2026-10-16 03:40:53 +00:00
4 changed files with 43 additions and 40 deletions
+1 -4
View File
@@ -694,10 +694,7 @@ Key settings in more detail:
and generator pages. Default: empty
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection, added
to the always-enforced built-in ranges (loopback, private, link-local, CGNAT,
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
benchmark, NAT64, and the like); an invalid CIDR aborts startup
- `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
of these ranges; the logged and login-recorded client address is then the
+9 -8
View File
@@ -32,16 +32,17 @@ P2: security: per-origin rate limiting
- 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 refuses an IPv6 address in the IPv4-compatible
form (`::/96`) when `blocked_networks` contains either the address as written
or the IPv4 address it holds (closes #299). A source host such as
refuses `64:ff9b::/96`, and refuses an IPv6 address that holds an IPv4
address, in the IPv4-compatible form (`::/96`) or the mapped form
(`::ffff:a.b.c.d`), when `blocked_networks` contains either the address as
written or the IPv4 address it holds (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
range reaches the IPv4 address it holds, here `10.0.0.1`; 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. 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.
`[::203.0.113.5]` was fetched; and with `::ffff:0:0/96` listed,
`[::ffff:203.0.113.5]` was fetched. All now answer 403. Go keeps a plain IPv4
address in the mapped form, so an IPv6 entry covering `::ffff:0:0/96`, such as
`::/0`, now refuses every IPv4 address too.
- 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
+22 -15
View File
@@ -599,32 +599,39 @@ func isPrivateIP(ip net.IP) bool {
}
// isBlockedIP reports whether ip is refused, either by the built-in
// 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.
// internal-range check or by one of the operator-supplied prefixes. An IPv6
// address that holds an IPv4 address is refused when a prefix contains the
// address as written or the IPv4 address it holds.
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
if isPrivateIP(ip) {
return true
}
addr, ok := netip.AddrFromSlice(ip)
if !ok {
asIPv6, asIPv4 := ipv6AndIPv4Forms(ip)
if !asIPv6.IsValid() {
return true
}
addr = addr.Unmap()
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
return prefix.Contains(asIPv6) || prefix.Contains(asIPv4)
})
}
// ipv6AndIPv4Forms returns ip as an IPv6 address and, when it holds an IPv4
// address in the IPv4-mapped form (::ffff:0:0/96) or the IPv4-compatible form
// (::/96), that IPv4 address. Otherwise the IPv4 form is the zero netip.Addr,
// which no prefix contains. Go keeps a plain IPv4 address in the mapped form,
// so the IPv6 form of 203.0.113.5 is ::ffff:203.0.113.5. An ip that is not 4
// or 16 bytes long gives two zero addresses.
func ipv6AndIPv4Forms(ip net.IP) (netip.Addr, netip.Addr) {
asIPv6, ok := netip.AddrFromSlice(ip.To16())
if !ok {
return netip.Addr{}, netip.Addr{}
}
// 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 prefix.Contains(addr) || prefix.Contains(asIPv4)
})
return asIPv6, asIPv4
}
// ipv4CompatibleToIPv4 returns the IPv4 address that an IPv4-compatible IPv6
+11 -13
View File
@@ -589,18 +589,16 @@ func TestImageRoutesRefuseIPv6SourceHostHoldingInternalIPv4Address(t *testing.T)
}
// TestImageRoutesCheckIPv4AddressInIPv6SourceHost requests ipv6SourcePath on
// source hosts that hold an IPv4 address, through a /v1/image/ URL with a
// IPv6 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.
// [64:ff9b:1::a00:1], in the local-use NAT64 range (64:ff9b:1::/48, RFC 8215),
// answers 403. [::203.0.113.5] holds the public address 203.0.113.5 in the
// IPv4-compatible form (::/96) and answers 403 when blocked_networks lists
// 203.0.113.0/24 or ::/96. [::ffff:203.0.113.5] holds it in the IPv4-mapped
// form (::ffff:0:0/96) and answers 403 when blocked_networks lists
// 203.0.113.0/24. Go keeps the plain address 203.0.113.5 in the mapped form
// too, and an IPv6 range must not refuse it: with ::/0 listed it is not
// 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.
// answers 403. [::203.0.113.5] and [::ffff:203.0.113.5] hold a public IPv4
// address in the IPv4-compatible form (::/96) and in the IPv4-mapped form
// (::ffff:0:0/96). Each answers 403 when blocked_networks lists
// 203.0.113.0/24, and when it lists the range of its own form. When
// blocked_networks is empty [::203.0.113.5] 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) {
t.Parallel()
@@ -625,9 +623,9 @@ func TestImageRoutesCheckIPv4AddressInIPv6SourceHost(t *testing.T) {
{"IPv4-mapped form of an address in blocked_networks",
ipv4MappedHost,
[]netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, true},
{"plain IPv4 address with ::/0 in blocked_networks",
"203.0.113.5",
[]netip.Prefix{netip.MustParsePrefix("::/0")}, false},
{"IPv4-mapped form with ::ffff:0:0/96 in blocked_networks",
ipv4MappedHost,
[]netip.Prefix{netip.MustParsePrefix("::ffff:0:0/96")}, true},
{"IPv4-compatible form of an address with blocked_networks empty",
ipv4CompatibleHost, nil, false},
}