Refuse an IPv6 address that holds an internal IPv4 address (closes #297) #298
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -186,6 +186,44 @@ func TestIsPrivateIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsPrivateIPJudgesEmbeddedIPv4Address checks IPv6 addresses that hold an
|
||||
// IPv4 address in their last four bytes: the IPv4-compatible form (::/96) and
|
||||
// the NAT64 form (64:ff9b::/96). One holding a loopback, private or
|
||||
// link-local IPv4 address is refused as that address is, and one holding a
|
||||
// public IPv4 address in the IPv4-compatible form passes.
|
||||
func TestIsPrivateIPJudgesEmbeddedIPv4Address(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
ip string
|
||||
want bool
|
||||
}{
|
||||
{"::127.0.0.1", true}, // loopback
|
||||
{"::10.0.0.1", true}, // private
|
||||
{"::169.254.169.254", true}, // link-local
|
||||
{"64:ff9b::7f00:1", true}, // NAT64 form of 127.0.0.1
|
||||
{"::8.8.8.8", false}, // public
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.ip, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ip := net.ParseIP(tc.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||
}
|
||||
|
||||
got := isPrivateIP(ip)
|
||||
t.Logf("isPrivateIP(%s) = %v", ip, got)
|
||||
|
||||
if got != tc.want {
|
||||
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -543,3 +543,46 @@ func encryptedImageURL(t *testing.T, sourceHost string) string {
|
||||
|
||||
return "/v1/e/" + token + "/img.png"
|
||||
}
|
||||
|
||||
// TestImageRoutesRefuseIPv6SourceHostHoldingInternalIPv4Address requests
|
||||
// ipv6SourcePath on IPv6 source hosts that hold a loopback or private IPv4
|
||||
// address in their last four bytes, in the IPv4-compatible form (::/96) or the
|
||||
// NAT64 form (64:ff9b::/96), through a /v1/image/ URL with a signature and
|
||||
// through an encrypted URL. Each answers 403, and the upstream receives no
|
||||
// request: the test's fetcher connects every address to the test upstream, so
|
||||
// only the fetcher's check of the source URL can refuse it.
|
||||
func TestImageRoutesRefuseIPv6SourceHostHoldingInternalIPv4Address(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
upstream := startRecordingUpstream(t)
|
||||
s, _, _ := startImageProxy(t, upstream.Server)
|
||||
|
||||
sourceHosts := []string{
|
||||
"[::127.0.0.1]", // IPv4-compatible form of a loopback address
|
||||
"[::10.0.0.1]", // IPv4-compatible form of a private address
|
||||
"[64:ff9b::7f00:1]", // NAT64 form of 127.0.0.1
|
||||
}
|
||||
|
||||
for _, sourceHost := range sourceHosts {
|
||||
imageURLs := []string{
|
||||
signedImageURL(t, sourceHost),
|
||||
encryptedImageURL(t, sourceHost),
|
||||
}
|
||||
|
||||
for _, imageURL := range imageURLs {
|
||||
rec := httptest.NewRecorder()
|
||||
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, imageURL, nil))
|
||||
t.Logf("GET %s (source host %s): %d", imageURL, sourceHost, rec.Code)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("source host %s: status = %d, want %d; body %s",
|
||||
sourceHost, rec.Code, http.StatusForbidden, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if got := upstream.requestTargets(); len(got) != 0 {
|
||||
t.Errorf("upstream received %q, want no request", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user