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/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..0b43032 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. @@ -56,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) @@ -68,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. @@ -142,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, @@ -154,6 +167,8 @@ func (s *Signer) buildSignatureData(req *Request) string { req.Height, req.Format, req.Expires.Unix(), + req.Quality, + req.FitMode, ) }