diff --git a/README.md b/README.md index 0906560..dda724b 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ everything in this list without further settings. The reverse proxy must: Routes); - 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`, - 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 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 @@ -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 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 - the upstream host, or a host it redirects to, is `localhost`, ends in - `.localhost` or `.local`, or has an address in a blocked network (see - `blocked_networks`); 502 when the upstream answered with an error 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. + the request's `Referer` names a host in `referer_blocklist`, checked before + anything else; 403 when the upstream host, or a host it redirects to, is + `localhost`, ends in `.localhost` or `.local`, or has an address in a blocked + network (see `blocked_networks`); 502 when the upstream answered with an error + 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//` — an image through an encrypted URL (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 @@ -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_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_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_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` | @@ -456,6 +459,17 @@ Key settings in more detail: 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 - `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, added to the always-enforced built-in ranges (loopback, private, link-local, CGNAT, benchmark, NAT64, and the like); an invalid CIDR diff --git a/TODO.md b/TODO.md index 18d33b4..8840ea1 100644 --- a/TODO.md +++ b/TODO.md @@ -27,7 +27,7 @@ The disk cache is now size-bounded with LRU eviction # Next Step -P2: security: referer blacklist +P2: security: per-IP rate limiting on the image routes # Completed Steps @@ -47,6 +47,16 @@ P2: security: referer blacklist 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 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` only passes through a periodic pass (closes #189): it slept for three eviction intervals before writing its file, and a startup pass still running @@ -561,7 +571,6 @@ P2: security: referer blacklist # Future Steps - P2: security - - per-IP rate limiting on the image routes - per-origin rate limiting - P2: HTTP response handling - Last-Modified headers diff --git a/configs/config.example.yml b/configs/config.example.yml index 098d936..3bcef54 100644 --- a/configs/config.example.yml +++ b/configs/config.example.yml @@ -53,6 +53,16 @@ allowlist_hosts: - github.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 # SSRF protection. These are added to the always-enforced built-in ranges # (loopback, RFC 1918 private, link-local, CGNAT, benchmark, NAT64, and diff --git a/internal/config/config.go b/internal/config/config.go index 98addb0..a68f888 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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)) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 489e3c3..a65f3b5 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -9,6 +9,7 @@ import ( "time" "go.uber.org/fx" + "sneak.berlin/go/pixa/internal/allowlist" "sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/encurl" @@ -40,6 +41,10 @@ type Handlers struct { sessMgr *session.Manager encGen *encurl.Generator 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. @@ -50,11 +55,12 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) { } s := &Handlers{ - log: params.Logger.Get(), - hc: params.Healthcheck, - db: params.Database, - config: params.Config, - csrfProtect: csrfProtect, + log: params.Logger.Get(), + hc: params.Healthcheck, + db: params.Database, + config: params.Config, + csrfProtect: csrfProtect, + refererBlocklist: allowlist.New(params.Config.RefererBlocklist), } lc.Append(fx.Hook{ diff --git a/internal/handlers/image.go b/internal/handlers/image.go index 1a46a58..e099799 100644 --- a/internal/handlers/image.go +++ b/internal/handlers/image.go @@ -21,6 +21,10 @@ import ( // /v1/image///x. func (s *Handlers) HandleImage() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + if s.refuseBlockedReferer(w, r) { + return + } + req, ok := s.parseImageRequest(w, r) if !ok { return @@ -248,6 +252,23 @@ func cacheControl(expires time.Time) string { 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 // If-None-Match is that ETag, answers 304 Not Modified. It reports whether it // answered. An empty etag sets no header and never answers. diff --git a/internal/handlers/imageenc.go b/internal/handlers/imageenc.go index dadb6bd..a9c672f 100644 --- a/internal/handlers/imageenc.go +++ b/internal/handlers/imageenc.go @@ -22,6 +22,10 @@ import ( // browsers identify the content type. func (s *Handlers) HandleImageEnc() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + if s.refuseBlockedReferer(w, r) { + return + } + ctx := r.Context() start := time.Now()