From 7f6139e6d6fb91d60c5436d8558ebd3cc7eeccd0 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 21 Sep 2026 07:30:57 +0000 Subject: [PATCH 1/2] test: show quality and fit are not covered by the URL signature Add Quality and FitMode fields to signature.Request and a failing test that signs a request for quality 85 / fit cover and replays it with a different quality or fit mode. The replay currently verifies, proving the amplification vector: one signed URL authorizes any quality and fit, yielding unauthorized cache entries and transcodes. The fields are inert here; the next commit makes the signature cover them. model: claude-opus-4-8 --- internal/signature/quality_fit_test.go | 68 ++++++++++++++++++++++++++ internal/signature/signature.go | 9 ++++ 2 files changed, 77 insertions(+) create mode 100644 internal/signature/quality_fit_test.go diff --git a/internal/signature/quality_fit_test.go b/internal/signature/quality_fit_test.go new file mode 100644 index 0000000..0c82c5a --- /dev/null +++ b/internal/signature/quality_fit_test.go @@ -0,0 +1,68 @@ +package signature_test + +import ( + "errors" + "testing" + "time" + + "sneak.berlin/go/pixa/internal/signature" +) + +// signedQualityFitRequest returns a request signed for quality 85 and fit +// mode "cover", the effective defaults the handler applies before +// verification. +func signedQualityFitRequest(signer *signature.Signer) *signature.Request { + req := &signature.Request{ + SourceHost: testHost, + SourcePath: testPath, + Width: 800, + Height: 600, + Format: testFormatWebP, + Quality: 85, + FitMode: "cover", + Expires: time.Now().Add(1 * time.Hour), + } + req.Signature = signer.Sign(req) + + return req +} + +// TestSigner_Verify_QualityAndFitAreSigned proves that quality and fit are +// covered by the signature: a URL signed for one quality or fit mode must +// not verify when replayed with a different quality or fit mode. This is the +// amplification vector from the issue — one signed URL replayed across many +// quality and fit values yields many unauthorized cache entries and +// transcodes — so it must be rejected. +func TestSigner_Verify_QualityAndFitAreSigned(t *testing.T) { + t.Parallel() + + signer := signature.New("test-secret-key") + + cases := []struct { + name string + tamper func(r *signature.Request) + }{ + { + name: "replayed with different quality", + tamper: func(r *signature.Request) { r.Quality = 40 }, + }, + { + name: "replayed with different fit mode", + tamper: func(r *signature.Request) { r.FitMode = "contain" }, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := signedQualityFitRequest(signer) + tt.tamper(req) + + err := signer.Verify(req) + if !errors.Is(err, signature.ErrInvalid) { + t.Errorf("Verify() = %v, want %v", err, signature.ErrInvalid) + } + }) + } +} diff --git a/internal/signature/signature.go b/internal/signature/signature.go index 1a5b00e..f044ffa 100644 --- a/internal/signature/signature.go +++ b/internal/signature/signature.go @@ -37,6 +37,15 @@ type Request struct { Height int // Format is the requested output format (e.g. "webp"). Format string + // Quality is the requested output quality (1-100) for lossy formats. + // It is the effective value the request resolves to: callers pass the + // default quality when the request omits the parameter, so an omitted + // quality signs identically to that same value stated explicitly. + Quality int + // FitMode is how the image is fit into the requested dimensions + // (e.g. "cover"). Like Quality it is the effective value: callers pass + // the default fit mode when the request omits the parameter. + FitMode string // Signature is the HMAC signature to verify. Signature string // Expires is the signature expiration timestamp. -- 2.54.0 From 460c11a7bf219edd98aaa6dd101605ce08938206 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 21 Sep 2026 07:33:55 +0000 Subject: [PATCH 2/2] feat: include quality and fit in the URL signature (closes #60) Quality (q) and fit were read after signature validation and folded into the variant cache key, so one signed URL could be replayed across 100 quality values and 5 fit modes, yielding up to 500 unauthorized cache entries and libvips transcodes. The signed data now appends the effective quality and fit: host:path:query:width:height:format:expiration:quality:fit The handler already defaults an omitted q to 85 and fit to cover before verification, so those effective values are what gets signed; a URL signed for one quality or fit no longer verifies when replayed with another. This is a breaking change to the URL signing scheme: external signers must append :: to the signed string. New known-answer vectors are added in golden_qualityfit_test.go; the README signature specification is updated. The pre-existing golden_test.go pins the old signed bytes and can no longer stay green; it is left unedited per instruction pending an owner decision. model: claude-opus-4-8 --- README.md | 17 ++- internal/imgcache/service.go | 2 + internal/signature/golden_qualityfit_test.go | 122 +++++++++++++++++++ internal/signature/signature.go | 16 ++- 4 files changed, 147 insertions(+), 10 deletions(-) create mode 100644 internal/signature/golden_qualityfit_test.go diff --git a/README.md b/README.md index 8e36dc0..43d50b5 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ or partial matching is supported. **Signed data format** (colon-separated): ``` -HMAC-SHA256(secret, "host:path:query:width:height:format:expiration") +HMAC-SHA256(secret, "host:path:query:width:height:format:expiration:quality:fit") ``` Where: @@ -87,13 +87,20 @@ Where: - `height` — requested height in pixels, `0` for original - `format` — output format (jpeg, png, webp, avif, gif, orig) - `expiration` — Unix timestamp when signature expires +- `quality` — output quality 1-100; sign `85` (the default) when the URL + omits the `q` parameter +- `fit` — fit mode (cover, contain, fill, inside, outside); sign `cover` + (the default) when the URL omits the `fit` parameter -**Example:** resize -`https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with -expiration 1704067200: +The `q` and `fit` query parameters are covered by the signature. A URL +signed for one quality or fit value will not verify when replayed with a +different value; the effective (post-default) value is what is signed. + +**Example:** resize `https://cdn.example.com/photos/cat.jpg` to 800x600 +WebP with expiration 1704067200, default quality and fit: 1. Build input: - `cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200` + `cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover` 2. Compute HMAC-SHA256 with your secret key 3. Base64URL-encode the result 4. URL: diff --git a/internal/imgcache/service.go b/internal/imgcache/service.go index ecb745d..35d1fab 100644 --- a/internal/imgcache/service.go +++ b/internal/imgcache/service.go @@ -452,6 +452,8 @@ func signatureRequest(req *ImageRequest) *signature.Request { Width: req.Size.Width, Height: req.Size.Height, Format: string(req.Format), + Quality: req.Quality, + FitMode: string(req.FitMode), Signature: req.Signature, Expires: req.Expires, } diff --git a/internal/signature/golden_qualityfit_test.go b/internal/signature/golden_qualityfit_test.go new file mode 100644 index 0000000..144f06e --- /dev/null +++ b/internal/signature/golden_qualityfit_test.go @@ -0,0 +1,122 @@ +package signature_test + +import ( + "testing" + "time" + + "sneak.berlin/go/pixa/internal/signature" +) + +// Fixed inputs for the quality/fit golden vectors. They are independent of +// the constants in golden_test.go so this file pins the current signed +// format on its own. +const ( + qfSigningKey = "golden-test-key" + qfExpiresUnix int64 = 1704067200 // 2024-01-01T00:00:00Z + qfFitCover = "cover" + qfFitContain = "contain" +) + +type qualityFitGoldenVector struct { + name string + req signature.Request + // wantSignature is the exact base64url (RFC 4648 URL-safe, padded) + // HMAC-SHA256 signature for the request with Expires set to + // qfExpiresUnix, under the signed format + // "host:path:query:width:height:format:expiration:quality:fit". + wantSignature string +} + +// qualityFitGoldenVectors returns the known-answer vectors that pin quality +// and fit as signed components. The three default-value vectors use the +// effective quality (85) and fit ("cover") the handler applies when a URL +// omits q and fit, so they are the signatures real signed URLs must carry. +func qualityFitGoldenVectors() []qualityFitGoldenVector { + return []qualityFitGoldenVector{ + { + name: "resized, default quality and fit", + req: signature.Request{ + SourceHost: testHost, + SourcePath: testPath, + Width: 800, + Height: 600, + Format: testFormatWebP, + Quality: 85, + FitMode: qfFitCover, + }, + // "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover" + wantSignature: "kdqeGoW2SX7qnaYtoB970wEnLydn0UnIgQYQLfAnjXQ=", + }, + { + name: "resized with query, default quality and fit", + req: signature.Request{ + SourceHost: testHost, + SourcePath: testPath, + SourceQuery: "token=abc&v=2", + Width: 800, + Height: 600, + Format: testFormatWebP, + Quality: 85, + FitMode: qfFitCover, + }, + // "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200:85:cover" + wantSignature: "pKgVBOTd_Q_EikI7MNQLC9Q8Hurdxzyv3EIYvVhqc2I=", + }, + { + name: "original size, default quality and fit", + req: signature.Request{ + SourceHost: testHost, + SourcePath: testPath, + Width: 0, + Height: 0, + Format: testFormatPNG, + Quality: 85, + FitMode: qfFitCover, + }, + // "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200:85:cover" + wantSignature: "6_rZ0yyVbGZRs8kG7n7HLgLi5Jt8vjiWQljIEL1jbIs=", + }, + { + name: "non-default quality and fit", + req: signature.Request{ + SourceHost: testHost, + SourcePath: testPath, + Width: 800, + Height: 600, + Format: testFormatWebP, + Quality: 40, + FitMode: qfFitContain, + }, + // "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:40:contain" + wantSignature: "pGaXpPUbI3A7nMx-4T9bfq9bYWBNL0kY4bxlcv3g1F8=", + }, + } +} + +// TestSigner_GoldenVectors_QualityFit pins the exact HMAC-SHA256 signature +// output for requests that carry quality and fit as signed components. If +// these assertions fail, the signed byte format +// ("host:path:query:width:height:format:expiration:quality:fit") or the +// base64url encoding has changed, breaking every signature already issued. +// Update these constants only as part of a deliberate, documented signature +// format migration. +func TestSigner_GoldenVectors_QualityFit(t *testing.T) { + t.Parallel() + + signer := signature.New(qfSigningKey) + + for _, tt := range qualityFitGoldenVectors() { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + signReq := tt.req + signReq.Expires = time.Unix(qfExpiresUnix, 0) + + gotSignature := signer.Sign(&signReq) + if gotSignature != tt.wantSignature { + t.Errorf("Sign() = %q, want %q (signed byte format changed?)", + gotSignature, tt.wantSignature) + } + }) + } +} diff --git a/internal/signature/signature.go b/internal/signature/signature.go index f044ffa..0b43032 100644 --- a/internal/signature/signature.go +++ b/internal/signature/signature.go @@ -65,7 +65,8 @@ func New(secretKey string) *Signer { } // Sign generates an HMAC-SHA256 signature for the given request. -// The signature covers: host + path + query + width + height + format + expiration. +// The signature covers: host + path + query + width + height + format + +// expiration + quality + fit. func (s *Signer) Sign(req *Request) string { data := s.buildSignatureData(req) mac := hmac.New(sha256.New, s.secretKey) @@ -77,7 +78,8 @@ func (s *Signer) Sign(req *Request) string { // Verify checks if the signature on the request is valid and not expired. // Signatures are exact-match only: every component of the signed data -// (host, path, query, dimensions, format, expiration) must match exactly. +// (host, path, query, dimensions, format, expiration, quality, fit) must +// match exactly. // No suffix matching, wildcard matching, or partial matching is supported. // A signature for "cdn.example.com" will NOT verify for "example.com" or // "other.cdn.example.com", and vice versa. @@ -151,11 +153,13 @@ func (s *Signer) GenerateSignedURL( } // buildSignatureData creates the string to be signed. -// Format: "host:path:query:width:height:format:expiration" +// Format: "host:path:query:width:height:format:expiration:quality:fit" // All components are used verbatim (exact match). No normalization, -// suffix matching, or wildcard expansion is performed. +// suffix matching, or wildcard expansion is performed. Quality and fit +// are the effective transform values, so replaying a signed URL with a +// different quality or fit mode fails verification. func (s *Signer) buildSignatureData(req *Request) string { - return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d", + return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s", req.SourceHost, req.SourcePath, req.SourceQuery, @@ -163,6 +167,8 @@ func (s *Signer) buildSignatureData(req *Request) string { req.Height, req.Format, req.Expires.Unix(), + req.Quality, + req.FitMode, ) } -- 2.54.0