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) + } +}