diff --git a/README.md b/README.md index 96b4c1a..7397c45 100644 --- a/README.md +++ b/README.md @@ -96,11 +96,10 @@ expiration 1704067200: 4. URL: `/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=&exp=1704067200` -**Whitelist patterns:** - -- **Exact match**: `cdn.example.com` — matches only that host -- **Suffix match**: `.example.com` — matches `cdn.example.com`, - `images.example.com`, and `example.com` +**Whitelist entries** are exact host matches only (e.g. +`cdn.example.com`). Suffix/wildcard matching is not supported — +signatures are per-URL, so each allowed host must be listed +explicitly. ### Configuration diff --git a/config.example.yml b/config.example.yml index 1660cb4..44f9b94 100644 --- a/config.example.yml +++ b/config.example.yml @@ -13,8 +13,7 @@ state_dir: ./data # Generate with: openssl rand -base64 32 signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32" -# Hosts that don't require signatures -# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com") +# Hosts that don't require signatures (exact match only) whitelist_hosts: - s3.sneak.cloud - static.sneak.cloud diff --git a/internal/imgcache/whitelist.go b/internal/imgcache/whitelist.go index df24be2..22d5fcb 100644 --- a/internal/imgcache/whitelist.go +++ b/internal/imgcache/whitelist.go @@ -5,23 +5,20 @@ import ( "strings" ) -// HostWhitelist implements the Whitelist interface for checking allowed source hosts. +// HostWhitelist checks whether a source host is allowed without a signature. +// Only exact host matches are supported. Signatures are per-URL, so +// wildcard/suffix matching is intentionally not provided. type HostWhitelist struct { // exactHosts contains hosts that must match exactly (e.g., "cdn.example.com") exactHosts map[string]struct{} - // suffixHosts contains domain suffixes to match (e.g., ".example.com" matches "cdn.example.com") - suffixHosts []string } -// NewHostWhitelist creates a whitelist from a list of host patterns. -// Patterns starting with "." are treated as suffix matches. -// Examples: -// - "cdn.example.com" - exact match only -// - ".example.com" - matches cdn.example.com, images.example.com, etc. +// NewHostWhitelist creates a whitelist from a list of hostnames. +// Each entry is treated as an exact host match. Leading dots are +// stripped so that legacy ".example.com" entries become "example.com". func NewHostWhitelist(patterns []string) *HostWhitelist { w := &HostWhitelist{ - exactHosts: make(map[string]struct{}), - suffixHosts: make([]string, 0), + exactHosts: make(map[string]struct{}), } for _, pattern := range patterns { @@ -30,9 +27,11 @@ func NewHostWhitelist(patterns []string) *HostWhitelist { continue } - if strings.HasPrefix(pattern, ".") { - w.suffixHosts = append(w.suffixHosts, pattern) - } else { + // Strip leading dot — suffix matching is no longer supported; + // ".example.com" is normalised to "example.com" as an exact entry. + pattern = strings.TrimPrefix(pattern, ".") + + if pattern != "" { w.exactHosts[pattern] = struct{}{} } } @@ -40,7 +39,7 @@ func NewHostWhitelist(patterns []string) *HostWhitelist { return w } -// IsWhitelisted checks if a URL's host is in the whitelist. +// IsWhitelisted checks if a URL's host is in the whitelist (exact match only). func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool { if u == nil { return false @@ -51,32 +50,17 @@ func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool { return false } - // Check exact match - if _, ok := w.exactHosts[host]; ok { - return true - } + _, ok := w.exactHosts[host] - // Check suffix match - for _, suffix := range w.suffixHosts { - if strings.HasSuffix(host, suffix) { - return true - } - // Also match if host equals the suffix without the leading dot - // e.g., pattern ".example.com" should match "example.com" - if host == strings.TrimPrefix(suffix, ".") { - return true - } - } - - return false + return ok } // IsEmpty returns true if the whitelist has no entries. func (w *HostWhitelist) IsEmpty() bool { - return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0 + return len(w.exactHosts) == 0 } // Count returns the total number of whitelist entries. func (w *HostWhitelist) Count() int { - return len(w.exactHosts) + len(w.suffixHosts) + return len(w.exactHosts) } diff --git a/internal/imgcache/whitelist_test.go b/internal/imgcache/whitelist_test.go index 3e33b66..d575717 100644 --- a/internal/imgcache/whitelist_test.go +++ b/internal/imgcache/whitelist_test.go @@ -31,41 +31,41 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) { want: false, }, { - name: "suffix match", - patterns: []string{".example.com"}, + name: "no suffix matching for subdomains", + patterns: []string{"example.com"}, testURL: "https://cdn.example.com/image.jpg", - want: true, + want: false, }, { - name: "suffix match deep subdomain", - patterns: []string{".example.com"}, - testURL: "https://cdn.images.example.com/image.jpg", - want: true, - }, - { - name: "suffix match apex domain", + name: "leading dot stripped to exact match", patterns: []string{".example.com"}, testURL: "https://example.com/image.jpg", want: true, }, { - name: "suffix match not found", + name: "leading dot does not enable suffix matching", patterns: []string{".example.com"}, - testURL: "https://notexample.com/image.jpg", + testURL: "https://cdn.example.com/image.jpg", want: false, }, { - name: "suffix match partial not allowed", + name: "leading dot does not match deep subdomain", patterns: []string{".example.com"}, - testURL: "https://fakeexample.com/image.jpg", + testURL: "https://cdn.images.example.com/image.jpg", want: false, }, { - name: "multiple patterns", - patterns: []string{"cdn.example.com", ".images.org", "static.test.net"}, + name: "multiple patterns exact only", + patterns: []string{"cdn.example.com", "photos.images.org", "static.test.net"}, testURL: "https://photos.images.org/image.jpg", want: true, }, + { + name: "multiple patterns no suffix leak", + patterns: []string{"cdn.example.com", "images.org"}, + testURL: "https://photos.images.org/image.jpg", + want: false, + }, { name: "empty whitelist", patterns: []string{}, @@ -86,7 +86,7 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) { }, { name: "whitespace in patterns", - patterns: []string{" cdn.example.com ", " .other.com "}, + patterns: []string{" cdn.example.com ", " other.com "}, testURL: "https://cdn.example.com/image.jpg", want: true, }, @@ -139,6 +139,11 @@ func TestHostWhitelist_IsEmpty(t *testing.T) { patterns: []string{"example.com"}, want: false, }, + { + name: "leading dot normalised to entry", + patterns: []string{".example.com"}, + want: false, + }, } for _, tt := range tests { @@ -168,14 +173,14 @@ func TestHostWhitelist_Count(t *testing.T) { want: 3, }, { - name: "suffix hosts only", + name: "leading dots normalised to exact", patterns: []string{".a.com", ".b.com"}, want: 2, }, { - name: "mixed", - patterns: []string{"exact.com", ".suffix.com"}, - want: 2, + name: "mixed deduplication", + patterns: []string{"example.com", ".example.com"}, + want: 1, }, }