Refuse image requests whose Referer is on referer_blocklist (closes #90)

A new setting, referer_blocklist (PIXA_REFERER_BLOCKLIST), lists hosts
written and matched as allowlist_hosts are, with the same matcher and the
same entry check; a bad entry aborts startup naming the setting and the
entry. Both image routes check the Referer first and answer 403 with the
JSON error, so a blocked request fetches nothing and is refused whether or
not the image is cached. No Referer, or one that does not parse as a URL
with a host, is served; README.md and config.example.yml say this makes the
list easy to get around. The CIDR-list entry reader is renamed listEntries
now that host lists use it too.

Model: opus-5-5
This commit is contained in:
2026-10-04 19:08:30 +00:00
parent b2c6bc91d0
commit c9a8b926db
7 changed files with 141 additions and 34 deletions
+64 -21
View File
@@ -48,6 +48,7 @@ const (
keyMetricsPassword = "metrics.password"
keySigningKey = "signing_key"
keyAllowlistHosts = "allowlist_hosts"
keyRefererBlocklist = "referer_blocklist"
keyAllowHTTP = "allow_http"
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
keyUpstreamConnections = "upstream_connections"
@@ -130,6 +131,10 @@ type Config struct {
AllowHTTP bool // Allow non-TLS upstream (testing only)
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
// RefererBlocklist holds host patterns, matched as AllowlistHosts is: the
// image routes refuse a request whose Referer names a matching host.
RefererBlocklist []string
// UpstreamConnections is the most concurrent connections to all
// upstream hosts together, on top of the per-host limit.
// MaxConcurrentProcessing is the most images processed at once.
@@ -261,6 +266,11 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
return nil, err
}
refererBlocklist, err := parseHostList(sc, keyRefererBlocklist)
if err != nil {
return nil, err
}
// parseCIDRList returns a nil slice only when the key is absent; an
// explicitly empty list ([]) comes back non-nil and empty. An omitted
// key takes the RFC 1918 default, while an explicit empty list is left
@@ -298,9 +308,10 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
DownstreamTimeout: loader.durationVal(
keyDownstreamTimeout, DefaultDownstreamTimeout),
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
BlockedNetworks: blockedNetworks,
TrustedProxies: trustedProxies,
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
BlockedNetworks: blockedNetworks,
TrustedProxies: trustedProxies,
RefererBlocklist: refererBlocklist,
}
// The default for an omitted cache_max_bytes is worked out when
@@ -420,7 +431,8 @@ func isKnownConfigKey(key string) bool {
keyUpstreamConnectionsPerHost, keyUpstreamConnections,
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
keyUpstreamMaxResponseSize, keyDownstreamTimeout, "env":
keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist,
"env":
return true
}
@@ -443,6 +455,7 @@ func envVarNames() map[string]string {
keyMetricsPassword: "PIXA_METRICS_PASSWORD",
keySigningKey: "PIXA_SIGNING_KEY",
keyAllowlistHosts: "PIXA_ALLOWLIST_HOSTS",
keyRefererBlocklist: "PIXA_REFERER_BLOCKLIST",
keyAllowHTTP: "PIXA_ALLOW_HTTP",
keyUpstreamConnectionsPerHost: "PIXA_UPSTREAM_CONNECTIONS_PER_HOST",
keyUpstreamConnections: "PIXA_UPSTREAM_CONNECTIONS",
@@ -612,7 +625,7 @@ func (c *Config) validate() error {
}
for _, host := range c.AllowlistHosts {
err := validateAllowlistHost(host)
err := validateHostPattern(keyAllowlistHosts, host)
if err != nil {
return err
}
@@ -735,22 +748,22 @@ func (c *Config) validateConcurrencyLimits() error {
return nil
}
// validateAllowlistHost checks that an allowlist_hosts entry is a bare
// hostname, optionally with a leading dot for suffix matching. URLs,
// paths, and whitespace indicate a misconfigured entry. An entry with
// no hostname labels (such as ".") is rejected: the allowlist matcher
// treats a leading dot as a suffix pattern, so a bare "." would match
// any upstream host written in FQDN trailing-dot form and effectively
// disable URL signing.
func validateAllowlistHost(host string) error {
// validateHostPattern checks that an entry of the named key, allowlist_hosts
// or referer_blocklist, is a bare hostname, optionally with a leading dot for
// suffix matching. URLs, paths, and whitespace indicate a misconfigured entry.
// An entry with no hostname labels (such as ".") is rejected: the allowlist
// matcher treats a leading dot as a suffix pattern, so a bare "." would match
// any host written in FQDN trailing-dot form, and in allowlist_hosts
// effectively disable URL signing.
func validateHostPattern(key, host string) error {
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
return fmt.Errorf("%s: entry %q %w",
settingName(keyAllowlistHosts), host, errNotBareHostname)
settingName(key), host, errNotBareHostname)
}
if strings.Trim(host, ".") == "" {
return fmt.Errorf("%s: entry %q %w",
settingName(keyAllowlistHosts), host, errNoHostnameLabels)
settingName(key), host, errNoHostnameLabels)
}
return nil
@@ -1180,7 +1193,7 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
return nil, errNullConfigValue(key)
}
entries, err := cidrListEntries(raw, key)
entries, err := listEntries(raw, key)
if err != nil {
return nil, err
}
@@ -1200,11 +1213,41 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
return prefixes, nil
}
// cidrListEntries extracts the raw entries of the named CIDR-list key as
// trimmed, non-empty strings, from either a YAML list of strings or a
// comma-separated string; an empty string is an empty list, as for
// allowlist_hosts. Any other shape is a configuration error.
func cidrListEntries(raw any, key string) ([]string, error) {
// parseHostList parses the value of the named config key into host patterns,
// or returns nil if the key is omitted. It accepts a YAML list of strings or a
// comma-separated string. An explicitly null value, a wrong type, an empty
// entry, a non-string entry, or an entry validateHostPattern rejects aborts
// startup naming the key and the offending value.
func parseHostList(sc *smartconfig.Config, key string) ([]string, error) {
raw, ok := lookupValue(sc, key)
if !ok {
return nil, nil
}
if raw == nil {
return nil, errNullConfigValue(key)
}
entries, err := listEntries(raw, key)
if err != nil {
return nil, err
}
for _, entry := range entries {
err := validateHostPattern(key, entry)
if err != nil {
return nil, err
}
}
return entries, nil
}
// listEntries extracts the raw entries of the named list key as trimmed,
// non-empty strings, from either a YAML list of strings or a comma-separated
// string; an empty string is an empty list, as for allowlist_hosts. Any other
// shape is a configuration error.
func listEntries(raw any, key string) ([]string, error) {
switch val := raw.(type) {
case []any:
entries := make([]string, 0, len(val))