feat: include quality and fit in the URL signature (closes #60)
check / check (push) Failing after 1s
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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user