2 Commits
Author SHA1 Message Date
sneak 43b9f1cb59 refactor: finish whitelist-to-allowlist rename in imgcache
check / check (push) Successful in 6s
2026-08-07 21:24:31 +07:00
sneak d69019b9d1 test: pin signature format with golden known-answer vectors 2026-08-07 21:03:24 +07:00
7 changed files with 142 additions and 37 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ func (s *Handlers) initImageService() error {
Cache: cache, Cache: cache,
FetcherConfig: fetcherCfg, FetcherConfig: fetcherCfg,
SigningKey: s.config.SigningKey, SigningKey: s.config.SigningKey,
Whitelist: s.config.WhitelistHosts, Allowlist: s.config.WhitelistHosts,
Logger: s.log, Logger: s.log,
}) })
if err != nil { if err != nil {
+1 -1
View File
@@ -57,7 +57,7 @@ func setupTestHandler(t *testing.T) *testFixtures {
Cache: cache, Cache: cache,
Fetcher: newMockFetcher(mockFS), Fetcher: newMockFetcher(mockFS),
SigningKey: "test-signing-key-must-be-32-chars", SigningKey: "test-signing-key-must-be-32-chars",
Whitelist: []string{goodHost}, Allowlist: []string{goodHost},
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create service: %v", err) t.Fatalf("failed to create service: %v", err)
+5 -5
View File
@@ -75,7 +75,7 @@ type ImageRequest struct {
Quality int Quality int
// FitMode is how to fit the image into requested dimensions // FitMode is how to fit the image into requested dimensions
FitMode FitMode FitMode FitMode
// Signature is the HMAC signature for non-whitelisted hosts // Signature is the HMAC signature for non-allowlisted hosts
Signature string Signature string
// Expires is the signature expiration timestamp // Expires is the signature expiration timestamp
Expires time.Time Expires time.Time
@@ -163,10 +163,10 @@ type SignatureValidator interface {
Generate(req *ImageRequest) string Generate(req *ImageRequest) string
} }
// Whitelist checks if a URL is whitelisted (no signature required) // Allowlist checks if a URL is allowlisted (no signature required)
type Whitelist interface { type Allowlist interface {
// IsWhitelisted returns true if the URL doesn't require a signature // IsAllowlisted returns true if the URL doesn't require a signature
IsWhitelisted(u *url.URL) bool IsAllowlisted(u *url.URL) bool
} }
// Storage handles persistent storage of cached content // Storage handles persistent storage of cached content
+3 -3
View File
@@ -40,8 +40,8 @@ type ServiceConfig struct {
Fetcher httpfetcher.Fetcher Fetcher httpfetcher.Fetcher
// SigningKey is the HMAC signing key (empty disables signing) // SigningKey is the HMAC signing key (empty disables signing)
SigningKey string SigningKey string
// Whitelist is the list of hosts that don't require signatures // Allowlist is the list of hosts that don't require signatures
Whitelist []string Allowlist []string
// Logger for logging // Logger for logging
Logger *slog.Logger Logger *slog.Logger
} }
@@ -89,7 +89,7 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
fetcher: fetcher, fetcher: fetcher,
processor: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}), processor: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}),
signer: signer, signer: signer,
allowlist: allowlist.New(cfg.Whitelist), allowlist: allowlist.New(cfg.Allowlist),
log: log, log: log,
allowHTTP: allowHTTP, allowHTTP: allowHTTP,
maxResponseSize: maxResponseSize, maxResponseSize: maxResponseSize,
+11 -11
View File
@@ -10,7 +10,7 @@ import (
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
) )
func TestService_Get_WhitelistedHost(t *testing.T) { func TestService_Get_AllowlistedHost(t *testing.T) {
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
@@ -44,7 +44,7 @@ func TestService_Get_WhitelistedHost(t *testing.T) {
} }
} }
func TestService_Get_NonWhitelistedHost_NoSignature(t *testing.T) { func TestService_Get_NonAllowlistedHost_NoSignature(t *testing.T) {
svc, fixtures := SetupTestService(t, WithSigningKey("test-key")) svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
req := &ImageRequest{ req := &ImageRequest{
@@ -56,14 +56,14 @@ func TestService_Get_NonWhitelistedHost_NoSignature(t *testing.T) {
FitMode: FitCover, FitMode: FitCover,
} }
// Should fail validation - not whitelisted and no signature // Should fail validation - not allowlisted and no signature
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
if err == nil { if err == nil {
t.Error("ValidateRequest() expected error for non-whitelisted host without signature") t.Error("ValidateRequest() expected error for non-allowlisted host without signature")
} }
} }
func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) { func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) {
signingKey := "test-signing-key-12345" signingKey := "test-signing-key-12345"
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
ctx := context.Background() ctx := context.Background()
@@ -105,7 +105,7 @@ func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
} }
} }
func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) { func TestService_Get_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
signingKey := "test-signing-key-12345" signingKey := "test-signing-key-12345"
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
@@ -130,7 +130,7 @@ func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
} }
} }
func TestService_Get_NonWhitelistedHost_InvalidSignature(t *testing.T) { func TestService_Get_NonAllowlistedHost_InvalidSignature(t *testing.T) {
signingKey := "test-signing-key-12345" signingKey := "test-signing-key-12345"
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
@@ -162,7 +162,7 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
signingKey := "test-signing-key-must-be-32-chars" signingKey := "test-signing-key-must-be-32-chars"
svc, _ := SetupTestService(t, svc, _ := SetupTestService(t,
WithSigningKey(signingKey), WithSigningKey(signingKey),
WithNoWhitelist(), WithNoAllowlist(),
) )
signer := signature.New(signingKey) signer := signature.New(signingKey)
@@ -438,8 +438,8 @@ func TestService_Get_DifferentSizes(t *testing.T) {
} }
func TestService_ValidateRequest_NoSigningKey(t *testing.T) { func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
// Service with no signing key - all non-whitelisted requests should fail // Service with no signing key - all non-allowlisted requests should fail
svc, fixtures := SetupTestService(t, WithNoWhitelist()) svc, fixtures := SetupTestService(t, WithNoAllowlist())
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.OtherHost, SourceHost: fixtures.OtherHost,
@@ -452,7 +452,7 @@ func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
if err == nil { if err == nil {
t.Error("ValidateRequest() expected error when no signing key and host not whitelisted") t.Error("ValidateRequest() expected error when no signing key and host not allowlisted")
} }
} }
+16 -16
View File
@@ -21,11 +21,11 @@ import (
// TestFixtures contains paths to test files in the mock filesystem. // TestFixtures contains paths to test files in the mock filesystem.
type TestFixtures struct { type TestFixtures struct {
// Valid image files // Valid image files
GoodHostJPEG string // whitelisted host, valid JPEG GoodHostJPEG string // allowlisted host, valid JPEG
GoodHostPNG string // whitelisted host, valid PNG GoodHostPNG string // allowlisted host, valid PNG
GoodHostGIF string // whitelisted host, valid GIF GoodHostGIF string // allowlisted host, valid GIF
OtherHostJPEG string // non-whitelisted host, valid JPEG OtherHostJPEG string // non-allowlisted host, valid JPEG
OtherHostPNG string // non-whitelisted host, valid PNG OtherHostPNG string // non-allowlisted host, valid PNG
// Invalid/edge case files // Invalid/edge case files
InvalidFile string // file with wrong magic bytes InvalidFile string // file with wrong magic bytes
@@ -33,8 +33,8 @@ type TestFixtures struct {
TextFile string // text file masquerading as image TextFile string // text file masquerading as image
// Hostnames // Hostnames
GoodHost string // whitelisted hostname GoodHost string // allowlisted hostname
OtherHost string // non-whitelisted hostname OtherHost string // non-allowlisted hostname
} }
// DefaultFixtures returns the standard test fixture paths. // DefaultFixtures returns the standard test fixture paths.
@@ -148,7 +148,7 @@ func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestF
mockFS, fixtures := NewTestFS(t) mockFS, fixtures := NewTestFS(t)
cfg := &testServiceConfig{ cfg := &testServiceConfig{
whitelist: []string{fixtures.GoodHost}, allowlist: []string{fixtures.GoodHost},
signingKey: "test-signing-key-must-be-32-chars", signingKey: "test-signing-key-must-be-32-chars",
} }
@@ -175,7 +175,7 @@ func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestF
Cache: cache, Cache: cache,
Fetcher: httpfetcher.NewMock(mockFS), Fetcher: httpfetcher.NewMock(mockFS),
SigningKey: cfg.signingKey, SigningKey: cfg.signingKey,
Whitelist: cfg.whitelist, Allowlist: cfg.allowlist,
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create service: %v", err) t.Fatalf("failed to create service: %v", err)
@@ -203,17 +203,17 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
} }
type testServiceConfig struct { type testServiceConfig struct {
whitelist []string allowlist []string
signingKey string signingKey string
} }
// TestServiceOption configures the test service. // TestServiceOption configures the test service.
type TestServiceOption func(*testServiceConfig) type TestServiceOption func(*testServiceConfig)
// WithWhitelist sets the whitelist for the test service. // WithAllowlist sets the allowlist for the test service.
func WithWhitelist(hosts ...string) TestServiceOption { func WithAllowlist(hosts ...string) TestServiceOption {
return func(c *testServiceConfig) { return func(c *testServiceConfig) {
c.whitelist = hosts c.allowlist = hosts
} }
} }
@@ -224,9 +224,9 @@ func WithSigningKey(key string) TestServiceOption {
} }
} }
// WithNoWhitelist removes all whitelisted hosts. // WithNoAllowlist removes all allowlisted hosts.
func WithNoWhitelist() TestServiceOption { func WithNoAllowlist() TestServiceOption {
return func(c *testServiceConfig) { return func(c *testServiceConfig) {
c.whitelist = nil c.allowlist = nil
} }
} }
+105
View File
@@ -0,0 +1,105 @@
package signature
import (
"testing"
"time"
)
// goldenExpiresUnix is the fixed expiration timestamp used by all golden
// vectors: 2024-01-01T00:00:00Z.
const goldenExpiresUnix int64 = 1704067200
// goldenSigningKey is the fixed signing key used by all golden vectors.
const goldenSigningKey = "golden-test-key"
// TestSigner_GoldenVectors pins the exact HMAC-SHA256 signature output and
// the exact generated signed URL path for fully-specified requests with a
// hardcoded signing key. The expected values were computed once and are
// hardcoded here as known answers.
//
// 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.
func TestSigner_GoldenVectors(t *testing.T) {
signer := New(goldenSigningKey)
vectors := []struct {
name string
req Request
// wantSignature is the exact base64url (RFC 4648 URL-safe,
// padded) HMAC-SHA256 signature for the request with Expires
// set to goldenExpiresUnix.
wantSignature string
// wantSignedPath is the exact path returned by
// GenerateSignedURL for the request. The signature and
// expiration are returned separately by GenerateSignedURL and
// are not embedded in the path.
wantSignedPath string
}{
{
name: "resized without query",
req: Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "",
Width: 800,
Height: 600,
Format: "webp",
},
// Signed data: "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200"
wantSignature: "x5PfPp8QSDo0cJT96od-AEgrQyOVLfqifH5sst61_-w=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
},
{
name: "resized with query string",
req: Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "token=abc&v=2",
Width: 800,
Height: 600,
Format: "webp",
},
// Signed data: "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200"
wantSignature: "394_Vf9TdQFkpQ3XKFDQSyxgqKq8N7mApf2S4QaHqyo=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg%3Ftoken=abc&v=2/800x600.webp",
},
{
name: "original size without query",
req: Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "",
Width: 0,
Height: 0,
Format: "png",
},
// Signed data: "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200"
wantSignature: "7Be7oteeQwvnSPU4bchyQ4ZGYGsAGBKpeEtuQ02ox60=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
},
}
for _, tt := range vectors {
t.Run(tt.name, func(t *testing.T) {
signReq := tt.req
signReq.Expires = time.Unix(goldenExpiresUnix, 0)
gotSignature := signer.Sign(&signReq)
if gotSignature != tt.wantSignature {
t.Errorf("Sign() = %q, want %q (signed byte format changed?)",
gotSignature, tt.wantSignature)
}
urlReq := tt.req
gotPath, _, _ := signer.GenerateSignedURL(&urlReq, time.Hour)
if gotPath != tt.wantSignedPath {
t.Errorf("GenerateSignedURL() path = %q, want %q (signed URL layout changed?)",
gotPath, tt.wantSignedPath)
}
})
}
}