diff --git a/README.md b/README.md index 0356e75..b167214 100644 --- a/README.md +++ b/README.md @@ -79,14 +79,14 @@ hosts require an HMAC-SHA256 signature. Signatures use HMAC-SHA256 and include an expiration timestamp to prevent replay attacks. Signatures are **exact match only**: every -component (host, path, query, dimensions, format, expiration) must -match exactly what was signed. No suffix matching, wildcard matching, -or partial matching is supported. +component (host, path, query, dimensions, format, expiration, quality, +fit) must match exactly what was signed. No suffix matching, wildcard +matching, 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: @@ -98,18 +98,25 @@ 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` — the URL's `q` query parameter (1-100), or `85` when the URL + has no `q` +- `fit` — the URL's `fit` query parameter (cover, contain, fill, inside, + outside), or `cover` when the URL has no `fit` -**Example:** resize -`https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with -expiration 1704067200: +**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: `/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=&exp=1704067200` +For the same image at quality 40 with fit `contain`, the input ends in +`:40:contain` and the URL is +`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=&exp=1704067200&q=40&fit=contain`. + **Allowlist patterns:** - **Exact match**: `cdn.example.com` — matches only that host diff --git a/TODO.md b/TODO.md index 0c52f04..fb5bcd3 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,13 @@ exhaustion # Completed Steps +- 2026-09-28 quality and fit in the URL signature (closes #60): the signed + data is now `host:path:query:width:height:format:expiration:quality:fit`, + using `85` and `cover` when the URL has no `q` or `fit`, so one signed + URL can no longer be replayed across other quality and fit values to + create unauthorized cache entries and transcodes; the known-answer + vectors in `internal/signature/golden_test.go` and the README signature + specification describe the new format. - 2026-09-28 Docker image healthcheck (closes #111): a `HEALTHCHECK` in the runtime stage probing `/.well-known/healthcheck.json` with busybox `wget`; `script/docker-smoke` (`make docker-smoke`) builds the image, diff --git a/internal/imgcache/service.go b/internal/imgcache/service.go index ecb745d..d6e07df 100644 --- a/internal/imgcache/service.go +++ b/internal/imgcache/service.go @@ -205,12 +205,23 @@ func (s *Service) ValidateRequest(req *ImageRequest) error { return s.signer.Verify(signatureRequest(req)) } -// GenerateSignedURL generates a signed URL for the given request. +// GenerateSignedURL generates a signed URL for the given request. The URL +// carries q and fit next to sig and exp, so the image route verifies it for +// the quality and fit it was signed with. An unset quality or fit is first +// set to 85 or cover, the values the route uses when a URL has no q or fit. func (s *Service) GenerateSignedURL( baseURL string, req *ImageRequest, ttl time.Duration, ) (string, error) { + if req.Quality == 0 { + req.Quality = 85 + } + + if req.FitMode == "" { + req.FitMode = FitCover + } + sigReq := signatureRequest(req) path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl) @@ -218,7 +229,8 @@ func (s *Service) GenerateSignedURL( req.Expires = sigReq.Expires req.Signature = sigReq.Signature - return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil + return fmt.Sprintf("%s%s?sig=%s&exp=%d&q=%d&fit=%s", + baseURL, path, sig, exp, req.Quality, req.FitMode), nil } // loadCachedSource attempts to load source content from cache, returning nil @@ -452,6 +464,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_test.go b/internal/signature/golden_test.go index 26385e0..126b05c 100644 --- a/internal/signature/golden_test.go +++ b/internal/signature/golden_test.go @@ -41,9 +41,12 @@ func goldenVectors() []goldenVector { Width: 800, Height: 600, Format: testFormatWebP, + Quality: 85, + FitMode: testFitCover, }, - // Signed data: "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200" - wantSignature: "x5PfPp8QSDo0cJT96od-AEgrQyOVLfqifH5sst61_-w=", + // Signed data: + // "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover" + wantSignature: "kdqeGoW2SX7qnaYtoB970wEnLydn0UnIgQYQLfAnjXQ=", wantSignedPath: testSignedPath, }, { @@ -55,10 +58,12 @@ func goldenVectors() []goldenVector { Width: 800, Height: 600, Format: testFormatWebP, + Quality: 85, + FitMode: testFitCover, }, // Signed data: - // "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200" - wantSignature: "394_Vf9TdQFkpQ3XKFDQSyxgqKq8N7mApf2S4QaHqyo=", + // "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200:85:cover" + wantSignature: "pKgVBOTd_Q_EikI7MNQLC9Q8Hurdxzyv3EIYvVhqc2I=", wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg" + "%3Ftoken=abc&v=2/800x600.webp", }, @@ -71,11 +76,34 @@ func goldenVectors() []goldenVector { Width: 0, Height: 0, Format: testFormatPNG, + Quality: 85, + FitMode: testFitCover, }, - // Signed data: "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200" - wantSignature: "7Be7oteeQwvnSPU4bchyQ4ZGYGsAGBKpeEtuQ02ox60=", + // Signed data: + // "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200:85:cover" + wantSignature: "6_rZ0yyVbGZRs8kG7n7HLgLi5Jt8vjiWQljIEL1jbIs=", wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png", }, + { + name: "non-default quality and fit", + req: signature.Request{ + SourceHost: testHost, + SourcePath: testPath, + SourceQuery: "", + Width: 800, + Height: 600, + Format: testFormatWebP, + Quality: 40, + FitMode: testFitContain, + }, + // Signed data: + // "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:40:contain" + wantSignature: "pGaXpPUbI3A7nMx-4T9bfq9bYWBNL0kY4bxlcv3g1F8=", + // The path is the same as for the default quality and fit: + // q=40&fit=contain go in the query string next to sig and + // exp (imgcache.Service.GenerateSignedURL adds all four). + wantSignedPath: testSignedPath, + }, } } @@ -84,11 +112,9 @@ func goldenVectors() []goldenVector { // hardcoded signing key. // // If any of these assertions fail, the signed byte format -// ("host:path:query:width:height:format:expiration"), the base64url -// encoding, or the signed URL layout has changed. Such a change breaks -// every signature already issued to clients, so it must be made -// deliberately: update these constants only as part of an intentional, -// documented signature format migration. +// ("host:path:query:width:height:format:expiration:quality:fit"), the +// base64url encoding, or the signed URL layout has changed. Update these +// constants only when that change is intended. func TestSigner_GoldenVectors(t *testing.T) { t.Parallel() 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, ) }