feat: include quality and fit in the URL signature (closes #60)
check / check (push) Failing after 1s

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 :<quality>:<fit> 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
This commit is contained in:
2026-09-21 07:33:55 +00:00
parent 5f3ff448ab
commit af47215237
4 changed files with 147 additions and 10 deletions
+11 -5
View File
@@ -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,
)
}