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