Author SHA1 Message Date
sneak 0e9949afbc Test blocked_networks on both forms of an IPv6 source host holding IPv4
check / check (push) Canceled after 0s
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:06:52 +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 51 additions and 183 deletions
+2 -7
View File
@@ -199,9 +199,7 @@ into place, so a crash leaves no file under its own name with part of its data.
When pixa starts, and every five minutes after, it removes each cached source or When pixa starts, and every five minutes after, it removes each cached source or
transformed image whose file is empty, or not the size the database records for transformed image whose file is empty, or not the size the database records for
it, with a warning naming the file; the next request for it fetches the source it, with a warning naming the file; the next request for it fetches the source
or converts the image again. Looking for those files reads the database once for or converts the image again.
every cached file, so after a look that took longer than 30 seconds, pixa waits
ten times as long as it took before looking again.
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
Test). Toward it, pixa keeps in memory the content types of the 10,000 Test). Toward it, pixa keeps in memory the content types of the 10,000
@@ -696,10 +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
- `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
+7 -12
View File
@@ -30,20 +30,15 @@ P2: security: per-origin rate limiting
# Completed Steps # Completed Steps
- 2026-10-18 the next reconciliation pass waits ten times as long as the last
one took, so passes on a large cache no longer run back to back (closes #250).
- 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 matches an IPv6 address in the IPv4-compatible
form (`::/96`) when `blocked_networks` contains either the address as written form (`::/96`) against `blocked_networks` by the IPv4 address it holds, as it
or the IPv4 address it holds (closes #299). A source host such as already matched the mapped form (`::ffff:a.b.c.d`) (closes #299). A source
`[64:ff9b:1::a00:1]` was fetched, which on a network that runs NAT64 on that host such as `[64:ff9b:1::a00:1]` was fetched, which on a network that runs
range reaches the IPv4 address it holds, here `10.0.0.1`; and with NAT64 on that range reaches the IPv4 address it holds, here `10.0.0.1`; and
`203.0.113.0/24` in `blocked_networks`, `[::ffff:203.0.113.5]` was refused but with `203.0.113.0/24` in `blocked_networks`, `[::ffff:203.0.113.5]` was
`[::203.0.113.5]` was fetched. Both now answer 403. The mapped form refused but `[::203.0.113.5]` was fetched. Both now answer 403.
(`::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
+5 -13
View File
@@ -599,31 +599,23 @@ 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.
// 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
} }
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 { 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) || prefix.Contains(asIPv4) return prefix.Contains(addr)
}) })
} }
+22 -46
View File
@@ -18,14 +18,6 @@ import (
// write-pressure wakeups triggered by stores. // write-pressure wakeups triggered by stores.
const DefaultEvictionInterval = 5 * time.Minute const DefaultEvictionInterval = 5 * time.Minute
// reconciliationWaitFactor is how many times as long as a
// reconciliation pass took the eviction loop waits, after that pass
// ends, before it starts the next one. A pass reads a row and stats a
// file for every cached file, so its time grows with the cache; waiting
// ten times as long keeps reconciliation to at most a tenth of the time,
// however large the cache is.
const reconciliationWaitFactor = 10
// evictionBatchSize is how many LRU candidates of each class (variants // evictionBatchSize is how many LRU candidates of each class (variants
// and source blobs) one eviction pass fetches from the database. // and source blobs) one eviction pass fetches from the database.
const evictionBatchSize = 100 const evictionBatchSize = 100
@@ -446,12 +438,10 @@ func (c *Cache) notifyWritePressure() {
} }
// StartEviction launches the background eviction goroutine, which // StartEviction launches the background eviction goroutine, which
// reconciles the database accounting with the cache directories and
// removes the expired negative cache rows at startup and again on every // removes the expired negative cache rows at startup and again on every
// periodic tick thereafter, reconciles the database accounting with the // periodic tick thereafter, and evicts to the configured limit on the
// cache directories at startup and again on the first periodic tick // given periodic interval and on write-pressure notifications. On a
// that comes ten times as long after a reconciliation pass ended as
// that pass took, and evicts to the configured limit on the given
// periodic interval and on write-pressure notifications. On a
// disabled cache it only removes the expired negative cache rows, as // disabled cache it only removes the expired negative cache rows, as
// the negative cache is used either way. It is a no-op when already // the negative cache is used either way. It is a no-op when already
// started. The goroutine outlives the caller, so it runs with its own // started. The goroutine outlives the caller, so it runs with its own
@@ -492,7 +482,7 @@ func (c *Cache) StopEviction(ctx context.Context) error {
func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) { func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
defer close(c.evictionDone) defer close(c.evictionDone)
nextReconciliation := c.runReconciliationPass(ctx) c.runReconciliationPass(ctx)
c.runCleanExpired(ctx) c.runCleanExpired(ctx)
c.runEvictionPass(ctx) c.runEvictionPass(ctx)
@@ -504,20 +494,16 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
// Reconciliation walks the cache directories and reads a // Reconciliation walks the cache directories, so it only
// row for every file, so it runs only on the periodic // runs on the periodic ticker rather than on every
// ticker rather than on every write-pressure wakeup, and // write-pressure wakeup, keeping it off the per-store hot
// not before nextReconciliation, which is ten times as long // path. Reusing the eviction interval itself (rather than a
// after the last pass ended as that pass took. Passes then // separate, longer one) is a deliberate choice: it is the
// never run back to back, however long one takes. A file // simplest option that still bounds how long a store's
// whose best-effort accounting insert failed in a store // best-effort accounting insert can stay silently
// stays unaccounted for until the next pass, for about the // unaccounted for to one interval, on a process that is
// longer of one eviction interval and ten times the last // already running this loop regardless.
// pass's time. c.runReconciliationPass(ctx)
if !time.Now().Before(nextReconciliation) {
nextReconciliation = c.runReconciliationPass(ctx)
}
c.runCleanExpired(ctx) c.runCleanExpired(ctx)
case <-c.evictionPressure: case <-c.evictionPressure:
} }
@@ -541,25 +527,17 @@ func (c *Cache) runEvictionPass(ctx context.Context) {
} }
// runReconciliationPass runs one reconciliation pass, logging failures // runReconciliationPass runs one reconciliation pass, logging failures
// instead of propagating them (the loop must keep running), and returns // instead of propagating them (the loop must keep running). It does
// the earliest time the next pass may start: reconciliationWaitFactor // nothing once ctx is cancelled.
// times as long after this pass ended as it took. It does nothing once func (c *Cache) runReconciliationPass(ctx context.Context) {
// ctx is cancelled.
func (c *Cache) runReconciliationPass(ctx context.Context) time.Time {
if ctx.Err() != nil { if ctx.Err() != nil {
return time.Now() return
} }
started := time.Now()
err := c.reconcileAccounting(ctx) err := c.reconcileAccounting(ctx)
if err != nil { if err != nil {
c.log.Warn("cache accounting reconciliation failed", "error", err) c.log.Warn("cache accounting reconciliation failed", "error", err)
} }
ended := time.Now()
return ended.Add(reconciliationWaitFactor * ended.Sub(started))
} }
// runCleanExpired runs CleanExpired, logging a failure instead of // runCleanExpired runs CleanExpired, logging a failure instead of
@@ -578,8 +556,7 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
// reconcileAccounting synchronizes the database size accounting with // reconcileAccounting synchronizes the database size accounting with
// the actual contents of the cache directories. It runs at startup and // the actual contents of the cache directories. It runs at startup and
// again on later periodic eviction ticks, each no sooner than ten times // again on every periodic eviction tick thereafter, off the request
// as long after the last pass ended as that pass took, off the request
// hot path: it adopts variant files that predate the accounting table // hot path: it adopts variant files that predate the accounting table
// (or whose accounting insert failed, e.g. StoreVariant's best-effort // (or whose accounting insert failed, e.g. StoreVariant's best-effort
// insert under transient DB contention), drops accounting rows whose // insert under transient DB contention), drops accounting rows whose
@@ -589,10 +566,9 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
// files left behind by crashed writes, and last checks the total cache // files left behind by crashed writes, and last checks the total cache
// usage against the tables. It reads the tables a page at a time. // usage against the tables. It reads the tables a page at a time.
// Running it periodically, not just once, bounds how long such drift can // Running it periodically, not just once, bounds how long such drift can
// accumulate unaccounted for on a long-running process to about the // accumulate unaccounted for on a long-running process to one eviction
// longer of one eviction interval and ten times the last pass's time. // interval. Once ctx is cancelled, it stops at the next file or row and
// Once ctx is cancelled, it stops at the next file or row and returns // returns ctx's error.
// ctx's error.
func (c *Cache) reconcileAccounting(ctx context.Context) error { func (c *Cache) reconcileAccounting(ctx context.Context) error {
if c.disabled { if c.disabled {
return nil return nil
@@ -918,89 +918,6 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
} }
} }
// TestReconciliationPassesAreSpacedByTenTimesTheirTime makes the hook
// called after every read of the reconciliation pass sleep 10 ms, runs
// the eviction loop on a 10 ms interval, and records when each of those
// calls starts and ends. A pass starts before its first call and ends
// after its last, so the time from the first call's start to the last
// call's end is at most the pass's time. The next pass's first call must
// start at least ten times that long after the last call ended.
func TestReconciliationPassesAreSpacedByTenTimesTheirTime(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
// A pass made here, before the loop starts, counts the reads one
// pass makes on this empty cache, so the reads recorded below can be
// split into passes.
readsPerPass := 0
cache.reconciliationReadTestHook = func(int) { readsPerPass++ }
err := cache.reconcileAccounting(t.Context())
if err != nil {
t.Fatalf("reconcileAccounting failed: %v", err)
}
t.Logf("one reconciliation pass makes %d reads", readsPerPass)
if readsPerPass == 0 {
t.Fatal("a reconciliation pass made no read")
}
// The pass at startup and two after it.
const passes = 3
type read struct{ start, end time.Time }
// The channel holds every read the test looks at, so none of those
// is dropped; reads after them are, and never block the loop.
reads := make(chan read, passes*readsPerPass)
cache.reconciliationReadTestHook = func(int) {
start := time.Now()
time.Sleep(10 * time.Millisecond)
select {
case reads <- read{start: start, end: time.Now()}:
default:
}
}
cache.StartEviction(10 * time.Millisecond)
defer func() { _ = cache.StopEviction(t.Context()) }()
recorded := make([]read, 0, passes*readsPerPass)
timeout := time.After(15 * time.Second)
for len(recorded) < passes*readsPerPass {
select {
case r := <-reads:
recorded = append(recorded, r)
case <-timeout:
t.Fatalf("saw %d reconciliation reads in 15 s, want %d (%d passes)",
len(recorded), passes*readsPerPass, passes)
}
}
for pass := range passes - 1 {
first := recorded[pass*readsPerPass]
last := recorded[(pass+1)*readsPerPass-1]
next := recorded[(pass+1)*readsPerPass]
passTime := last.end.Sub(first.start)
wait := next.start.Sub(last.end)
t.Logf("pass %d took at least %v, and the next pass started %v after it",
pass+1, passTime, wait)
if wait < 10*passTime {
t.Errorf("pass %d took at least %v, and the next pass started %v "+
"after it, want at least %v", pass+1, passTime, wait, 10*passTime)
}
}
}
// TestStopEvictionInterruptsPassInProgress holds the test database's // TestStopEvictionInterruptsPassInProgress holds the test database's
// only connection, so the startup reconciliation pass waits for it, and // only connection, so the startup reconciliation pass waits for it, and
// checks that StopEviction stops that pass instead of waiting for the // checks that StopEviction stops that pass instead of waiting for the
+15 -22
View File
@@ -589,26 +589,19 @@ 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()
const (
ipv4CompatibleHost = "[::203.0.113.5]"
ipv4MappedHost = "[::ffff:203.0.113.5]"
)
tests := []struct { tests := []struct {
name string name string
sourceHost string sourceHost string
@@ -617,19 +610,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",
ipv4CompatibleHost, "[::203.0.113.5]",
[]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",
ipv4CompatibleHost, "[::203.0.113.5]",
[]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",
ipv4MappedHost, "[::ffff:203.0.113.5]",
[]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", "[::ffff:203.0.113.5]",
[]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}, "[::203.0.113.5]", nil, false},
} }
for _, tt := range tests { for _, tt := range tests {