Files
pixa/internal/signature/signature_test.go
T
sneak 8a03d0dbe7
check / check (push) Canceled after 0s
Take the signature, the expiry and the URL scheme off the image request (closes #314)
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
2026-10-26 12:18:23 +00:00

400 lines
10 KiB
Go

package signature_test
import (
"errors"
"testing"
"time"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/signature"
)
// Shared fixture values used across the signature tests.
const (
testHost = "cdn.example.com"
testPath = "/photos/cat.jpg"
testFormatWebP = "webp"
testFormatPNG = "png"
testFitCover = "cover"
testFitContain = "contain"
)
func TestSigner_Sign(t *testing.T) {
t.Parallel()
signer := signature.New("test-secret-key")
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: testHost,
Path: testPath,
Query: "",
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
}
expires := time.Unix(1704067200, 0) // Fixed timestamp for reproducibility
sig1 := signer.Sign(req, expires)
sig2 := signer.Sign(req, expires)
// Same input should produce same signature
if sig1 != sig2 {
t.Errorf("Sign() produced different signatures for same input: %q vs %q",
sig1, sig2)
}
// Signature should be non-empty
if sig1 == "" {
t.Error("Sign() produced empty signature")
}
// Different input should produce different signature
req2 := &imagerequest.Request{
Source: imagerequest.Source{
Host: testHost,
Path: "/photos/dog.jpg", // Different path
Query: "",
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
}
sig3 := signer.Sign(req2, expires)
if sig1 == sig3 {
t.Error("Sign() produced same signature for different input")
}
}
// 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,
Path: testPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
}, time.Now().Add(1 * time.Hour)
}
type verifyCase struct {
name string
// setup returns the request, the signature and the expiry to verify
setup func() (*imagerequest.Request, string, time.Time)
wantErr error
}
func verifyCases(signer *signature.Signer) []verifyCase {
return []verifyCase{
{
name: "valid signature",
setup: func() (*imagerequest.Request, string, time.Time) {
req, expires := validVerifyRequest()
return req, signer.Sign(req, expires), expires
},
wantErr: nil,
},
{
name: "expired signature",
setup: func() (*imagerequest.Request, string, time.Time) {
req, _ := validVerifyRequest()
expires := time.Now().Add(-1 * time.Hour)
return req, signer.Sign(req, expires), expires
},
wantErr: signature.ErrExpired,
},
{
name: "invalid signature",
setup: func() (*imagerequest.Request, string, time.Time) {
req, expires := validVerifyRequest()
return req, "invalid-signature", expires
},
wantErr: signature.ErrInvalid,
},
{
name: "missing expiration",
setup: func() (*imagerequest.Request, string, time.Time) {
req, _ := validVerifyRequest()
return req, "some-signature", time.Time{}
},
wantErr: signature.ErrMissingExpiration,
},
{
name: "tampered request",
setup: func() (*imagerequest.Request, string, time.Time) {
req, expires := validVerifyRequest()
sig := signer.Sign(req, expires)
req.Source.Path = "/photos/secret.jpg"
return req, sig, expires
},
wantErr: signature.ErrInvalid,
},
}
}
func TestSigner_Verify(t *testing.T) {
t.Parallel()
signer := signature.New("test-secret-key")
for _, tt := range verifyCases(signer) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req, sig, expires := tt.setup()
err := signer.Verify(req, sig, expires)
if tt.wantErr == nil {
if err != nil {
t.Errorf("Verify() unexpected error = %v", err)
}
return
}
if !errors.Is(err, tt.wantErr) {
t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
type tamperCase struct {
name string
tamper func(r *imagerequest.Request)
}
// exactMatchTamperCases mutates one signed component per case; every
// mutation must cause verification to fail with ErrInvalid.
func exactMatchTamperCases() []tamperCase {
return []tamperCase{
{
name: "parent domain does not match subdomain",
tamper: func(r *imagerequest.Request) { r.Source.Host = "example.com" },
},
{
name: "subdomain does not match parent domain",
tamper: func(r *imagerequest.Request) { r.Source.Host = "images.cdn.example.com" },
},
{
name: "sibling subdomain does not match",
tamper: func(r *imagerequest.Request) { r.Source.Host = "images.example.com" },
},
{
name: "host with suffix appended does not match",
tamper: func(r *imagerequest.Request) { r.Source.Host = testHost + ".evil.com" },
},
{
name: "host with prefix does not match",
tamper: func(r *imagerequest.Request) { r.Source.Host = "evilcdn.example.com" },
},
{
name: "different path does not match",
tamper: func(r *imagerequest.Request) { r.Source.Path = "/photos/dog.jpg" },
},
{
name: "path suffix does not match",
tamper: func(r *imagerequest.Request) { r.Source.Path = testPath + "/extra" },
},
{
name: "path prefix does not match",
tamper: func(r *imagerequest.Request) { r.Source.Path = "/other" + testPath },
},
{
name: "different query does not match",
tamper: func(r *imagerequest.Request) { r.Source.Query = "token=xyz" },
},
{
name: "added query does not match empty query",
tamper: func(r *imagerequest.Request) { r.Source.Query = "extra=1" },
},
{
name: "removed query does not match",
tamper: func(r *imagerequest.Request) { r.Source.Query = "" },
},
{
name: "different width does not match",
tamper: func(r *imagerequest.Request) { r.Transform.Size.Width = 801 },
},
{
name: "different height does not match",
tamper: func(r *imagerequest.Request) { r.Transform.Size.Height = 601 },
},
{
name: "different format does not match",
tamper: func(r *imagerequest.Request) { r.Transform.Format = testFormatPNG },
},
}
}
// TestSigner_Verify_ExactMatchOnly verifies that signatures enforce exact
// matching on every URL component. No suffix matching, wildcard matching,
// or partial matching is supported.
func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
t.Parallel()
signer := signature.New("test-secret-key")
// 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,
Path: testPath,
Query: "token=abc",
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
}
expires := time.Now().Add(1 * time.Hour)
return req, signer.Sign(req, expires), expires
}
for _, tt := range exactMatchTamperCases() {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req, sig, expires := baseReq()
tt.tamper(req)
err := signer.Verify(req, sig, expires)
if !errors.Is(err, signature.ErrInvalid) {
t.Errorf("Verify() = %v, want %v", err, signature.ErrInvalid)
}
})
}
// Verify the unmodified base request still passes
t.Run("unmodified request passes", func(t *testing.T) {
t.Parallel()
req, sig, expires := baseReq()
err := signer.Verify(req, sig, expires)
if err != nil {
t.Errorf("Verify() unmodified request failed: %v", err)
}
})
}
// TestSigner_Sign_ExactHostInData verifies that Sign uses the exact host
// string in the signature data, producing different signatures for
// suffix-related hosts.
func TestSigner_Sign_ExactHostInData(t *testing.T) {
t.Parallel()
signer := signature.New("test-secret-key")
hosts := []string{
testHost,
"example.com",
"images.example.com",
"images.cdn.example.com",
"cdn.example.com.evil.com",
}
sigs := make(map[string]string)
for _, host := range hosts {
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: host,
Path: testPath,
Query: "",
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
}
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)
}
sigs[sig] = host
}
}
func TestSigner_DifferentKeys(t *testing.T) {
t.Parallel()
signer1 := signature.New("secret-key-1")
signer2 := signature.New("secret-key-2")
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: testHost,
Path: testPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
}
expires := time.Now().Add(1 * time.Hour)
// Sign with key 1
sig := signer1.Sign(req, expires)
// Verify with key 1 should succeed
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, sig, expires)
if !errors.Is(err, signature.ErrInvalid) {
t.Errorf("Verify() with different key should fail, got: %v", err)
}
}
// TestSign_ReadmeQueryExample checks the signature, with the README's key, of
// the README's example of a source URL with a query, the image at
// https://cdn.example.com/img?url=https://origin.example.com/my%20cat.jpg.
func TestSign_ReadmeQueryExample(t *testing.T) {
t.Parallel()
signer := signature.New("example-signing-key-for-documentation")
req := imagerequest.Request{
Source: imagerequest.Source{
Host: testHost,
Path: "/img",
Query: "url=https://origin.example.com/my%20cat.jpg",
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
Quality: 85,
FitMode: testFitCover,
},
}
const wantSig = "37aFa9GSVCZM1kRY9SzMOk2fOg_M7Wt5gKNR4xM-FYA="
gotSig := signer.Sign(&req, time.Unix(1704067200, 0))
if gotSig != wantSig {
t.Errorf("Sign() = %q, want %q", gotSig, wantSig)
}
}