Author SHA1 Message Date
clawbot 04b5db6fbf next -> main (1.0.0 milestone) (#105)
check / check (push) Failing after 1s
Accumulating milestone branch. One squashed commit per closed issue; `next` is kept green and mergeable to `main` at any time without notice.

Landed so far:

- `chore: update golangci-lint to v2.12.2 with canonical config` (#54) — canonical v2-schema `.golangci.yml`, pins bumped in `Dockerfile` and `script/bootstrap`, tree at `0 issues.`. Three behaviour deltas are recorded in that PR's body: `Cache.StoreVariant` takes a context, `MetadataStorage.Store` no longer leaks temp files on failure, and the `signing_key` too-short error text gained a `value too short:` prefix.

Sequencing for the milestone is tracked in #103.

Reviewed-on: #105
Co-authored-by: clawbot <clawbot@noreply.example.org>
2026-09-21 09:31:54 +02:00
5 changed files with 10 additions and 224 deletions
+5 -12
View File
@@ -75,7 +75,7 @@ or partial matching is supported.
**Signed data format** (colon-separated):
```
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration:quality:fit")
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration")
```
Where:
@@ -87,20 +87,13 @@ 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.
**Example:** resize `https://cdn.example.com/photos/cat.jpg` to 800x600
WebP with expiration 1704067200, default quality and fit:
**Example:** resize
`https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
expiration 1704067200:
1. Build input:
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover`
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200`
2. Compute HMAC-SHA256 with your secret key
3. Base64URL-encode the result
4. URL:
-2
View File
@@ -452,8 +452,6 @@ 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,
}
@@ -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)
}
})
}
}
-68
View File
@@ -1,68 +0,0 @@
package signature_test
import (
"errors"
"testing"
"time"
"sneak.berlin/go/pixa/internal/signature"
)
// signedQualityFitRequest returns a request signed for quality 85 and fit
// mode "cover", the effective defaults the handler applies before
// verification.
func signedQualityFitRequest(signer *signature.Signer) *signature.Request {
req := &signature.Request{
SourceHost: testHost,
SourcePath: testPath,
Width: 800,
Height: 600,
Format: testFormatWebP,
Quality: 85,
FitMode: "cover",
Expires: time.Now().Add(1 * time.Hour),
}
req.Signature = signer.Sign(req)
return req
}
// TestSigner_Verify_QualityAndFitAreSigned proves that quality and fit are
// covered by the signature: a URL signed for one quality or fit mode must
// not verify when replayed with a different quality or fit mode. This is the
// amplification vector from the issue — one signed URL replayed across many
// quality and fit values yields many unauthorized cache entries and
// transcodes — so it must be rejected.
func TestSigner_Verify_QualityAndFitAreSigned(t *testing.T) {
t.Parallel()
signer := signature.New("test-secret-key")
cases := []struct {
name string
tamper func(r *signature.Request)
}{
{
name: "replayed with different quality",
tamper: func(r *signature.Request) { r.Quality = 40 },
},
{
name: "replayed with different fit mode",
tamper: func(r *signature.Request) { r.FitMode = "contain" },
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := signedQualityFitRequest(signer)
tt.tamper(req)
err := signer.Verify(req)
if !errors.Is(err, signature.ErrInvalid) {
t.Errorf("Verify() = %v, want %v", err, signature.ErrInvalid)
}
})
}
}
+5 -20
View File
@@ -37,15 +37,6 @@ type Request struct {
Height int
// Format is the requested output format (e.g. "webp").
Format string
// Quality is the requested output quality (1-100) for lossy formats.
// It is the effective value the request resolves to: callers pass the
// default quality when the request omits the parameter, so an omitted
// quality signs identically to that same value stated explicitly.
Quality int
// FitMode is how the image is fit into the requested dimensions
// (e.g. "cover"). Like Quality it is the effective value: callers pass
// the default fit mode when the request omits the parameter.
FitMode string
// Signature is the HMAC signature to verify.
Signature string
// Expires is the signature expiration timestamp.
@@ -65,8 +56,7 @@ 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 + quality + fit.
// The signature covers: host + path + query + width + height + format + expiration.
func (s *Signer) Sign(req *Request) string {
data := s.buildSignatureData(req)
mac := hmac.New(sha256.New, s.secretKey)
@@ -78,8 +68,7 @@ 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, quality, fit) must
// match exactly.
// (host, path, query, dimensions, format, expiration) 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.
@@ -153,13 +142,11 @@ func (s *Signer) GenerateSignedURL(
}
// buildSignatureData creates the string to be signed.
// Format: "host:path:query:width:height:format:expiration:quality:fit"
// Format: "host:path:query:width:height:format:expiration"
// All components are used verbatim (exact match). No normalization,
// 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.
// suffix matching, or wildcard expansion is performed.
func (s *Signer) buildSignatureData(req *Request) string {
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s",
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
req.SourceHost,
req.SourcePath,
req.SourceQuery,
@@ -167,8 +154,6 @@ func (s *Signer) buildSignatureData(req *Request) string {
req.Height,
req.Format,
req.Expires.Unix(),
req.Quality,
req.FitMode,
)
}