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
6 changed files with 44 additions and 133 deletions
+1 -6
View File
@@ -694,12 +694,7 @@ 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. An IPv6 benchmark, NAT64, and the like); an invalid CIDR aborts startup
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. An entry written in that form is read as the IPv4 range it
covers, so `::ffff:10.0.0.0/104` refuses what `10.0.0.0/8` refuses; such an
entry shorter than `/96` covers more than IPv4 addresses and aborts startup
- `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
+9 -11
View File
@@ -30,21 +30,19 @@ P2: security: per-origin rate limiting
# Completed Steps # Completed Steps
- 2026-10-18 a `blocked_networks` entry in the IPv4-mapped form refused nothing;
`::ffff:10.0.0.0/104` is now read as `10.0.0.0/8`, and such an entry shorter
than `/96` aborts startup (closes #301).
- 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 refuses an IPv6 address in the IPv4-compatible refuses `64:ff9b::/96`, and refuses an IPv6 address that holds an IPv4
form (`::/96`) when `blocked_networks` contains either the address as written address, in the IPv4-compatible form (`::/96`) or the mapped form
or the IPv4 address it holds (closes #299). A source host such as (`::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 `[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.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 `[::203.0.113.5]` was fetched; and with `::ffff:0:0/96` listed,
(`::ffff:a.b.c.d`) is still matched only as the IPv4 address it holds: Go `[::ffff:203.0.113.5]` was fetched. All now answer 403. Go keeps a plain IPv4
keeps a plain IPv4 address in that form, and an IPv6 entry such as `::/0` must address in the mapped form, so an IPv6 entry covering `::ffff:0:0/96`, such as
not refuse every IPv4 address. `::/0`, now refuses every IPv4 address too.
- 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
@@ -88,56 +88,3 @@ func TestBlockedNetworksInvalidAbortsStartup(t *testing.T) {
}, },
}) })
} }
// TestBlockedNetworksMappedEntryIsIPv4Range checks that an entry written in
// the IPv4-mapped form (::ffff:a.b.c.d) is kept as the IPv4 range it covers,
// since the fetcher matches an IPv4-mapped address as the IPv4 address it
// holds. /96, the shortest such entry, covers every IPv4 address.
func TestBlockedNetworksMappedEntryIsIPv4Range(t *testing.T) {
t.Parallel()
yamlContent := signingKeyLine + `blocked_networks:
- "::ffff:10.0.0.0/104"
- "::ffff:0:0/96"
`
c, err := configFromYAML(t, yamlContent)
if err != nil {
t.Fatalf("IPv4-mapped blocked_networks entries should load, got error: %v", err)
}
want := []string{"10.0.0.0/8", "0.0.0.0/0"}
if len(c.BlockedNetworks) != len(want) {
t.Fatalf("BlockedNetworks = %v, want %d entries", c.BlockedNetworks, len(want))
}
for i, w := range want {
if got := c.BlockedNetworks[i].String(); got != w {
t.Errorf("BlockedNetworks[%d] = %q, want %q", i, got, w)
}
}
}
// TestBlockedNetworksMappedEntryShorterThan96AbortsStartup checks that an
// entry in the IPv4-mapped form shorter than /96, which covers more than the
// IPv4 addresses, aborts startup with an error naming the key and the value.
func TestBlockedNetworksMappedEntryShorterThan96AbortsStartup(t *testing.T) {
t.Parallel()
runAbortCases(t, []abortCase{
{
name: "mapped-shorter-than-96",
yaml: signingKeyLine + `blocked_networks:
- "::ffff:0.0.0.0/80"
`,
wantErrSubstrings: []string{keyBlockedNetworks, "::ffff:0.0.0.0/80"},
},
{
name: "mapped-95",
yaml: signingKeyLine + `blocked_networks:
- "::ffff:0:0/95"
`,
wantErrSubstrings: []string{keyBlockedNetworks},
},
})
}
+1 -35
View File
@@ -91,7 +91,6 @@ var (
errNotABoolean = errors.New("not a boolean") errNotABoolean = errors.New("not a boolean")
errNotAStringList = errors.New("not a list of strings") errNotAStringList = errors.New("not a list of strings")
errNotAValidCIDR = errors.New("not a valid CIDR network") errNotAValidCIDR = errors.New("not a valid CIDR network")
errMappedTooShort = errors.New("in the IPv4-mapped form but shorter than /96")
errNotAMetricsMap = errors.New("not a map of metrics settings") errNotAMetricsMap = errors.New("not a map of metrics settings")
errEmptyListEntry = errors.New("list contains an empty entry") errEmptyListEntry = errors.New("list contains an empty entry")
errEmptyEntry = errors.New("contains an empty entry") errEmptyEntry = errors.New("contains an empty entry")
@@ -272,7 +271,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
return nil, err return nil, err
} }
blockedNetworks, err := parseBlockedNetworks(sc) blockedNetworks, err := parseCIDRList(sc, keyBlockedNetworks)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1279,39 +1278,6 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
return prefixes, nil return prefixes, nil
} }
// parseBlockedNetworks parses blocked_networks with parseCIDRList, and keeps
// an entry written in the IPv4-mapped form (::ffff:a.b.c.d) as the IPv4
// range it covers: ::ffff:10.0.0.0/104 is kept as 10.0.0.0/8. The fetcher
// matches an IPv4-mapped address as the IPv4 address it holds, so the entry
// as written would match nothing. Such an entry shorter than /96 covers more
// than the IPv4 addresses and aborts startup.
func parseBlockedNetworks(sc *smartconfig.Config) ([]netip.Prefix, error) {
prefixes, err := parseCIDRList(sc, keyBlockedNetworks)
if err != nil {
return nil, err
}
// An IPv4-mapped address is the 96 bits of ::ffff:0:0/96 followed by
// the 32 bits of the IPv4 address it holds.
const mappedPrefixBits = 96
for i, prefix := range prefixes {
if !prefix.Addr().Is4In6() {
continue
}
if prefix.Bits() < mappedPrefixBits {
return nil, fmt.Errorf("%s: value %q is %w",
settingName(keyBlockedNetworks), prefix, errMappedTooShort)
}
prefixes[i] = netip.PrefixFrom(prefix.Addr().Unmap(),
prefix.Bits()-mappedPrefixBits)
}
return prefixes, nil
}
// parseHostList parses the value of the named config key into host patterns, // parseHostList parses the value of the named config key into host patterns,
// or returns nil if the key is omitted. It accepts a YAML list of strings or a // or returns nil if the key is omitted. It accepts a YAML list of strings or a
// comma-separated string. An explicitly null value, a wrong type, an empty // comma-separated string. An explicitly null value, a wrong type, an empty
+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 // isBlockedIP reports whether ip is refused, either by the built-in
// internal-range check or by one of the operator-supplied prefixes. An // internal-range check or by one of the operator-supplied prefixes. An IPv6
// IPv4-compatible address (in ::/96) is refused when a prefix contains the // address that holds an IPv4 address is refused when a prefix contains the
// address as written or the IPv4 address it holds. An IPv4-mapped address (in // address as written or the IPv4 address it holds.
// ::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
} }
addr, ok := netip.AddrFromSlice(ip) asIPv6, asIPv4 := ipv6AndIPv4Forms(ip)
if !ok { if !asIPv6.IsValid() {
return true 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()) asIPv4, _ := netip.AddrFromSlice(ipv4CompatibleToIPv4(ip).To4())
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool { return asIPv6, asIPv4
return prefix.Contains(addr) || prefix.Contains(asIPv4)
})
} }
// ipv4CompatibleToIPv4 returns the IPv4 address that an IPv4-compatible IPv6 // 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 // 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. // 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] holds the public address 203.0.113.5 in the // answers 403. [::203.0.113.5] and [::ffff:203.0.113.5] hold a public IPv4
// IPv4-compatible form (::/96) and answers 403 when blocked_networks lists // address in the IPv4-compatible form (::/96) and in the IPv4-mapped form
// 203.0.113.0/24 or ::/96. [::ffff:203.0.113.5] holds it in the IPv4-mapped // (::ffff:0:0/96). Each answers 403 when blocked_networks lists
// form (::ffff:0:0/96) and answers 403 when blocked_networks lists // 203.0.113.0/24, and when it lists the range of its own form. When
// 203.0.113.0/24. Go keeps the plain address 203.0.113.5 in the mapped form // blocked_networks is empty [::203.0.113.5] is not refused: the fetcher tries
// too, and an IPv6 range must not refuse it: with ::/0 listed it is not // to connect to it, finds nothing there, and the request fails with another
// refused. Neither is [::203.0.113.5] with blocked_networks empty. Where a // status.
// 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()
@@ -625,9 +623,9 @@ func TestImageRoutesCheckIPv4AddressInIPv6SourceHost(t *testing.T) {
{"IPv4-mapped form of an address in blocked_networks", {"IPv4-mapped form of an address in blocked_networks",
ipv4MappedHost, ipv4MappedHost,
[]netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, true}, []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, true},
{"plain IPv4 address with ::/0 in blocked_networks", {"IPv4-mapped form with ::ffff:0:0/96 in blocked_networks",
"203.0.113.5", ipv4MappedHost,
[]netip.Prefix{netip.MustParsePrefix("::/0")}, false}, []netip.Prefix{netip.MustParsePrefix("::ffff:0:0/96")}, true},
{"IPv4-compatible form of an address with blocked_networks empty", {"IPv4-compatible form of an address with blocked_networks empty",
ipv4CompatibleHost, nil, false}, ipv4CompatibleHost, nil, false},
} }