Take the signature, the expiry and the URL scheme off the image request (closes #314)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The image request now holds only its source and its transform, exactly what the variant key covers. The /v1/image/ handler checks the allowlist and verifies the signature itself with Signer.Verify(req, sig, expires). Both image handlers keep the URL's expiry as a local value for Cache-Control. The image service holds no signer, allowlist or AllowHTTP, and ValidateRequest is gone. The fetcher takes the source and builds its URL with Source.URL from its own AllowHTTP, so no URL string is built and parsed again. chooseAutoFormat returns the format it chooses instead of writing into the request. The signed URL format and every route's answers are unchanged. Model: opus-5-5
This commit is contained in:
@@ -28,20 +28,20 @@ func TestSigner_Verify_ColonCannotMovePathQueryBoundary(t *testing.T) {
|
||||
Quality: 85,
|
||||
FitMode: testFitCover,
|
||||
},
|
||||
Expires: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
signed.Signature = signer.Sign(signed)
|
||||
expires := time.Now().Add(1 * time.Hour)
|
||||
sig := signer.Sign(signed, expires)
|
||||
|
||||
replayed := *signed
|
||||
replayed.Source.Path = "/a"
|
||||
replayed.Source.Query = "b:"
|
||||
|
||||
t.Logf("signature for path %q query %q: %s",
|
||||
signed.Source.Path, signed.Source.Query, signed.Signature)
|
||||
signed.Source.Path, signed.Source.Query, sig)
|
||||
t.Logf("signature for path %q query %q: %s",
|
||||
replayed.Source.Path, replayed.Source.Query, signer.Sign(&replayed))
|
||||
replayed.Source.Path, replayed.Source.Query, signer.Sign(&replayed, expires))
|
||||
|
||||
err := signer.Verify(&replayed)
|
||||
err := signer.Verify(&replayed, sig, expires)
|
||||
if !errors.Is(err, signature.ErrInvalid) {
|
||||
t.Errorf("Verify() of path %q query %q with the signature for "+
|
||||
"path %q = %v, want %v",
|
||||
@@ -106,10 +106,9 @@ func TestSigner_EscapedGoldenVectors(t *testing.T) {
|
||||
Quality: 85,
|
||||
FitMode: testFitCover,
|
||||
},
|
||||
Expires: time.Unix(goldenExpiresUnix, 0),
|
||||
}
|
||||
|
||||
gotSignature := signer.Sign(req)
|
||||
gotSignature := signer.Sign(req, time.Unix(goldenExpiresUnix, 0))
|
||||
t.Logf("Sign() of path %q query %q = %s", tt.path, tt.query, gotSignature)
|
||||
|
||||
if gotSignature != tt.wantSignature {
|
||||
|
||||
@@ -19,8 +19,8 @@ type goldenVector struct {
|
||||
name string
|
||||
req imagerequest.Request
|
||||
// wantSignature is the exact base64url (RFC 4648 URL-safe,
|
||||
// padded) HMAC-SHA256 signature for the request with Expires
|
||||
// set to goldenExpiresUnix.
|
||||
// padded) HMAC-SHA256 signature for the request expiring at
|
||||
// goldenExpiresUnix.
|
||||
wantSignature string
|
||||
}
|
||||
|
||||
@@ -124,9 +124,8 @@ func TestSigner_GoldenVectors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
signReq := tt.req
|
||||
signReq.Expires = time.Unix(goldenExpiresUnix, 0)
|
||||
|
||||
gotSignature := signer.Sign(&signReq)
|
||||
gotSignature := signer.Sign(&signReq, time.Unix(goldenExpiresUnix, 0))
|
||||
if gotSignature != tt.wantSignature {
|
||||
t.Errorf("Sign() = %q, want %q (signed byte format changed?)",
|
||||
gotSignature, tt.wantSignature)
|
||||
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
"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) *imagerequest.Request {
|
||||
// signedQualityFitRequest returns a request, its signature for quality 85 and
|
||||
// fit mode "cover", the effective defaults the handler applies before
|
||||
// verification, and the expiry it was signed with.
|
||||
func signedQualityFitRequest(
|
||||
signer *signature.Signer,
|
||||
) (*imagerequest.Request, string, time.Time) {
|
||||
req := &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: testHost,
|
||||
@@ -24,11 +26,10 @@ func signedQualityFitRequest(signer *signature.Signer) *imagerequest.Request {
|
||||
Quality: 85,
|
||||
FitMode: testFitCover,
|
||||
},
|
||||
Expires: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
req.Signature = signer.Sign(req)
|
||||
expires := time.Now().Add(1 * time.Hour)
|
||||
|
||||
return req
|
||||
return req, signer.Sign(req, expires), expires
|
||||
}
|
||||
|
||||
// TestSigner_Verify_QualityAndFitAreSigned proves that quality and fit are
|
||||
@@ -60,10 +61,10 @@ func TestSigner_Verify_QualityAndFitAreSigned(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := signedQualityFitRequest(signer)
|
||||
req, sig, expires := signedQualityFitRequest(signer)
|
||||
tt.tamper(req)
|
||||
|
||||
err := signer.Verify(req)
|
||||
err := signer.Verify(req, sig, expires)
|
||||
if !errors.Is(err, signature.ErrInvalid) {
|
||||
t.Errorf("Verify() = %v, want %v", err, signature.ErrInvalid)
|
||||
}
|
||||
|
||||
@@ -34,14 +34,14 @@ 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 quality and fit mode are the effective
|
||||
// values the request resolves to: callers pass the defaults when the request
|
||||
// omits those parameters, so an omitted quality or fit mode signs
|
||||
// Sign generates an HMAC-SHA256 signature for the given request, expiring at
|
||||
// expires. The signature covers: host + path + query + width + height +
|
||||
// format + expiration + quality + fit. The quality and fit mode are the
|
||||
// effective values the request resolves to: callers pass the defaults when
|
||||
// the request omits those parameters, so an omitted quality or fit mode signs
|
||||
// identically to that same value stated explicitly.
|
||||
func (s *Signer) Sign(req *imagerequest.Request) string {
|
||||
data := s.buildSignatureData(req)
|
||||
func (s *Signer) Sign(req *imagerequest.Request, expires time.Time) string {
|
||||
data := s.buildSignatureData(req, expires)
|
||||
mac := hmac.New(sha256.New, s.secretKey)
|
||||
mac.Write([]byte(data))
|
||||
sig := mac.Sum(nil)
|
||||
@@ -49,28 +49,31 @@ func (s *Signer) Sign(req *imagerequest.Request) string {
|
||||
return base64.URLEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// Verify checks if the signature on the request is valid and not expired.
|
||||
// Verify checks that sig is the signature of the request expiring at expires,
|
||||
// and that expires has not passed.
|
||||
// Signatures are exact-match only: every component of the signed data
|
||||
// (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.
|
||||
func (s *Signer) Verify(req *imagerequest.Request) error {
|
||||
func (s *Signer) Verify(
|
||||
req *imagerequest.Request, sig string, expires time.Time,
|
||||
) error {
|
||||
// Check expiration first
|
||||
if req.Expires.IsZero() {
|
||||
if expires.IsZero() {
|
||||
return ErrMissingExpiration
|
||||
}
|
||||
|
||||
if time.Now().After(req.Expires) {
|
||||
if time.Now().After(expires) {
|
||||
return ErrExpired
|
||||
}
|
||||
|
||||
// Compute expected signature
|
||||
expected := s.Sign(req)
|
||||
expected := s.Sign(req, expires)
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if !hmac.Equal([]byte(req.Signature), []byte(expected)) {
|
||||
if !hmac.Equal([]byte(sig), []byte(expected)) {
|
||||
return ErrInvalid
|
||||
}
|
||||
|
||||
@@ -85,7 +88,9 @@ func (s *Signer) Verify(req *imagerequest.Request) error {
|
||||
// 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 *imagerequest.Request) string {
|
||||
func (s *Signer) buildSignatureData(
|
||||
req *imagerequest.Request, expires time.Time,
|
||||
) string {
|
||||
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s",
|
||||
req.Source.Host,
|
||||
escapePercentAndColon(req.Source.Path),
|
||||
@@ -93,7 +98,7 @@ func (s *Signer) buildSignatureData(req *imagerequest.Request) string {
|
||||
req.Transform.Size.Width,
|
||||
req.Transform.Size.Height,
|
||||
req.Transform.Format,
|
||||
req.Expires.Unix(),
|
||||
expires.Unix(),
|
||||
req.Transform.Quality,
|
||||
req.Transform.FitMode,
|
||||
)
|
||||
|
||||
@@ -34,11 +34,11 @@ func TestSigner_Sign(t *testing.T) {
|
||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
||||
Format: testFormatWebP,
|
||||
},
|
||||
Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility
|
||||
}
|
||||
expires := time.Unix(1704067200, 0) // Fixed timestamp for reproducibility
|
||||
|
||||
sig1 := signer.Sign(req)
|
||||
sig2 := signer.Sign(req)
|
||||
sig1 := signer.Sign(req, expires)
|
||||
sig2 := signer.Sign(req, expires)
|
||||
|
||||
// Same input should produce same signature
|
||||
if sig1 != sig2 {
|
||||
@@ -62,18 +62,17 @@ func TestSigner_Sign(t *testing.T) {
|
||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
||||
Format: testFormatWebP,
|
||||
},
|
||||
Expires: time.Unix(1704067200, 0),
|
||||
}
|
||||
|
||||
sig3 := signer.Sign(req2)
|
||||
sig3 := signer.Sign(req2, expires)
|
||||
if sig1 == sig3 {
|
||||
t.Error("Sign() produced same signature for different input")
|
||||
}
|
||||
}
|
||||
|
||||
// validVerifyRequest returns a fully-populated request that verifies
|
||||
// successfully once signed.
|
||||
func validVerifyRequest() *imagerequest.Request {
|
||||
// validVerifyRequest returns a fully-populated request, and an expiry an hour
|
||||
// ahead, that verify successfully once signed.
|
||||
func validVerifyRequest() (*imagerequest.Request, time.Time) {
|
||||
return &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: testHost,
|
||||
@@ -83,13 +82,13 @@ func validVerifyRequest() *imagerequest.Request {
|
||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
||||
Format: testFormatWebP,
|
||||
},
|
||||
Expires: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
}, time.Now().Add(1 * time.Hour)
|
||||
}
|
||||
|
||||
type verifyCase struct {
|
||||
name string
|
||||
setup func() *imagerequest.Request
|
||||
name string
|
||||
// setup returns the request, the signature and the expiry to verify
|
||||
setup func() (*imagerequest.Request, string, time.Time)
|
||||
wantErr error
|
||||
}
|
||||
|
||||
@@ -97,54 +96,49 @@ func verifyCases(signer *signature.Signer) []verifyCase {
|
||||
return []verifyCase{
|
||||
{
|
||||
name: "valid signature",
|
||||
setup: func() *imagerequest.Request {
|
||||
req := validVerifyRequest()
|
||||
req.Signature = signer.Sign(req)
|
||||
setup: func() (*imagerequest.Request, string, time.Time) {
|
||||
req, expires := validVerifyRequest()
|
||||
|
||||
return req
|
||||
return req, signer.Sign(req, expires), expires
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "expired signature",
|
||||
setup: func() *imagerequest.Request {
|
||||
req := validVerifyRequest()
|
||||
req.Expires = time.Now().Add(-1 * time.Hour)
|
||||
req.Signature = signer.Sign(req)
|
||||
setup: func() (*imagerequest.Request, string, time.Time) {
|
||||
req, _ := validVerifyRequest()
|
||||
expires := time.Now().Add(-1 * time.Hour)
|
||||
|
||||
return req
|
||||
return req, signer.Sign(req, expires), expires
|
||||
},
|
||||
wantErr: signature.ErrExpired,
|
||||
},
|
||||
{
|
||||
name: "invalid signature",
|
||||
setup: func() *imagerequest.Request {
|
||||
req := validVerifyRequest()
|
||||
req.Signature = "invalid-signature"
|
||||
setup: func() (*imagerequest.Request, string, time.Time) {
|
||||
req, expires := validVerifyRequest()
|
||||
|
||||
return req
|
||||
return req, "invalid-signature", expires
|
||||
},
|
||||
wantErr: signature.ErrInvalid,
|
||||
},
|
||||
{
|
||||
name: "missing expiration",
|
||||
setup: func() *imagerequest.Request {
|
||||
req := validVerifyRequest()
|
||||
req.Expires = time.Time{}
|
||||
req.Signature = "some-signature"
|
||||
setup: func() (*imagerequest.Request, string, time.Time) {
|
||||
req, _ := validVerifyRequest()
|
||||
|
||||
return req
|
||||
return req, "some-signature", time.Time{}
|
||||
},
|
||||
wantErr: signature.ErrMissingExpiration,
|
||||
},
|
||||
{
|
||||
name: "tampered request",
|
||||
setup: func() *imagerequest.Request {
|
||||
req := validVerifyRequest()
|
||||
req.Signature = signer.Sign(req)
|
||||
setup: func() (*imagerequest.Request, string, time.Time) {
|
||||
req, expires := validVerifyRequest()
|
||||
sig := signer.Sign(req, expires)
|
||||
req.Source.Path = "/photos/secret.jpg"
|
||||
|
||||
return req
|
||||
return req, sig, expires
|
||||
},
|
||||
wantErr: signature.ErrInvalid,
|
||||
},
|
||||
@@ -160,8 +154,8 @@ func TestSigner_Verify(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := tt.setup()
|
||||
err := signer.Verify(req)
|
||||
req, sig, expires := tt.setup()
|
||||
err := signer.Verify(req, sig, expires)
|
||||
|
||||
if tt.wantErr == nil {
|
||||
if err != nil {
|
||||
@@ -254,8 +248,9 @@ func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
|
||||
|
||||
signer := signature.New("test-secret-key")
|
||||
|
||||
// Base request that we'll sign, then tamper with individual fields.
|
||||
baseReq := func() *imagerequest.Request {
|
||||
// Base request that we'll sign, then tamper with individual fields; it
|
||||
// is returned with its signature and expiry.
|
||||
baseReq := func() (*imagerequest.Request, string, time.Time) {
|
||||
req := &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: testHost,
|
||||
@@ -266,21 +261,20 @@ func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
|
||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
||||
Format: testFormatWebP,
|
||||
},
|
||||
Expires: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
req.Signature = signer.Sign(req)
|
||||
expires := time.Now().Add(1 * time.Hour)
|
||||
|
||||
return req
|
||||
return req, signer.Sign(req, expires), expires
|
||||
}
|
||||
|
||||
for _, tt := range exactMatchTamperCases() {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := baseReq()
|
||||
req, sig, expires := baseReq()
|
||||
tt.tamper(req)
|
||||
|
||||
err := signer.Verify(req)
|
||||
err := signer.Verify(req, sig, expires)
|
||||
if !errors.Is(err, signature.ErrInvalid) {
|
||||
t.Errorf("Verify() = %v, want %v", err, signature.ErrInvalid)
|
||||
}
|
||||
@@ -291,9 +285,9 @@ func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
|
||||
t.Run("unmodified request passes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := baseReq()
|
||||
req, sig, expires := baseReq()
|
||||
|
||||
err := signer.Verify(req)
|
||||
err := signer.Verify(req, sig, expires)
|
||||
if err != nil {
|
||||
t.Errorf("Verify() unmodified request failed: %v", err)
|
||||
}
|
||||
@@ -329,10 +323,9 @@ func TestSigner_Sign_ExactHostInData(t *testing.T) {
|
||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
||||
Format: testFormatWebP,
|
||||
},
|
||||
Expires: time.Unix(1704067200, 0),
|
||||
}
|
||||
|
||||
sig := signer.Sign(req)
|
||||
sig := signer.Sign(req, time.Unix(1704067200, 0))
|
||||
if existing, ok := sigs[sig]; ok {
|
||||
t.Errorf("hosts %q and %q produced the same signature", existing, host)
|
||||
}
|
||||
@@ -356,20 +349,20 @@ func TestSigner_DifferentKeys(t *testing.T) {
|
||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
||||
Format: testFormatWebP,
|
||||
},
|
||||
Expires: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
expires := time.Now().Add(1 * time.Hour)
|
||||
|
||||
// Sign with key 1
|
||||
req.Signature = signer1.Sign(req)
|
||||
sig := signer1.Sign(req, expires)
|
||||
|
||||
// Verify with key 1 should succeed
|
||||
err := signer1.Verify(req)
|
||||
err := signer1.Verify(req, sig, expires)
|
||||
if err != nil {
|
||||
t.Errorf("Verify() with same key failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify with key 2 should fail
|
||||
err = signer2.Verify(req)
|
||||
err = signer2.Verify(req, sig, expires)
|
||||
if !errors.Is(err, signature.ErrInvalid) {
|
||||
t.Errorf("Verify() with different key should fail, got: %v", err)
|
||||
}
|
||||
@@ -395,12 +388,11 @@ func TestSign_ReadmeQueryExample(t *testing.T) {
|
||||
Quality: 85,
|
||||
FitMode: testFitCover,
|
||||
},
|
||||
Expires: time.Unix(1704067200, 0),
|
||||
}
|
||||
|
||||
const wantSig = "37aFa9GSVCZM1kRY9SzMOk2fOg_M7Wt5gKNR4xM-FYA="
|
||||
|
||||
gotSig := signer.Sign(&req)
|
||||
gotSig := signer.Sign(&req, time.Unix(1704067200, 0))
|
||||
if gotSig != wantSig {
|
||||
t.Errorf("Sign() = %q, want %q", gotSig, wantSig)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user