Include quality and fit in the URL signature (closes #60)
check / check (push) Successful in 12s
check / check (push) Successful in 12s
The signed data is now host:path:query:width:height:format:expiration:quality:fit. The route turns a missing q into 85 and a missing fit into cover before checking the signature, so those are the values signed for a URL without them; imgcache fills both from the parsed request. imgcache.Service.GenerateSignedURL now writes q and fit into the URL next to sig and exp, first setting an unset quality or fit to 85 or cover, so a generated URL verifies for the values it signed. The known-answer vectors in golden_test.go, including one for quality 40 and fit contain, and the README signature section describe the new format. Model: opus-4-8 (implementation); opus-5-5 (rework)
This commit was merged in pull request #116.
This commit is contained in:
@@ -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,34 @@ 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=",
|
||||
// The path is the same as for the default quality and fit:
|
||||
// q=40&fit=contain go in the query string next to sig and
|
||||
// exp (imgcache.Service.GenerateSignedURL adds all four).
|
||||
wantSignedPath: testSignedPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,11 +112,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()
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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: testFitCover,
|
||||
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 = testFitContain },
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,15 @@ 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.
|
||||
@@ -56,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)
|
||||
@@ -68,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.
|
||||
@@ -142,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,
|
||||
@@ -154,6 +167,8 @@ func (s *Signer) buildSignatureData(req *Request) string {
|
||||
req.Height,
|
||||
req.Format,
|
||||
req.Expires.Unix(),
|
||||
req.Quality,
|
||||
req.FitMode,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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