From 1631f92e2f7faad0612b0f2008283eba39aea46e Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 16 Oct 2026 03:11:40 +0000 Subject: [PATCH] Refuse an IPv6 address that holds an internal IPv4 address (closes #297) isPrivateIP read an IPv6 address as IPv4 only in the mapped form (::ffff:a.b.c.d), so an IPv4-compatible address such as ::127.0.0.1 or ::10.0.0.1 was judged as a public IPv6 address and fetched. An address in ::/96 is now judged by the IPv4 address in its last four bytes. The NAT64 range 64:ff9b::/96 is already one of the built-in blocked ranges and is left as it is. Model: opus-5-5 --- TODO.md | 8 ++++++++ internal/httpfetcher/httpfetcher.go | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/TODO.md b/TODO.md index 9ae228e..5009531 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,14 @@ P2: security: per-origin rate limiting # Completed Steps +- 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 + (closes #297). It read only the mapped form (`::ffff:a.b.c.d`) as IPv4, so a + source host such as `[::127.0.0.1]` was checked as a public IPv6 address and + fetched, which on a network that routes it reaches the internal IPv4 host; it + is now refused with 403. The NAT64 form (`64:ff9b::/96`) was already refused + as a whole, as one of the built-in blocked ranges, and still is. - 2026-10-16 a source host that is an IPv6 address in brackets with no port, such as `[2001:db8::a]`, is fetched like one with a port, through `/v1/image/` and through an encrypted URL (closes #295). The fetcher's check of the source diff --git a/internal/httpfetcher/httpfetcher.go b/internal/httpfetcher/httpfetcher.go index 159ee68..53d5b20 100644 --- a/internal/httpfetcher/httpfetcher.go +++ b/internal/httpfetcher/httpfetcher.go @@ -543,6 +543,13 @@ 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:]) + } + // Check for loopback if ip.IsLoopback() { return true