Pin the new signed format in the golden vectors (closes #60)
check / check (push) Successful in 3m6s
check / check (push) Successful in 3m6s
The three known-answer vectors in golden_test.go now sign the effective quality and fit (85 and cover when the URL has neither), matching the new signed data format. The extra non-default vector (quality 40, fit contain) moves in from golden_qualityfit_test.go, which is deleted, so there is one known-answer test file. The fit-mode fixtures join the shared test constants. README: the exact-match list names quality and fit, the field list says where each value comes from, and the worked example shows a non-default case. Model: opus-5-5
This commit is contained in:
@@ -79,9 +79,9 @@ 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):
|
||||
|
||||
@@ -98,14 +98,10 @@ 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
|
||||
|
||||
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.
|
||||
- `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, default quality and fit:
|
||||
@@ -117,6 +113,9 @@ WebP with expiration 1704067200, default quality and fit:
|
||||
4. URL:
|
||||
`/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 also carries `q=40&fit=contain`.
|
||||
|
||||
**Allowlist patterns:**
|
||||
|
||||
- **Exact match**: `cdn.example.com` — matches only that host
|
||||
|
||||
@@ -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-21 trusted-proxy client IP resolution (closes #94): a
|
||||
`trusted_proxies` config key taking a list of CIDRs, parsed by the same
|
||||
`net/netip` list parser as `blocked_networks` (an invalid entry aborts
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,31 @@ 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=",
|
||||
wantSignedPath: testSignedPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,11 +109,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()
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ func signedQualityFitRequest(signer *signature.Signer) *signature.Request {
|
||||
Height: 600,
|
||||
Format: testFormatWebP,
|
||||
Quality: 85,
|
||||
FitMode: "cover",
|
||||
FitMode: testFitCover,
|
||||
Expires: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
req.Signature = signer.Sign(req)
|
||||
@@ -48,7 +48,7 @@ func TestSigner_Verify_QualityAndFitAreSigned(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "replayed with different fit mode",
|
||||
tamper: func(r *signature.Request) { r.FitMode = "contain" },
|
||||
tamper: func(r *signature.Request) { r.FitMode = testFitContain },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ const (
|
||||
testPath = "/photos/cat.jpg"
|
||||
testFormatWebP = "webp"
|
||||
testFormatPNG = "png"
|
||||
testFitCover = "cover"
|
||||
testFitContain = "contain"
|
||||
testSignedPath = "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
|
||||
testSig = "abc123"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user