From 4634d2dc2fe2b737d86f0ecec75088d7d8ef0698 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 21:01:41 +0000 Subject: [PATCH] Test that a source query in an image URL reaches the signature check Round trips through a chi router for source queries holding /, ;, ,, =, &, ? and a percent-escape, both as GenerateSignedURL writes them and with characters other than ? and % percent-encoded, plus the README's example of a source URL with a query. These fail until the fix. Model: opus-5-5 --- .../handlers/image_signature_internal_test.go | 93 +++++++++++++++++++ internal/signature/signature_test.go | 37 ++++++++ 2 files changed, 130 insertions(+) diff --git a/internal/handlers/image_signature_internal_test.go b/internal/handlers/image_signature_internal_test.go index c057514..b74ed18 100644 --- a/internal/handlers/image_signature_internal_test.go +++ b/internal/handlers/image_signature_internal_test.go @@ -169,3 +169,96 @@ func TestHandleImage_InvalidExp_Returns400(t *testing.T) { }) } } + +// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built +// by the service's signed-URL generator for source URLs with a query to the +// route, through a chi router: chi routes on the path as the client wrote it +// whenever that is not Go's own encoding of it. A 502 means the signature +// passed, so the route read the source path and query that were signed. +func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + }{ + {"slash and colon", "url=https://origin.example.com/cat.jpg"}, + {"semicolon", "a=1;b=2"}, + {"comma", "crop=0,0,50,50"}, + {"equals and ampersand", "a=1&b=2"}, + {"percent-escape", "name=my%20cat"}, + {"question mark", "next=/a?b=1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fix := setupTestHandler(t) + + signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{ + SourceHost: signedHost, + SourcePath: "/images/photo.jpg", + SourceQuery: tt.query, + Size: imgcache.Size{Width: 50, Height: 50}, + Format: imgcache.FormatJPEG, + }, time.Hour) + if err != nil { + t.Fatalf("GenerateSignedURL() error = %v", err) + } + + status := getImage(t, fix, signedURL) + if status != http.StatusBadGateway { + t.Errorf("status = %d, want %d", status, http.StatusBadGateway) + } + }) + } +} + +// TestHandleImage_SourceQueryEncodedAnyWay signs one source URL with a query +// and writes it into the image URL in ways the README allows: the "?" before +// the query and each "%" in it percent-encoded, and every other character as +// itself or percent-encoded. A 502 means the signature passed. +func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) { + t.Parallel() + + expires := time.Now().Add(time.Hour) + signer := signature.New("test-signing-key-must-be-32-chars") + sig := signer.Sign(&signature.Request{ + SourceHost: signedHost, + SourcePath: "/images/photo.jpg", + SourceQuery: "url=https://origin.example.com/my%20cat.jpg", + Width: 50, + Height: 50, + Format: string(imgcache.FormatJPEG), + Quality: 85, + FitMode: string(imgcache.FitCover), + Expires: expires, + }) + + tests := []struct { + name string + writtenSource string + }{ + {"only ? and % encoded", + "/images/photo.jpg%3Furl=https://origin.example.com/my%2520cat.jpg"}, + {"every reserved character encoded", + "/images/photo.jpg%3Furl%3Dhttps%3A%2F%2Forigin.example.com%2Fmy%2520cat.jpg"}, + {"lower-case hex digits", + "/images/photo.jpg%3furl=https://origin.example.com/my%2520cat.jpg"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + target := fmt.Sprintf("/v1/image/%s%s/50x50.jpeg?sig=%s&exp=%d", + signedHost, tt.writtenSource, sig, expires.Unix()) + + status := getImage(t, setupTestHandler(t), target) + if status != http.StatusBadGateway { + t.Errorf("status = %d, want %d", status, http.StatusBadGateway) + } + }) + } +} diff --git a/internal/signature/signature_test.go b/internal/signature/signature_test.go index 1712fca..939fda0 100644 --- a/internal/signature/signature_test.go +++ b/internal/signature/signature_test.go @@ -475,6 +475,43 @@ func TestGenerateSignedURL_WithoutQueryString(t *testing.T) { } } +// TestGenerateSignedURL_ReadmeQueryExample checks the README's example of a +// source URL with a query, https://cdn.example.com/img?url=https://origin.example.com/my%20cat.jpg: +// its signature with the README's key, and the path GenerateSignedURL writes +// for it, with the "?" written as %3F and the "%" as %25. +func TestGenerateSignedURL_ReadmeQueryExample(t *testing.T) { + t.Parallel() + + signer := signature.New("example-signing-key-for-documentation") + + req := signature.Request{ + SourceHost: testHost, + SourcePath: "/img", + SourceQuery: "url=https://origin.example.com/my%20cat.jpg", + Width: 800, + Height: 600, + Format: testFormatWebP, + Quality: 85, + FitMode: testFitCover, + Expires: time.Unix(1704067200, 0), + } + + const wantSig = "37aFa9GSVCZM1kRY9SzMOk2fOg_M7Wt5gKNR4xM-FYA=" + + gotSig := signer.Sign(&req) + if gotSig != wantSig { + t.Errorf("Sign() = %q, want %q", gotSig, wantSig) + } + + const wantPath = "/v1/image/cdn.example.com" + + "/img%3Furl=https://origin.example.com/my%2520cat.jpg/800x600.webp" + + gotPath, _, _ := signer.GenerateSignedURL(&req, time.Hour) + if gotPath != wantPath { + t.Errorf("GenerateSignedURL() path = %q, want %q", gotPath, wantPath) + } +} + func TestParseParams(t *testing.T) { t.Parallel()