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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user