From e8ffd4e47f37e36a96dbc56344acaac2642eae86 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 16 Oct 2026 03:10:51 +0000 Subject: [PATCH] Test that an IPv6 address holding an internal IPv4 address is refused The check that keeps pixa from fetching internal addresses reads an IPv6 address as IPv4 only in the mapped form (::ffff:a.b.c.d). These tests fail until it also judges the IPv4-compatible form (::/96), such as ::127.0.0.1 or ::10.0.0.1, by the IPv4 address it holds. The NAT64 form, 64:ff9b::7f00:1, is already refused and is tested alongside. Model: opus-5-5 --- .../httpfetcher/httpfetcher_internal_test.go | 38 ++++++++++++++++ internal/server/source_url_internal_test.go | 43 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/internal/httpfetcher/httpfetcher_internal_test.go b/internal/httpfetcher/httpfetcher_internal_test.go index a774614..c7a8970 100644 --- a/internal/httpfetcher/httpfetcher_internal_test.go +++ b/internal/httpfetcher/httpfetcher_internal_test.go @@ -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() diff --git a/internal/server/source_url_internal_test.go b/internal/server/source_url_internal_test.go index 0a5b4a1..172de90 100644 --- a/internal/server/source_url_internal_test.go +++ b/internal/server/source_url_internal_test.go @@ -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) + } +}