1 Commits

Author SHA1 Message Date
user
e241b99d22 remove suffix matching from host whitelist
All checks were successful
check / check (push) Successful in 1m50s
Signatures are per-URL, so the whitelist should only support exact host
matches. Remove the suffix/wildcard matching that allowed patterns like
'.example.com' to bypass signature requirements for entire domain trees.

Leading dots in existing config entries are now stripped, so '.example.com'
becomes 'example.com' as an exact match (backwards-compatible normalisation).
2026-03-17 01:55:19 -07:00
3 changed files with 34 additions and 53 deletions

View File

@@ -96,9 +96,10 @@ expiration 1704067200:
4. URL: 4. URL:
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200` `/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
**Whitelist patterns:** **Whitelist entries** are exact host matches only (e.g.
`cdn.example.com`). Suffix/wildcard matching is not supported —
- **Exact match only**: `cdn.example.com` — matches only that host signatures are per-URL, so each allowed host must be listed
explicitly.
### Configuration ### Configuration

View File

@@ -5,20 +5,20 @@ import (
"strings" "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. Leading dots in patterns are stripped // Only exact host matches are supported. Signatures are per-URL, so
// (e.g. ".example.com" becomes an exact match for "example.com"). // wildcard/suffix matching is intentionally not provided.
type HostWhitelist struct { type HostWhitelist struct {
// hosts contains hosts that must match exactly (e.g., "cdn.example.com") // exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
hosts map[string]struct{} exactHosts map[string]struct{}
} }
// NewHostWhitelist creates a whitelist from a list of host patterns. // NewHostWhitelist creates a whitelist from a list of hostnames.
// All patterns are treated as exact matches. Leading dots are stripped // Each entry is treated as an exact host match. Leading dots are
// for backwards compatibility (e.g. ".example.com" matches "example.com" only). // stripped so that legacy ".example.com" entries become "example.com".
func NewHostWhitelist(patterns []string) *HostWhitelist { func NewHostWhitelist(patterns []string) *HostWhitelist {
w := &HostWhitelist{ w := &HostWhitelist{
hosts: make(map[string]struct{}), exactHosts: make(map[string]struct{}),
} }
for _, pattern := range patterns { for _, pattern := range patterns {
@@ -27,22 +27,19 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
continue continue
} }
// Strip leading dot — suffix matching is not supported. // Strip leading dot — suffix matching is no longer supported;
// ".example.com" is treated as exact match for "example.com". // ".example.com" is normalised to "example.com" as an exact entry.
pattern = strings.TrimPrefix(pattern, ".") pattern = strings.TrimPrefix(pattern, ".")
if pattern == "" { if pattern != "" {
continue w.exactHosts[pattern] = struct{}{}
} }
w.hosts[pattern] = struct{}{}
} }
return w 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).
// Only exact host matches are supported.
func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool { func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
if u == nil { if u == nil {
return false return false
@@ -53,17 +50,17 @@ func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
return false return false
} }
_, ok := w.hosts[host] _, ok := w.exactHosts[host]
return ok return ok
} }
// IsEmpty returns true if the whitelist has no entries. // IsEmpty returns true if the whitelist has no entries.
func (w *HostWhitelist) IsEmpty() bool { func (w *HostWhitelist) IsEmpty() bool {
return len(w.hosts) == 0 return len(w.exactHosts) == 0
} }
// Count returns the total number of whitelist entries. // Count returns the total number of whitelist entries.
func (w *HostWhitelist) Count() int { func (w *HostWhitelist) Count() int {
return len(w.hosts) return len(w.exactHosts)
} }

View File

@@ -31,33 +31,27 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
want: false, want: false,
}, },
{ {
name: "dot prefix does not enable suffix matching", name: "no suffix matching for subdomains",
patterns: []string{".example.com"}, patterns: []string{"example.com"},
testURL: "https://cdn.example.com/image.jpg", testURL: "https://cdn.example.com/image.jpg",
want: false, want: false,
}, },
{ {
name: "dot prefix does not match deep subdomain", name: "leading dot stripped to exact match",
patterns: []string{".example.com"},
testURL: "https://cdn.images.example.com/image.jpg",
want: false,
},
{
name: "dot prefix stripped matches apex domain exactly",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://example.com/image.jpg", testURL: "https://example.com/image.jpg",
want: true, want: true,
}, },
{ {
name: "dot prefix does not match unrelated domain", name: "leading dot does not enable suffix matching",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://notexample.com/image.jpg", testURL: "https://cdn.example.com/image.jpg",
want: false, want: false,
}, },
{ {
name: "dot prefix does not match partial domain", name: "leading dot does not match deep subdomain",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://fakeexample.com/image.jpg", testURL: "https://cdn.images.example.com/image.jpg",
want: false, want: false,
}, },
{ {
@@ -67,8 +61,8 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
want: true, want: true,
}, },
{ {
name: "multiple patterns no suffix match", name: "multiple patterns no suffix leak",
patterns: []string{"cdn.example.com", ".images.org", "static.test.net"}, patterns: []string{"cdn.example.com", "images.org"},
testURL: "https://photos.images.org/image.jpg", testURL: "https://photos.images.org/image.jpg",
want: false, want: false,
}, },
@@ -92,16 +86,10 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
}, },
{ {
name: "whitespace in patterns", 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", testURL: "https://cdn.example.com/image.jpg",
want: true, want: true,
}, },
{
name: "whitespace dot prefix stripped matches exactly",
patterns: []string{" .other.com "},
testURL: "https://other.com/image.jpg",
want: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -152,7 +140,7 @@ func TestHostWhitelist_IsEmpty(t *testing.T) {
want: false, want: false,
}, },
{ {
name: "dot prefix entry still counts", name: "leading dot normalised to entry",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
want: false, want: false,
}, },
@@ -185,17 +173,12 @@ func TestHostWhitelist_Count(t *testing.T) {
want: 3, want: 3,
}, },
{ {
name: "dot prefix hosts treated as exact", name: "leading dots normalised to exact",
patterns: []string{".a.com", ".b.com"}, patterns: []string{".a.com", ".b.com"},
want: 2, want: 2,
}, },
{ {
name: "mixed", name: "mixed deduplication",
patterns: []string{"exact.com", ".suffix.com"},
want: 2,
},
{
name: "dot prefix deduplicates with exact",
patterns: []string{"example.com", ".example.com"}, patterns: []string{"example.com", ".example.com"},
want: 1, want: 1,
}, },