check / check (push) Canceled after 0s
The query follows the source path after %3F. The image route now parses the request's path decoded once instead of chi's wildcard, which holds the path as the client wrote it whenever that is not Go's own encoding of it, so a / ; or , written as %2F, %3B or %2C, as GenerateSignedURL wrote them, hid the %3F and the request failed with 401. Now the ? and every % in the source path or query must be percent-encoded and any other character may be. GenerateSignedURL writes Go's own encoding of the path. The README states the rule, with an example and its signature. Model: opus-5-5
579 lines
14 KiB
Go
579 lines
14 KiB
Go
package signature_test
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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"
|
|
testSignedPath = "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
|
|
testSig = "abc123"
|
|
)
|
|
|
|
func TestSigner_Sign(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signer := signature.New("test-secret-key")
|
|
|
|
req := &signature.Request{
|
|
SourceHost: testHost,
|
|
SourcePath: testPath,
|
|
SourceQuery: "",
|
|
Width: 800,
|
|
Height: 600,
|
|
Format: testFormatWebP,
|
|
Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility
|
|
}
|
|
|
|
sig1 := signer.Sign(req)
|
|
sig2 := signer.Sign(req)
|
|
|
|
// 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 := &signature.Request{
|
|
SourceHost: testHost,
|
|
SourcePath: "/photos/dog.jpg", // Different path
|
|
SourceQuery: "",
|
|
Width: 800,
|
|
Height: 600,
|
|
Format: testFormatWebP,
|
|
Expires: time.Unix(1704067200, 0),
|
|
}
|
|
|
|
sig3 := signer.Sign(req2)
|
|
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() *signature.Request {
|
|
return &signature.Request{
|
|
SourceHost: testHost,
|
|
SourcePath: testPath,
|
|
Width: 800,
|
|
Height: 600,
|
|
Format: testFormatWebP,
|
|
Expires: time.Now().Add(1 * time.Hour),
|
|
}
|
|
}
|
|
|
|
type verifyCase struct {
|
|
name string
|
|
setup func() *signature.Request
|
|
wantErr error
|
|
}
|
|
|
|
func verifyCases(signer *signature.Signer) []verifyCase {
|
|
return []verifyCase{
|
|
{
|
|
name: "valid signature",
|
|
setup: func() *signature.Request {
|
|
req := validVerifyRequest()
|
|
req.Signature = signer.Sign(req)
|
|
|
|
return req
|
|
},
|
|
wantErr: nil,
|
|
},
|
|
{
|
|
name: "expired signature",
|
|
setup: func() *signature.Request {
|
|
req := validVerifyRequest()
|
|
req.Expires = time.Now().Add(-1 * time.Hour)
|
|
req.Signature = signer.Sign(req)
|
|
|
|
return req
|
|
},
|
|
wantErr: signature.ErrExpired,
|
|
},
|
|
{
|
|
name: "invalid signature",
|
|
setup: func() *signature.Request {
|
|
req := validVerifyRequest()
|
|
req.Signature = "invalid-signature"
|
|
|
|
return req
|
|
},
|
|
wantErr: signature.ErrInvalid,
|
|
},
|
|
{
|
|
name: "missing expiration",
|
|
setup: func() *signature.Request {
|
|
req := validVerifyRequest()
|
|
req.Expires = time.Time{}
|
|
req.Signature = "some-signature"
|
|
|
|
return req
|
|
},
|
|
wantErr: signature.ErrMissingExpiration,
|
|
},
|
|
{
|
|
name: "tampered request",
|
|
setup: func() *signature.Request {
|
|
req := validVerifyRequest()
|
|
req.Signature = signer.Sign(req)
|
|
req.SourcePath = "/photos/secret.jpg"
|
|
|
|
return req
|
|
},
|
|
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 := tt.setup()
|
|
err := signer.Verify(req)
|
|
|
|
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 *signature.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 *signature.Request) { r.SourceHost = "example.com" },
|
|
},
|
|
{
|
|
name: "subdomain does not match parent domain",
|
|
tamper: func(r *signature.Request) { r.SourceHost = "images.cdn.example.com" },
|
|
},
|
|
{
|
|
name: "sibling subdomain does not match",
|
|
tamper: func(r *signature.Request) { r.SourceHost = "images.example.com" },
|
|
},
|
|
{
|
|
name: "host with suffix appended does not match",
|
|
tamper: func(r *signature.Request) { r.SourceHost = testHost + ".evil.com" },
|
|
},
|
|
{
|
|
name: "host with prefix does not match",
|
|
tamper: func(r *signature.Request) { r.SourceHost = "evilcdn.example.com" },
|
|
},
|
|
{
|
|
name: "different path does not match",
|
|
tamper: func(r *signature.Request) { r.SourcePath = "/photos/dog.jpg" },
|
|
},
|
|
{
|
|
name: "path suffix does not match",
|
|
tamper: func(r *signature.Request) { r.SourcePath = testPath + "/extra" },
|
|
},
|
|
{
|
|
name: "path prefix does not match",
|
|
tamper: func(r *signature.Request) { r.SourcePath = "/other" + testPath },
|
|
},
|
|
{
|
|
name: "different query does not match",
|
|
tamper: func(r *signature.Request) { r.SourceQuery = "token=xyz" },
|
|
},
|
|
{
|
|
name: "added query does not match empty query",
|
|
tamper: func(r *signature.Request) { r.SourceQuery = "extra=1" },
|
|
},
|
|
{
|
|
name: "removed query does not match",
|
|
tamper: func(r *signature.Request) { r.SourceQuery = "" },
|
|
},
|
|
{
|
|
name: "different width does not match",
|
|
tamper: func(r *signature.Request) { r.Width = 801 },
|
|
},
|
|
{
|
|
name: "different height does not match",
|
|
tamper: func(r *signature.Request) { r.Height = 601 },
|
|
},
|
|
{
|
|
name: "different format does not match",
|
|
tamper: func(r *signature.Request) { r.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.
|
|
baseReq := func() *signature.Request {
|
|
req := &signature.Request{
|
|
SourceHost: testHost,
|
|
SourcePath: testPath,
|
|
SourceQuery: "token=abc",
|
|
Width: 800,
|
|
Height: 600,
|
|
Format: testFormatWebP,
|
|
Expires: time.Now().Add(1 * time.Hour),
|
|
}
|
|
req.Signature = signer.Sign(req)
|
|
|
|
return req
|
|
}
|
|
|
|
for _, tt := range exactMatchTamperCases() {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := baseReq()
|
|
tt.tamper(req)
|
|
|
|
err := signer.Verify(req)
|
|
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 := baseReq()
|
|
|
|
err := signer.Verify(req)
|
|
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 := &signature.Request{
|
|
SourceHost: host,
|
|
SourcePath: testPath,
|
|
SourceQuery: "",
|
|
Width: 800,
|
|
Height: 600,
|
|
Format: testFormatWebP,
|
|
Expires: time.Unix(1704067200, 0),
|
|
}
|
|
|
|
sig := signer.Sign(req)
|
|
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 := &signature.Request{
|
|
SourceHost: testHost,
|
|
SourcePath: testPath,
|
|
Width: 800,
|
|
Height: 600,
|
|
Format: testFormatWebP,
|
|
Expires: time.Now().Add(1 * time.Hour),
|
|
}
|
|
|
|
// Sign with key 1
|
|
req.Signature = signer1.Sign(req)
|
|
|
|
// Verify with key 1 should succeed
|
|
err := signer1.Verify(req)
|
|
if err != nil {
|
|
t.Errorf("Verify() with same key failed: %v", err)
|
|
}
|
|
|
|
// Verify with key 2 should fail
|
|
err = signer2.Verify(req)
|
|
if !errors.Is(err, signature.ErrInvalid) {
|
|
t.Errorf("Verify() with different key should fail, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGenerateSignedURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signer := signature.New("test-secret-key")
|
|
|
|
req := &signature.Request{
|
|
SourceHost: testHost,
|
|
SourcePath: testPath,
|
|
SourceQuery: "",
|
|
Width: 800,
|
|
Height: 600,
|
|
Format: testFormatWebP,
|
|
}
|
|
|
|
ttl := 1 * time.Hour
|
|
path, sig, exp := signer.GenerateSignedURL(req, ttl)
|
|
|
|
// Path should be correct format
|
|
if path != testSignedPath {
|
|
t.Errorf("GenerateSignedURL() path = %q, want %q", path, testSignedPath)
|
|
}
|
|
|
|
// Signature should be non-empty
|
|
if sig == "" {
|
|
t.Error("GenerateSignedURL() produced empty signature")
|
|
}
|
|
|
|
// Expiration should be approximately now + TTL
|
|
expTime := time.Unix(exp, 0)
|
|
|
|
expectedExp := time.Now().Add(ttl)
|
|
if expTime.Sub(expectedExp) > time.Second {
|
|
t.Errorf("GenerateSignedURL() exp time off by too much")
|
|
}
|
|
|
|
// Request should have been updated with signature and expiration
|
|
if req.Signature != sig {
|
|
t.Errorf("GenerateSignedURL() didn't update request signature")
|
|
}
|
|
}
|
|
|
|
func TestGenerateSignedURL_OrigSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signer := signature.New("test-secret-key")
|
|
|
|
req := &signature.Request{
|
|
SourceHost: testHost,
|
|
SourcePath: testPath,
|
|
Width: 0, // Original size
|
|
Height: 0,
|
|
Format: testFormatPNG,
|
|
}
|
|
|
|
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
|
|
|
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/orig.png"
|
|
if path != expectedPath {
|
|
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
|
|
}
|
|
}
|
|
|
|
func TestGenerateSignedURL_WithQueryString(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signer := signature.New("test-secret-key-for-testing!")
|
|
|
|
req := &signature.Request{
|
|
SourceHost: testHost,
|
|
SourcePath: testPath,
|
|
SourceQuery: "token=abc&v=2",
|
|
Width: 800,
|
|
Height: 600,
|
|
Format: testFormatWebP,
|
|
}
|
|
|
|
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
|
|
|
// The path must NOT contain a bare "?" that would be interpreted as
|
|
// a query string delimiter. The size segment must appear as the last
|
|
// path component.
|
|
if strings.Contains(path, "?token=abc") {
|
|
t.Errorf("GenerateSignedURL() produced bare query string in path: %q", path)
|
|
}
|
|
|
|
// The size segment must be present in the path
|
|
if !strings.Contains(path, "/800x600.webp") {
|
|
t.Errorf("GenerateSignedURL() missing size segment in path: %q", path)
|
|
}
|
|
|
|
// Path should end with the size.format, not with query params
|
|
if !strings.HasSuffix(path, "/800x600.webp") {
|
|
t.Errorf("GenerateSignedURL() path should end with size.format: %q", path)
|
|
}
|
|
}
|
|
|
|
func TestGenerateSignedURL_WithoutQueryString(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signer := signature.New("test-secret-key-for-testing!")
|
|
|
|
req := &signature.Request{
|
|
SourceHost: testHost,
|
|
SourcePath: testPath,
|
|
Width: 800,
|
|
Height: 600,
|
|
Format: testFormatWebP,
|
|
}
|
|
|
|
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
|
|
|
if path != testSignedPath {
|
|
t.Errorf("GenerateSignedURL() path = %q, want %q", path, testSignedPath)
|
|
}
|
|
}
|
|
|
|
// TestGenerateSignedURL_ReadmeQueryExample checks 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: its
|
|
// signature with the README's key, and the path GenerateSignedURL writes for
|
|
// it, with the "?" written as %3F and the "%" as %25.
|
|
func TestGenerateSignedURL_ReadmeQueryExample(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signer := signature.New("example-signing-key-for-documentation")
|
|
|
|
req := signature.Request{
|
|
SourceHost: testHost,
|
|
SourcePath: "/img",
|
|
SourceQuery: "url=https://origin.example.com/my%20cat.jpg",
|
|
Width: 800,
|
|
Height: 600,
|
|
Format: testFormatWebP,
|
|
Quality: 85,
|
|
FitMode: testFitCover,
|
|
Expires: time.Unix(1704067200, 0),
|
|
}
|
|
|
|
const wantSig = "37aFa9GSVCZM1kRY9SzMOk2fOg_M7Wt5gKNR4xM-FYA="
|
|
|
|
gotSig := signer.Sign(&req)
|
|
if gotSig != wantSig {
|
|
t.Errorf("Sign() = %q, want %q", gotSig, wantSig)
|
|
}
|
|
|
|
const wantPath = "/v1/image/cdn.example.com" +
|
|
"/img%3Furl=https://origin.example.com/my%2520cat.jpg/800x600.webp"
|
|
|
|
gotPath, _, _ := signer.GenerateSignedURL(&req, time.Hour)
|
|
if gotPath != wantPath {
|
|
t.Errorf("GenerateSignedURL() path = %q, want %q", gotPath, wantPath)
|
|
}
|
|
}
|
|
|
|
func TestParseParams(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
sig string
|
|
expStr string
|
|
wantSig string
|
|
wantErr bool
|
|
checkTime bool
|
|
}{
|
|
{
|
|
name: "valid params",
|
|
sig: testSig,
|
|
expStr: "1704067200",
|
|
wantSig: testSig,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "empty expiration",
|
|
sig: testSig,
|
|
expStr: "",
|
|
wantSig: testSig,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid expiration",
|
|
sig: testSig,
|
|
expStr: "not-a-number",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sig, exp, err := signature.ParseParams(tt.sig, tt.expStr)
|
|
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("ParseParams() expected error, got nil")
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Errorf("ParseParams() unexpected error = %v", err)
|
|
|
|
return
|
|
}
|
|
|
|
if sig != tt.wantSig {
|
|
t.Errorf("sig = %q, want %q", sig, tt.wantSig)
|
|
}
|
|
|
|
if tt.expStr != "" && exp.IsZero() {
|
|
t.Error("exp should not be zero when expStr is provided")
|
|
}
|
|
})
|
|
}
|
|
}
|