diff --git a/internal/server/image_proxy_internal_test.go b/internal/server/image_proxy_internal_test.go index 24b7961..ba56e4f 100644 --- a/internal/server/image_proxy_internal_test.go +++ b/internal/server/image_proxy_internal_test.go @@ -14,10 +14,12 @@ import ( "net" "net/http" "net/http/httptest" + "net/netip" "os" "path/filepath" "sync/atomic" "testing" + "time" "go.uber.org/fx" "go.uber.org/fx/fxtest" @@ -183,6 +185,57 @@ func startImageProxy( return s, db.DB(), stateDir } +// startImageProxyWithBlockedNetworks starts the components pixad's fx app +// builds, from a config with a fresh state directory and blockedNetworks as +// blocked_networks. Unlike startImageProxy it provides no upstream fetcher, so +// the handlers build the one pixad uses, which checks every address it +// connects to against the built-in blocked ranges and blocked_networks. It +// returns the server with its routes. +func startImageProxyWithBlockedNetworks( + t *testing.T, blockedNetworks []netip.Prefix, +) *Server { + t.Helper() + + stateDir := t.TempDir() + cfg := &config.Config{ + SigningKey: testSigningKey, + StateDir: stateDir, + DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"), + BlockedNetworks: blockedNetworks, + // A fetch that gets past the checks and finds nothing at the address + // fails within this time at the latest. + UpstreamFetchTimeout: 2 * time.Second, + // With no connection slots, the fetch would fail before connecting. + UpstreamConnections: config.DefaultUpstreamConnections, + DownstreamTimeout: config.DefaultDownstreamTimeout, + } + + var ( + h *handlers.Handlers + mw *middleware.Middleware + ) + + app := fxtest.New(t, + fx.Supply(cfg), + fx.Provide( + globals.New, + logger.New, + database.New, + healthcheck.New, + handlers.New, + middleware.New, + ), + fx.Populate(&h, &mw), + ) + app.RequireStart() + t.Cleanup(app.RequireStop) + + s := &Server{config: cfg, mw: mw, h: h} + s.SetupRoutes() + + return s +} + // getImage sends a GET for target to s and fails unless it answers 200. func getImage(t *testing.T, s *Server, target string) *httptest.ResponseRecorder { t.Helper() diff --git a/internal/server/source_url_internal_test.go b/internal/server/source_url_internal_test.go index 172de90..1411602 100644 --- a/internal/server/source_url_internal_test.go +++ b/internal/server/source_url_internal_test.go @@ -3,6 +3,7 @@ package server import ( "net/http" "net/http/httptest" + "net/netip" "net/url" "regexp" "slices" @@ -586,3 +587,61 @@ func TestImageRoutesRefuseIPv6SourceHostHoldingInternalIPv4Address(t *testing.T) t.Errorf("upstream received %q, want no request", got) } } + +// TestImageRoutesCheckIPv4AddressInIPv6SourceHost requests ipv6SourcePath on +// IPv6 source hosts that hold an IPv4 address, through a /v1/image/ URL with a +// signature and through an encrypted URL, with the fetcher pixad uses. +// [64:ff9b:1::a00:1], in the local-use NAT64 range (64:ff9b:1::/48, RFC 8215), +// answers 403. [::203.0.113.5] holds a public IPv4 address in the +// IPv4-compatible form (::/96). It answers 403 when blocked_networks lists +// 203.0.113.0/24. When blocked_networks is empty it is not refused: the +// fetcher tries to connect to it, finds nothing there, and the request fails +// with another status. +func TestImageRoutesCheckIPv4AddressInIPv6SourceHost(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sourceHost string + blockedNetworks []netip.Prefix + wantForbidden bool + }{ + {"local-use NAT64 form of 10.0.0.1", "[64:ff9b:1::a00:1]", nil, true}, + {"IPv4-compatible form of an address in blocked_networks", + "[::203.0.113.5]", + []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, true}, + {"IPv4-compatible form of an address with blocked_networks empty", + "[::203.0.113.5]", nil, false}, + } + + for _, tt := range tests { + imageURLs := map[string]string{ + "image URL with a signature": signedImageURL(t, tt.sourceHost), + "encrypted URL": encryptedImageURL(t, tt.sourceHost), + } + + for route, imageURL := range imageURLs { + t.Run(tt.name+" "+route, func(t *testing.T) { + t.Parallel() + + s := startImageProxyWithBlockedNetworks(t, tt.blockedNetworks) + + rec := httptest.NewRecorder() + s.ServeHTTP(rec, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, imageURL, nil)) + t.Logf("GET %s: %d", imageURL, rec.Code) + + forbidden := rec.Code == http.StatusForbidden + if tt.wantForbidden && !forbidden { + t.Errorf("status = %d, want %d; body %s", + rec.Code, http.StatusForbidden, rec.Body.String()) + } + + if !tt.wantForbidden && forbidden { + t.Errorf("status = %d, want any other status; body %s", + rec.Code, rec.Body.String()) + } + }) + } + } +}