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
+20 -6
View File
@@ -45,7 +45,8 @@ everything in this list without further settings. The reverse proxy must:
Routes); Routes);
- pass the `Host`, `Origin` and `Referer` headers on unchanged, as pixa refuses - pass the `Host`, `Origin` and `Referer` headers on unchanged, as pixa refuses
a form from those pages unless `Origin` or `Referer` names the host in `Host`, a form from those pages unless `Origin` or `Referer` names the host in `Host`,
and builds encrypted URLs from `Host`; builds encrypted URLs from `Host`, and checks `Referer` against
`referer_blocklist`;
- set `X-Forwarded-For` to the client's address, with `trusted_proxies` set to - set `X-Forwarded-For` to the client's address, with `trusted_proxies` set to
the address pixa sees the proxy's requests come from, so the login limit the address pixa sees the proxy's requests come from, so the login limit
counts each client by its own address (see `trusted_proxies` under counts each client by its own address (see `trusted_proxies` under
@@ -175,11 +176,12 @@ path under `/v1/` answers 200, in maintenance mode too.
allowlisted (see Source Hosts). Answers: 200; 304 when `If-None-Match` matches allowlisted (see Source Hosts). Answers: 200; 304 when `If-None-Match` matches
the image's `ETag`; 400 for a URL or parameter that is not valid; 401 for a the image's `ETag`; 400 for a URL or parameter that is not valid; 401 for a
missing or wrong signature, a missing `exp` or an `exp` in the past; 403 when missing or wrong signature, a missing `exp` or an `exp` in the past; 403 when
the upstream host, or a host it redirects to, is `localhost`, ends in the request's `Referer` names a host in `referer_blocklist`, checked before
`.localhost` or `.local`, or has an address in a blocked network (see anything else; 403 when the upstream host, or a host it redirects to, is
`blocked_networks`); 502 when the upstream answered with an error status, and `localhost`, ends in `.localhost` or `.local`, or has an address in a blocked
for 5 minutes after that for the same source URL; 503 when pixa is busy or in network (see `blocked_networks`); 502 when the upstream answered with an error
maintenance mode; 500 for any other failure. status, and for 5 minutes after that for the same source URL; 503 when pixa is
busy or in maintenance mode; 500 for any other failure.
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL - `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
(see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when (see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when
`If-None-Match` matches the image's `ETag`; 400 for a token that does not `If-None-Match` matches the image's `ETag`; 400 for a token that does not
@@ -428,6 +430,7 @@ tell. With no file, pixa uses the environment and the defaults.
| `PIXA_DB_URL` | `db_url` | SQLite database URL; default `state.sqlite3` in the state directory | | `PIXA_DB_URL` | `db_url` | SQLite database URL; default `state.sqlite3` in the state directory |
| `PIXA_CACHE_MAX_BYTES` | `cache_max_bytes` | Disk cache limit in bytes; `0` disables it; default 75% of (free + cached) | | `PIXA_CACHE_MAX_BYTES` | `cache_max_bytes` | Disk cache limit in bytes; `0` disables it; default 75% of (free + cached) |
| `PIXA_ALLOWLIST_HOSTS` | `allowlist_hosts` | Upstream hosts served without a signature | | `PIXA_ALLOWLIST_HOSTS` | `allowlist_hosts` | Upstream hosts served without a signature |
| `PIXA_REFERER_BLOCKLIST` | `referer_blocklist` | Hosts whose pages the image routes refuse with 403, by `Referer` |
| `PIXA_BLOCKED_NETWORKS` | `blocked_networks` | CIDR ranges never fetched from, on top of the built-in ones | | `PIXA_BLOCKED_NETWORKS` | `blocked_networks` | CIDR ranges never fetched from, on top of the built-in ones |
| `PIXA_TRUSTED_PROXIES` | `trusted_proxies` | CIDR ranges of proxies whose `X-Forwarded-For` is believed; default RFC 1918 | | `PIXA_TRUSTED_PROXIES` | `trusted_proxies` | CIDR ranges of proxies whose `X-Forwarded-For` is believed; default RFC 1918 |
| `PIXA_ALLOW_HTTP` | `allow_http` | Allow plain-HTTP upstreams, for testing only; default `false` | | `PIXA_ALLOW_HTTP` | `allow_http` | Allow plain-HTTP upstreams, for testing only; default `false` |
@@ -456,6 +459,17 @@ Key settings in more detail:
that has no leading zero and is not the scheme's default. Any other value, that has no leading zero and is not the scheme's default. Any other value,
including another scheme such as a browser extension's, aborts startup including another scheme such as a browser extension's, aborts startup
- `allowlist_hosts` — list of allowed upstream hosts - `allowlist_hosts` — list of allowed upstream hosts
- `referer_blocklist` — list of hosts whose pages may not show pixa's images, to
stop other sites hotlinking them. Entries are written and matched as for
`allowlist_hosts` (see Allowlist patterns); one that is not a bare host aborts
startup. A request to `/v1/image/` or `/v1/e/` whose `Referer` header names a
listed host is refused with 403 before anything else is done for it, so it
fetches nothing from the upstream host, and it is refused even when the image
is cached. A request with no `Referer`, or one that does not parse as a URL
with a host, is served, as many clients send none. So this is easily got
around: a site whose pages send no `Referer` (for example with
`Referrer-Policy: no-referrer`) is not stopped. It does not apply to the login
and generator pages. Default: empty
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection, - `blocked_networks` — list of CIDR ranges to refuse for SSRF protection,
added to the always-enforced built-in ranges (loopback, private, added to the always-enforced built-in ranges (loopback, private,
link-local, CGNAT, benchmark, NAT64, and the like); an invalid CIDR link-local, CGNAT, benchmark, NAT64, and the like); an invalid CIDR
+11 -2
View File
@@ -27,7 +27,7 @@ The disk cache is now size-bounded with LRU eviction
# Next Step # Next Step
P2: security: referer blacklist P2: security: per-IP rate limiting on the image routes
# Completed Steps # Completed Steps
@@ -47,6 +47,16 @@ P2: security: referer blacklist
for it, and the default `db_url` turns on WAL mode with for it, and the default `db_url` turns on WAL mode with
`_pragma=journal_mode(WAL)`. The old default's `_journal_mode=WAL` is not a `_pragma=journal_mode(WAL)`. The old default's `_journal_mode=WAL` is not a
parameter the driver reads, so the database was never in WAL mode. parameter the driver reads, so the database was never in WAL mode.
- 2026-10-04 referer blocklist (closes #90): `referer_blocklist`
(`PIXA_REFERER_BLOCKLIST`) lists hosts, written and matched as for
`allowlist_hosts` with the same matcher; an entry that is not a bare host
aborts startup naming the setting and the entry. Both image routes refuse a
request whose `Referer` names a listed host with 403 and a JSON error before
anything else is done for it, so it fetches nothing and is refused whether or
not the image is cached. A request with no `Referer`, or one that does not
parse as a URL with a host, is served, so the list is easily got around;
`README.md` and `config.example.yml` say so. It does not apply to the login
and generator pages.
- 2026-10-04 `TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup` - 2026-10-04 `TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup`
only passes through a periodic pass (closes #189): it slept for three only passes through a periodic pass (closes #189): it slept for three
eviction intervals before writing its file, and a startup pass still running eviction intervals before writing its file, and a startup pass still running
@@ -561,7 +571,6 @@ P2: security: referer blacklist
# Future Steps # Future Steps
- P2: security - P2: security
- per-IP rate limiting on the image routes
- per-origin rate limiting - per-origin rate limiting
- P2: HTTP response handling - P2: HTTP response handling
- Last-Modified headers - Last-Modified headers
+10
View File
@@ -53,6 +53,16 @@ allowlist_hosts:
- github.com - github.com
- user-images.githubusercontent.com - user-images.githubusercontent.com
# Hosts whose pages may not show pixa's images, written as for
# allowlist_hosts. A request to /v1/image/ or /v1/e/ whose Referer header
# names one of them is answered 403 before anything is fetched, even when
# the image is cached. A request with no Referer, or one that does not
# parse, is served, so a site whose pages send no Referer is not stopped.
# An entry that is not a host aborts startup. (default: none)
# referer_blocklist:
# - leech.example
# - .hotlinker.example
# Additional CIDR ranges to refuse when fetching upstream, extending the # Additional CIDR ranges to refuse when fetching upstream, extending the
# SSRF protection. These are added to the always-enforced built-in ranges # SSRF protection. These are added to the always-enforced built-in ranges
# (loopback, RFC 1918 private, link-local, CGNAT, benchmark, NAT64, and # (loopback, RFC 1918 private, link-local, CGNAT, benchmark, NAT64, and
+64 -21
View File
@@ -48,6 +48,7 @@ const (
keyMetricsPassword = "metrics.password" keyMetricsPassword = "metrics.password"
keySigningKey = "signing_key" keySigningKey = "signing_key"
keyAllowlistHosts = "allowlist_hosts" keyAllowlistHosts = "allowlist_hosts"
keyRefererBlocklist = "referer_blocklist"
keyAllowHTTP = "allow_http" keyAllowHTTP = "allow_http"
keyUpstreamConnectionsPerHost = "upstream_connections_per_host" keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
keyUpstreamConnections = "upstream_connections" keyUpstreamConnections = "upstream_connections"
@@ -130,6 +131,10 @@ type Config struct {
AllowHTTP bool // Allow non-TLS upstream (testing only) AllowHTTP bool // Allow non-TLS upstream (testing only)
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host 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 // UpstreamConnections is the most concurrent connections to all
// upstream hosts together, on top of the per-host limit. // upstream hosts together, on top of the per-host limit.
// MaxConcurrentProcessing is the most images processed at once. // MaxConcurrentProcessing is the most images processed at once.
@@ -261,6 +266,11 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
return nil, err 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 // parseCIDRList returns a nil slice only when the key is absent; an
// explicitly empty list ([]) comes back non-nil and empty. An omitted // explicitly empty list ([]) comes back non-nil and empty. An omitted
// key takes the RFC 1918 default, while an explicit empty list is left // 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), keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
DownstreamTimeout: loader.durationVal( DownstreamTimeout: loader.durationVal(
keyDownstreamTimeout, DefaultDownstreamTimeout), keyDownstreamTimeout, DefaultDownstreamTimeout),
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0), CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
BlockedNetworks: blockedNetworks, BlockedNetworks: blockedNetworks,
TrustedProxies: trustedProxies, TrustedProxies: trustedProxies,
RefererBlocklist: refererBlocklist,
} }
// The default for an omitted cache_max_bytes is worked out when // The default for an omitted cache_max_bytes is worked out when
@@ -420,7 +431,8 @@ func isKnownConfigKey(key string) bool {
keyUpstreamConnectionsPerHost, keyUpstreamConnections, keyUpstreamConnectionsPerHost, keyUpstreamConnections,
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks, keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout, keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
keyUpstreamMaxResponseSize, keyDownstreamTimeout, "env": keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist,
"env":
return true return true
} }
@@ -443,6 +455,7 @@ func envVarNames() map[string]string {
keyMetricsPassword: "PIXA_METRICS_PASSWORD", keyMetricsPassword: "PIXA_METRICS_PASSWORD",
keySigningKey: "PIXA_SIGNING_KEY", keySigningKey: "PIXA_SIGNING_KEY",
keyAllowlistHosts: "PIXA_ALLOWLIST_HOSTS", keyAllowlistHosts: "PIXA_ALLOWLIST_HOSTS",
keyRefererBlocklist: "PIXA_REFERER_BLOCKLIST",
keyAllowHTTP: "PIXA_ALLOW_HTTP", keyAllowHTTP: "PIXA_ALLOW_HTTP",
keyUpstreamConnectionsPerHost: "PIXA_UPSTREAM_CONNECTIONS_PER_HOST", keyUpstreamConnectionsPerHost: "PIXA_UPSTREAM_CONNECTIONS_PER_HOST",
keyUpstreamConnections: "PIXA_UPSTREAM_CONNECTIONS", keyUpstreamConnections: "PIXA_UPSTREAM_CONNECTIONS",
@@ -612,7 +625,7 @@ func (c *Config) validate() error {
} }
for _, host := range c.AllowlistHosts { for _, host := range c.AllowlistHosts {
err := validateAllowlistHost(host) err := validateHostPattern(keyAllowlistHosts, host)
if err != nil { if err != nil {
return err return err
} }
@@ -735,22 +748,22 @@ func (c *Config) validateConcurrencyLimits() error {
return nil return nil
} }
// validateAllowlistHost checks that an allowlist_hosts entry is a bare // validateHostPattern checks that an entry of the named key, allowlist_hosts
// hostname, optionally with a leading dot for suffix matching. URLs, // or referer_blocklist, is a bare hostname, optionally with a leading dot for
// paths, and whitespace indicate a misconfigured entry. An entry with // suffix matching. URLs, paths, and whitespace indicate a misconfigured entry.
// no hostname labels (such as ".") is rejected: the allowlist matcher // An entry with no hostname labels (such as ".") is rejected: the allowlist
// treats a leading dot as a suffix pattern, so a bare "." would match // 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 // any host written in FQDN trailing-dot form, and in allowlist_hosts
// disable URL signing. // effectively disable URL signing.
func validateAllowlistHost(host string) error { func validateHostPattern(key, host string) error {
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") { if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
return fmt.Errorf("%s: entry %q %w", return fmt.Errorf("%s: entry %q %w",
settingName(keyAllowlistHosts), host, errNotBareHostname) settingName(key), host, errNotBareHostname)
} }
if strings.Trim(host, ".") == "" { if strings.Trim(host, ".") == "" {
return fmt.Errorf("%s: entry %q %w", return fmt.Errorf("%s: entry %q %w",
settingName(keyAllowlistHosts), host, errNoHostnameLabels) settingName(key), host, errNoHostnameLabels)
} }
return nil return nil
@@ -1180,7 +1193,7 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
return nil, errNullConfigValue(key) return nil, errNullConfigValue(key)
} }
entries, err := cidrListEntries(raw, key) entries, err := listEntries(raw, key)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1200,11 +1213,41 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
return prefixes, nil return prefixes, nil
} }
// cidrListEntries extracts the raw entries of the named CIDR-list key as // parseHostList parses the value of the named config key into host patterns,
// trimmed, non-empty strings, from either a YAML list of strings or a // or returns nil if the key is omitted. It accepts a YAML list of strings or a
// comma-separated string; an empty string is an empty list, as for // comma-separated string. An explicitly null value, a wrong type, an empty
// allowlist_hosts. Any other shape is a configuration error. // entry, a non-string entry, or an entry validateHostPattern rejects aborts
func cidrListEntries(raw any, key string) ([]string, error) { // 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) { switch val := raw.(type) {
case []any: case []any:
entries := make([]string, 0, len(val)) entries := make([]string, 0, len(val))
+11 -5
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
@@ -40,6 +41,10 @@ type Handlers struct {
sessMgr *session.Manager sessMgr *session.Manager
encGen *encurl.Generator encGen *encurl.Generator
csrfProtect func(http.Handler) http.Handler csrfProtect func(http.Handler) http.Handler
// refererBlocklist matches the hosts of referer_blocklist; its IsAllowed
// reports whether a URL's host is on that list.
refererBlocklist *allowlist.HostAllowList
} }
// New creates a new Handlers instance. // New creates a new Handlers instance.
@@ -50,11 +55,12 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
} }
s := &Handlers{ s := &Handlers{
log: params.Logger.Get(), log: params.Logger.Get(),
hc: params.Healthcheck, hc: params.Healthcheck,
db: params.Database, db: params.Database,
config: params.Config, config: params.Config,
csrfProtect: csrfProtect, csrfProtect: csrfProtect,
refererBlocklist: allowlist.New(params.Config.RefererBlocklist),
} }
lc.Append(fx.Hook{ lc.Append(fx.Hook{
+21
View File
@@ -21,6 +21,10 @@ import (
// /v1/image/<host>/<path>/<width>x<height>.<format> // /v1/image/<host>/<path>/<width>x<height>.<format>
func (s *Handlers) HandleImage() http.HandlerFunc { func (s *Handlers) HandleImage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if s.refuseBlockedReferer(w, r) {
return
}
req, ok := s.parseImageRequest(w, r) req, ok := s.parseImageRequest(w, r)
if !ok { if !ok {
return return
@@ -248,6 +252,23 @@ func cacheControl(expires time.Time) string {
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second)) return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
} }
// refuseBlockedReferer answers 403 with a JSON error when the request's Referer
// names a host on referer_blocklist, and reports whether it answered. A request
// with no Referer, or one that does not parse as a URL with a host, is not
// refused.
func (s *Handlers) refuseBlockedReferer(
w http.ResponseWriter, r *http.Request,
) bool {
referer, err := url.Parse(r.Referer())
if err != nil || !s.refererBlocklist.IsAllowed(referer) {
return false
}
s.respondError(w, "referer blocked", http.StatusForbidden)
return true
}
// notModified sets the ETag header to etag and, when the request's // notModified sets the ETag header to etag and, when the request's
// If-None-Match is that ETag, answers 304 Not Modified. It reports whether it // If-None-Match is that ETag, answers 304 Not Modified. It reports whether it
// answered. An empty etag sets no header and never answers. // answered. An empty etag sets no header and never answers.
+4
View File
@@ -22,6 +22,10 @@ import (
// browsers identify the content type. // browsers identify the content type.
func (s *Handlers) HandleImageEnc() http.HandlerFunc { func (s *Handlers) HandleImageEnc() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if s.refuseBlockedReferer(w, r) {
return
}
ctx := r.Context() ctx := r.Context()
start := time.Now() start := time.Now()