Make the HSTS max-age configurable with HSTS_MAX_AGE (closes #271)
check / check (push) Failing after 5m21s

HSTS_MAX_AGE, a Go duration, sets the max-age of the
Strict-Transport-Security header. Unset, it is ten years (87600h);
0 sends no header at all. A negative value, or one that is not a
whole number of seconds, stops startup like an unparseable one.
includeSubDomains and preload stay in the header. The value is shown
on /settings and in the startup log, and the README's HSTS section
now says what each part of the header commits the operator to.

Model: opus-5-5
This commit is contained in:
2026-10-12 22:18:40 +00:00
parent 128eb1b644
commit b5e16fdf20
7 changed files with 241 additions and 24 deletions
+40 -18
View File
@@ -141,6 +141,7 @@ security headers are always applied.
| `SENTRY_DSN` | Sentry error reporting DSN. Unset leaves error reporting off; a value the Sentry SDK cannot parse fails startup rather than serving with reporting silently off | `""` |
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive). A value that does not parse, or is zero or negative, fails startup | `1h` |
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
| `HSTS_MAX_AGE` | `max-age` of the `Strict-Transport-Security` header, which also carries `includeSubDomains` and `preload` (Go duration, whole seconds). `0` sends no header. Read [HSTS](#hsts) before changing it | `87600h` (ten years) |
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
@@ -483,9 +484,10 @@ crash the reaper after startup), every entry in `TRUSTED_PROXIES` and
`ALLOWED_EGRESS_CIDRS` must be a CIDR block or a bare IP address, and
`BIND_ADDRESS` must be an IP address literal — `localhost`, `127.0.0.1:8080` and
`10.0.0.0/8` are each rejected rather than resolved, split, or narrowed to
something they do not say — and `SENTRY_DSN` must parse as a Sentry DSN.
`SESSION_IDLE_TIMEOUT` is the exception: a non-positive value there means idle
expiry is disabled, not invalid.
something they do not say — `SENTRY_DSN` must parse as a Sentry DSN, and
`HSTS_MAX_AGE` must be a whole number of seconds, zero or more (`1.5s` and `-1h`
are rejected). `SESSION_IDLE_TIMEOUT` is the exception: a non-positive value
there means idle expiry is disabled, not invalid.
`SENTRY_DSN` is checked with the Sentry SDK's own parser, the same call the SDK
makes on the DSN it is later handed, so what configuration accepts is exactly
@@ -882,17 +884,37 @@ If nginx runs on another host, `BIND_ADDRESS` becomes the address it connects
to, `TRUSTED_PROXIES` becomes nginx's address, and the port must be firewalled
to that address — the traffic between them is cleartext.
### HSTS is always sent, and is not configurable
### HSTS
Every response carries
`Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`. Two
years, every subdomain, and a `preload` token. There is no setting that changes
or suppresses it.
Every response carries a `Strict-Transport-Security` header unless
`HSTS_MAX_AGE` is `0`. With the default `HSTS_MAX_AGE` of `87600h` (ten years)
it is:
This is worth knowing before the first request reaches a browser: a client that
sees it once will refuse plaintext HTTP to that hostname — and to every
subdomain of it — for two years, whatever else is served there. Terminate TLS on
a hostname you are prepared to keep on HTTPS.
```
Strict-Transport-Security: max-age=315360000; includeSubDomains; preload
```
`HSTS_MAX_AGE` sets `max-age`, in seconds. `includeSubDomains` and `preload` are
always part of the header. A browser that sees the header refuses plaintext HTTP
to that hostname until `max-age` runs out, whatever else is served there, so
terminate TLS on a hostname you are prepared to keep on HTTPS.
`includeSubDomains` extends this to every subdomain of the hostname webhooker is
served at. Served at `hooks.example.com`, it covers every name under
`hooks.example.com`; served at `example.com`, every name under `example.com`.
`preload` invites browsers to put the domain on their built-in HSTS preload
lists. The token alone puts nothing there. A domain is listed after someone
submits it at hstspreload.org, which accepts only a whole registered domain such
as `example.com`, not `hooks.example.com`. A listed domain is HTTPS-only in
every browser that ships the list, even for users who have never visited it, and
removal takes months to reach those browsers, so treat a listing as permanent.
Setting `HSTS_MAX_AGE=0` later stops the header being sent, but a browser that
already saw it does not forget it. That browser keeps refusing plaintext HTTP
until the `max-age` it saw runs out. Only a `max-age=0` header received over
HTTPS clears it sooner. webhooker never sends one; a reverse proxy in front of
it can.
## Backup, Restore, and Upgrades
@@ -3132,9 +3154,9 @@ any database — it only writes to record results.
Applied to all routes in this order:
1. **RequestID** — Generate unique request IDs (chi built-in)
2. **SecurityHeaders** — Production security headers on every response (HSTS,
X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy,
Permissions-Policy)
2. **SecurityHeaders** — Production security headers on every response (HSTS
unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP,
Referrer-Policy, Permissions-Policy)
3. **Logging** — Structured request logging (method, URL, status, latency,
remote IP, client IP, user agent, request ID)
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
@@ -3215,9 +3237,9 @@ credential check, see [The login endpoint](#the-login-endpoint).
- Session regeneration on login to prevent session fixation attacks
- Session key is a 32-byte value auto-generated on first startup and stored in
the database
- Production security headers on all responses: HSTS, X-Content-Type-Options
(`nosniff`), X-Frame-Options (`DENY`), Content-Security-Policy,
Referrer-Policy, and Permissions-Policy
- Production security headers on all responses: HSTS (unless `HSTS_MAX_AGE` is
`0`), X-Content-Type-Options (`nosniff`), X-Frame-Options (`DENY`),
Content-Security-Policy, Referrer-Policy, and Permissions-Policy
- Request body size limits (1 MB) on all form POST endpoints, enforced by
middleware that runs before CSRF parses the form
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all