Include quality and fit in the URL signature (closes #60)
check / check (push) Successful in 3m23s
check / check (push) Successful in 3m23s
The signed data is now host:path:query:width:height:format:expiration:quality:fit. The route turns a missing q into 85 and a missing fit into cover before checking the signature, so those are the values signed for a URL without them; imgcache fills both from the parsed request. imgcache.Service.GenerateSignedURL now writes q and fit into the URL next to sig and exp, first setting an unset quality or fit to 85 or cover, so a generated URL verifies for the values it signed. The known-answer vectors in golden_test.go, including one for quality 40 and fit contain, and the README signature section describe the new format. Model: opus-4-8 (implementation); opus-5-5 (rework)
This commit is contained in:
@@ -79,14 +79,14 @@ hosts require an HMAC-SHA256 signature.
|
|||||||
|
|
||||||
Signatures use HMAC-SHA256 and include an expiration timestamp to
|
Signatures use HMAC-SHA256 and include an expiration timestamp to
|
||||||
prevent replay attacks. Signatures are **exact match only**: every
|
prevent replay attacks. Signatures are **exact match only**: every
|
||||||
component (host, path, query, dimensions, format, expiration) must
|
component (host, path, query, dimensions, format, expiration, quality,
|
||||||
match exactly what was signed. No suffix matching, wildcard matching,
|
fit) must match exactly what was signed. No suffix matching, wildcard
|
||||||
or partial matching is supported.
|
matching, or partial matching is supported.
|
||||||
|
|
||||||
**Signed data format** (colon-separated):
|
**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:
|
Where:
|
||||||
@@ -98,18 +98,25 @@ Where:
|
|||||||
- `height` — requested height in pixels, `0` for original
|
- `height` — requested height in pixels, `0` for original
|
||||||
- `format` — output format (jpeg, png, webp, avif, gif, orig)
|
- `format` — output format (jpeg, png, webp, avif, gif, orig)
|
||||||
- `expiration` — Unix timestamp when signature expires
|
- `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
|
**Example:** resize `https://cdn.example.com/photos/cat.jpg` to 800x600
|
||||||
`https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
|
WebP with expiration 1704067200, default quality and fit:
|
||||||
expiration 1704067200:
|
|
||||||
|
|
||||||
1. Build input:
|
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
|
2. Compute HMAC-SHA256 with your secret key
|
||||||
3. Base64URL-encode the result
|
3. Base64URL-encode the result
|
||||||
4. URL:
|
4. URL:
|
||||||
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
|
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&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=<base64url>&exp=1704067200&q=40&fit=contain`.
|
||||||
|
|
||||||
**Allowlist patterns:**
|
**Allowlist patterns:**
|
||||||
|
|
||||||
- **Exact match**: `cdn.example.com` — matches only that host
|
- **Exact match**: `cdn.example.com` — matches only that host
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ exhaustion
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 2026-09-28 Docker image healthcheck (closes #111): a `HEALTHCHECK` in
|
||||||
the runtime stage probing `/.well-known/healthcheck.json` with busybox
|
the runtime stage probing `/.well-known/healthcheck.json` with busybox
|
||||||
`wget`; `script/docker-smoke` (`make docker-smoke`) builds the image,
|
`wget`; `script/docker-smoke` (`make docker-smoke`) builds the image,
|
||||||
|
|||||||
@@ -205,12 +205,23 @@ func (s *Service) ValidateRequest(req *ImageRequest) error {
|
|||||||
return s.signer.Verify(signatureRequest(req))
|
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(
|
func (s *Service) GenerateSignedURL(
|
||||||
baseURL string,
|
baseURL string,
|
||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
ttl time.Duration,
|
ttl time.Duration,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
|
if req.Quality == 0 {
|
||||||
|
req.Quality = 85
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.FitMode == "" {
|
||||||
|
req.FitMode = FitCover
|
||||||
|
}
|
||||||
|
|
||||||
sigReq := signatureRequest(req)
|
sigReq := signatureRequest(req)
|
||||||
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl)
|
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl)
|
||||||
|
|
||||||
@@ -218,7 +229,8 @@ func (s *Service) GenerateSignedURL(
|
|||||||
req.Expires = sigReq.Expires
|
req.Expires = sigReq.Expires
|
||||||
req.Signature = sigReq.Signature
|
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
|
// loadCachedSource attempts to load source content from cache, returning nil
|
||||||
@@ -452,6 +464,8 @@ func signatureRequest(req *ImageRequest) *signature.Request {
|
|||||||
Width: req.Size.Width,
|
Width: req.Size.Width,
|
||||||
Height: req.Size.Height,
|
Height: req.Size.Height,
|
||||||
Format: string(req.Format),
|
Format: string(req.Format),
|
||||||
|
Quality: req.Quality,
|
||||||
|
FitMode: string(req.FitMode),
|
||||||
Signature: req.Signature,
|
Signature: req.Signature,
|
||||||
Expires: req.Expires,
|
Expires: req.Expires,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,12 @@ func goldenVectors() []goldenVector {
|
|||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Format: testFormatWebP,
|
Format: testFormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: testFitCover,
|
||||||
},
|
},
|
||||||
// Signed data: "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200"
|
// Signed data:
|
||||||
wantSignature: "x5PfPp8QSDo0cJT96od-AEgrQyOVLfqifH5sst61_-w=",
|
// "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover"
|
||||||
|
wantSignature: "kdqeGoW2SX7qnaYtoB970wEnLydn0UnIgQYQLfAnjXQ=",
|
||||||
wantSignedPath: testSignedPath,
|
wantSignedPath: testSignedPath,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -55,10 +58,12 @@ func goldenVectors() []goldenVector {
|
|||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Format: testFormatWebP,
|
Format: testFormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: testFitCover,
|
||||||
},
|
},
|
||||||
// Signed data:
|
// Signed data:
|
||||||
// "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200"
|
// "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200:85:cover"
|
||||||
wantSignature: "394_Vf9TdQFkpQ3XKFDQSyxgqKq8N7mApf2S4QaHqyo=",
|
wantSignature: "pKgVBOTd_Q_EikI7MNQLC9Q8Hurdxzyv3EIYvVhqc2I=",
|
||||||
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg" +
|
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg" +
|
||||||
"%3Ftoken=abc&v=2/800x600.webp",
|
"%3Ftoken=abc&v=2/800x600.webp",
|
||||||
},
|
},
|
||||||
@@ -71,11 +76,34 @@ func goldenVectors() []goldenVector {
|
|||||||
Width: 0,
|
Width: 0,
|
||||||
Height: 0,
|
Height: 0,
|
||||||
Format: testFormatPNG,
|
Format: testFormatPNG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: testFitCover,
|
||||||
},
|
},
|
||||||
// Signed data: "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200"
|
// Signed data:
|
||||||
wantSignature: "7Be7oteeQwvnSPU4bchyQ4ZGYGsAGBKpeEtuQ02ox60=",
|
// "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",
|
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.
|
// hardcoded signing key.
|
||||||
//
|
//
|
||||||
// If any of these assertions fail, the signed byte format
|
// If any of these assertions fail, the signed byte format
|
||||||
// ("host:path:query:width:height:format:expiration"), the base64url
|
// ("host:path:query:width:height:format:expiration:quality:fit"), the
|
||||||
// encoding, or the signed URL layout has changed. Such a change breaks
|
// base64url encoding, or the signed URL layout has changed. Update these
|
||||||
// every signature already issued to clients, so it must be made
|
// constants only when that change is intended.
|
||||||
// deliberately: update these constants only as part of an intentional,
|
|
||||||
// documented signature format migration.
|
|
||||||
func TestSigner_GoldenVectors(t *testing.T) {
|
func TestSigner_GoldenVectors(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ func New(secretKey string) *Signer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sign generates an HMAC-SHA256 signature for the given request.
|
// 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 {
|
func (s *Signer) Sign(req *Request) string {
|
||||||
data := s.buildSignatureData(req)
|
data := s.buildSignatureData(req)
|
||||||
mac := hmac.New(sha256.New, s.secretKey)
|
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.
|
// Verify checks if the signature on the request is valid and not expired.
|
||||||
// Signatures are exact-match only: every component of the signed data
|
// 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.
|
// No suffix matching, wildcard matching, or partial matching is supported.
|
||||||
// A signature for "cdn.example.com" will NOT verify for "example.com" or
|
// A signature for "cdn.example.com" will NOT verify for "example.com" or
|
||||||
// "other.cdn.example.com", and vice versa.
|
// "other.cdn.example.com", and vice versa.
|
||||||
@@ -151,11 +153,13 @@ func (s *Signer) GenerateSignedURL(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildSignatureData creates the string to be signed.
|
// 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,
|
// 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 {
|
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.SourceHost,
|
||||||
req.SourcePath,
|
req.SourcePath,
|
||||||
req.SourceQuery,
|
req.SourceQuery,
|
||||||
@@ -163,6 +167,8 @@ func (s *Signer) buildSignatureData(req *Request) string {
|
|||||||
req.Height,
|
req.Height,
|
||||||
req.Format,
|
req.Format,
|
||||||
req.Expires.Unix(),
|
req.Expires.Unix(),
|
||||||
|
req.Quality,
|
||||||
|
req.FitMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user