From 04d9a6bf37dec7dc70fd95c0ded9cf0a1dcc9971 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 16 Oct 2026 03:40:53 +0000 Subject: [PATCH 1/7] Test the local-use NAT64 range and blocked_networks on IPv6 source hosts 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 --- internal/server/image_proxy_internal_test.go | 53 ++++++++++++++++++ internal/server/source_url_internal_test.go | 59 ++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/internal/server/image_proxy_internal_test.go b/internal/server/image_proxy_internal_test.go index 24b7961..ba56e4f 100644 --- a/internal/server/image_proxy_internal_test.go +++ b/internal/server/image_proxy_internal_test.go @@ -14,10 +14,12 @@ import ( "net" "net/http" "net/http/httptest" + "net/netip" "os" "path/filepath" "sync/atomic" "testing" + "time" "go.uber.org/fx" "go.uber.org/fx/fxtest" @@ -183,6 +185,57 @@ func startImageProxy( return s, db.DB(), stateDir } +// startImageProxyWithBlockedNetworks starts the components pixad's fx app +// builds, from a config with a fresh state directory and blockedNetworks as +// blocked_networks. Unlike startImageProxy it provides no upstream fetcher, so +// the handlers build the one pixad uses, which checks every address it +// connects to against the built-in blocked ranges and blocked_networks. It +// returns the server with its routes. +func startImageProxyWithBlockedNetworks( + t *testing.T, blockedNetworks []netip.Prefix, +) *Server { + t.Helper() + + stateDir := t.TempDir() + cfg := &config.Config{ + SigningKey: testSigningKey, + StateDir: stateDir, + DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"), + BlockedNetworks: blockedNetworks, + // A fetch that gets past the checks and finds nothing at the address + // fails within this time at the latest. + UpstreamFetchTimeout: 2 * time.Second, + // With no connection slots, the fetch would fail before connecting. + UpstreamConnections: config.DefaultUpstreamConnections, + DownstreamTimeout: config.DefaultDownstreamTimeout, + } + + var ( + h *handlers.Handlers + mw *middleware.Middleware + ) + + app := fxtest.New(t, + fx.Supply(cfg), + fx.Provide( + globals.New, + logger.New, + database.New, + healthcheck.New, + handlers.New, + middleware.New, + ), + fx.Populate(&h, &mw), + ) + app.RequireStart() + t.Cleanup(app.RequireStop) + + s := &Server{config: cfg, mw: mw, h: h} + s.SetupRoutes() + + return s +} + // getImage sends a GET for target to s and fails unless it answers 200. func getImage(t *testing.T, s *Server, target string) *httptest.ResponseRecorder { t.Helper() diff --git a/internal/server/source_url_internal_test.go b/internal/server/source_url_internal_test.go index 172de90..1411602 100644 --- a/internal/server/source_url_internal_test.go +++ b/internal/server/source_url_internal_test.go @@ -3,6 +3,7 @@ package server import ( "net/http" "net/http/httptest" + "net/netip" "net/url" "regexp" "slices" @@ -586,3 +587,61 @@ func TestImageRoutesRefuseIPv6SourceHostHoldingInternalIPv4Address(t *testing.T) t.Errorf("upstream received %q, want no request", got) } } + +// TestImageRoutesCheckIPv4AddressInIPv6SourceHost requests ipv6SourcePath on +// 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 a public IPv4 address in the +// IPv4-compatible form (::/96). It answers 403 when blocked_networks lists +// 203.0.113.0/24. When blocked_networks is empty it 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() + + tests := []struct { + name string + sourceHost string + blockedNetworks []netip.Prefix + wantForbidden bool + }{ + {"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", + "[::203.0.113.5]", + []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, true}, + {"IPv4-compatible form of an address with blocked_networks empty", + "[::203.0.113.5]", nil, false}, + } + + for _, tt := range tests { + imageURLs := map[string]string{ + "image URL with a signature": signedImageURL(t, tt.sourceHost), + "encrypted URL": encryptedImageURL(t, tt.sourceHost), + } + + for route, imageURL := range imageURLs { + t.Run(tt.name+" "+route, func(t *testing.T) { + t.Parallel() + + s := startImageProxyWithBlockedNetworks(t, tt.blockedNetworks) + + rec := httptest.NewRecorder() + s.ServeHTTP(rec, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, imageURL, nil)) + t.Logf("GET %s: %d", imageURL, rec.Code) + + forbidden := rec.Code == http.StatusForbidden + if tt.wantForbidden && !forbidden { + t.Errorf("status = %d, want %d; body %s", + rec.Code, http.StatusForbidden, rec.Body.String()) + } + + if !tt.wantForbidden && forbidden { + t.Errorf("status = %d, want any other status; body %s", + rec.Code, rec.Body.String()) + } + }) + } + } +} -- 2.54.0 From 294474a2cdc2239f357acf901e3ad01b0b46342f Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 16 Oct 2026 03:43:47 +0000 Subject: [PATCH 2/7] Refuse local-use NAT64 addresses, and IPv4-compatible ones in blocked_networks (closes #299) 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 --- TODO.md | 9 +++++++ internal/httpfetcher/httpfetcher.go | 38 +++++++++++++++++++---------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/TODO.md b/TODO.md index 5009531..58098f7 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/internal/httpfetcher/httpfetcher.go b/internal/httpfetcher/httpfetcher.go index 53d5b20..97db365 100644 --- a/internal/httpfetcher/httpfetcher.go +++ b/internal/httpfetcher/httpfetcher.go @@ -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. -- 2.54.0 From 4900837068acfc28b6bb9354a2fba190ff6192a5 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 16 Oct 2026 04:06:52 +0000 Subject: [PATCH 3/7] 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 --- internal/server/source_url_internal_test.go | 30 ++++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/internal/server/source_url_internal_test.go b/internal/server/source_url_internal_test.go index 1411602..4257a6f 100644 --- a/internal/server/source_url_internal_test.go +++ b/internal/server/source_url_internal_test.go @@ -592,14 +592,21 @@ func TestImageRoutesRefuseIPv6SourceHostHoldingInternalIPv4Address(t *testing.T) // 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 a public IPv4 address in the -// IPv4-compatible form (::/96). It answers 403 when blocked_networks lists -// 203.0.113.0/24. When blocked_networks is empty it 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() + const ( + ipv4CompatibleHost = "[::203.0.113.5]" + ipv4MappedHost = "[::ffff:203.0.113.5]" + ) + tests := []struct { name string sourceHost string @@ -608,10 +615,19 @@ func TestImageRoutesCheckIPv4AddressInIPv6SourceHost(t *testing.T) { }{ {"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", - "[::203.0.113.5]", + ipv4CompatibleHost, []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, true}, + {"IPv4-compatible form with ::/96 in blocked_networks", + ipv4CompatibleHost, + []netip.Prefix{netip.MustParsePrefix("::/96")}, true}, + {"IPv4-mapped form of an address in blocked_networks", + ipv4MappedHost, + []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, true}, + {"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", - "[::203.0.113.5]", nil, false}, + ipv4CompatibleHost, nil, false}, } for _, tt := range tests { -- 2.54.0 From 8fceeca202ab476320600886d8ec6da4869c91a0 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 16 Oct 2026 04:09:28 +0000 Subject: [PATCH 4/7] Match blocked_networks against both forms of an address holding IPv4 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 --- TODO.md | 18 ++++++++++------- internal/httpfetcher/httpfetcher.go | 31 +++++++++++++++++++++-------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/TODO.md b/TODO.md index 58098f7..5296f5e 100644 --- a/TODO.md +++ b/TODO.md @@ -32,13 +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 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. + 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`; with + `203.0.113.0/24` in `blocked_networks`, `[::ffff:203.0.113.5]` was refused but + `[::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 diff --git a/internal/httpfetcher/httpfetcher.go b/internal/httpfetcher/httpfetcher.go index 97db365..0ebb436 100644 --- a/internal/httpfetcher/httpfetcher.go +++ b/internal/httpfetcher/httpfetcher.go @@ -599,26 +599,41 @@ 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. +// 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 } - // An IPv4-compatible or IPv4-mapped IPv6 address is matched as the IPv4 - // address it holds - addr, ok := netip.AddrFromSlice(ipv4CompatibleToIPv4(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(addr) + 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{} + } + + asIPv4, _ := netip.AddrFromSlice(ipv4CompatibleToIPv4(ip).To4()) + + return asIPv6, asIPv4 +} + // 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. -- 2.54.0 From a5088a69a113d6709a16d412ab50dd99e35b4f90 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 16 Oct 2026 04:24:19 +0000 Subject: [PATCH 5/7] Test that an IPv6 entry in blocked_networks does not refuse a plain IPv4 source Both image routes: the plain IPv4 source host 203.0.113.5 must not be refused when blocked_networks lists ::/0. It fails on this branch: Go keeps a plain IPv4 address in the mapped form, and isBlockedIP matches that form as written. The case that expected [::ffff:203.0.113.5] refused with ::ffff:0:0/96 listed, added earlier on this branch, is dropped: the mapped form is matched only as the IPv4 address it holds. Model: opus-5-5 --- internal/server/source_url_internal_test.go | 24 +++++++++++---------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/internal/server/source_url_internal_test.go b/internal/server/source_url_internal_test.go index 4257a6f..27b963b 100644 --- a/internal/server/source_url_internal_test.go +++ b/internal/server/source_url_internal_test.go @@ -589,16 +589,18 @@ func TestImageRoutesRefuseIPv6SourceHostHoldingInternalIPv4Address(t *testing.T) } // 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. // [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 -// 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. +// 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. func TestImageRoutesCheckIPv4AddressInIPv6SourceHost(t *testing.T) { t.Parallel() @@ -623,9 +625,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}, - {"IPv4-mapped form with ::ffff:0:0/96 in blocked_networks", - ipv4MappedHost, - []netip.Prefix{netip.MustParsePrefix("::ffff:0:0/96")}, true}, + {"plain IPv4 address with ::/0 in blocked_networks", + "203.0.113.5", + []netip.Prefix{netip.MustParsePrefix("::/0")}, false}, {"IPv4-compatible form of an address with blocked_networks empty", ipv4CompatibleHost, nil, false}, } -- 2.54.0 From 19113ffa2fce85e4ef773edcf93d72f9363cdc7b Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 16 Oct 2026 04:25:45 +0000 Subject: [PATCH 6/7] Match the mapped form against blocked_networks only as IPv4 isBlockedIP again matches an IPv4-mapped address (::ffff:0:0/96) only as the IPv4 address it holds, as on next: Go keeps a plain IPv4 address in that form, so matching it as written let an IPv6 entry such as ::/0 refuse every IPv4 address. An IPv4-compatible address (::/96) is still matched both as written and as the IPv4 address it holds. ipv6AndIPv4Forms is gone. README.md says the mapped form is matched as IPv4 only. Model: opus-5-5 --- README.md | 5 +++- TODO.md | 17 +++++++------ internal/httpfetcher/httpfetcher.go | 37 ++++++++++++----------------- 3 files changed, 27 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 7a71a5d..6338c40 100644 --- a/README.md +++ b/README.md @@ -694,7 +694,10 @@ 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 + 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 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 diff --git a/TODO.md b/TODO.md index 5296f5e..596940e 100644 --- a/TODO.md +++ b/TODO.md @@ -32,17 +32,16 @@ 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 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 + 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 `[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`; with + 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; 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. + `[::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 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 diff --git a/internal/httpfetcher/httpfetcher.go b/internal/httpfetcher/httpfetcher.go index 0ebb436..f4c705e 100644 --- a/internal/httpfetcher/httpfetcher.go +++ b/internal/httpfetcher/httpfetcher.go @@ -599,39 +599,32 @@ 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 IPv6 -// address that holds an IPv4 address is refused when a prefix contains the -// address as written or the IPv4 address it holds. +// 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 { if isPrivateIP(ip) { return true } - asIPv6, asIPv4 := ipv6AndIPv4Forms(ip) - if !asIPv6.IsValid() { + addr, ok := netip.AddrFromSlice(ip) + if !ok { return true } - 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{} - } + addr = addr.Unmap() + // ip read as IPv4 where it is or holds an IPv4 address, otherwise the + // zero netip.Addr, which no prefix contains. It differs from addr only + // for an IPv4-compatible address. asIPv4, _ := netip.AddrFromSlice(ipv4CompatibleToIPv4(ip).To4()) - return asIPv6, asIPv4 + return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool { + return prefix.Contains(addr) || prefix.Contains(asIPv4) + }) } // ipv4CompatibleToIPv4 returns the IPv4 address that an IPv4-compatible IPv6 -- 2.54.0 From 07c4746c59553cdd4c7284aa108d7f0a02f0a44f Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 16 Oct 2026 04:38:58 +0000 Subject: [PATCH 7/7] Correct the comment on asIPv4 in isBlockedIP Its last sentence said asIPv4 differs from addr only for an IPv4-compatible address, which is untrue for any other IPv6 address, where asIPv4 is the zero netip.Addr. It now says that matching asIPv4 as well as addr refuses more only for an IPv4-compatible address. Model: opus-5-5 --- internal/httpfetcher/httpfetcher.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/httpfetcher/httpfetcher.go b/internal/httpfetcher/httpfetcher.go index f4c705e..29373f4 100644 --- a/internal/httpfetcher/httpfetcher.go +++ b/internal/httpfetcher/httpfetcher.go @@ -618,8 +618,8 @@ func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool { addr = addr.Unmap() // ip read as IPv4 where it is or holds an IPv4 address, otherwise the - // zero netip.Addr, which no prefix contains. It differs from addr only - // for an IPv4-compatible address. + // 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 { -- 2.54.0