feat: include quality and fit in the URL signature (closes #60) #116
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user