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

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 was merged in pull request #515.
This commit is contained in:
2026-10-13 03:27:24 +02:00
parent 9199544999
commit 957de7f99f
7 changed files with 244 additions and 24 deletions
+42 -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 | `""` | | `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` | | `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` | | `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` | | `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) | | `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) | | `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 `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 `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 `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. something they do not say — `SENTRY_DSN` must parse as a Sentry DSN, and
`SESSION_IDLE_TIMEOUT` is the exception: a non-positive value there means idle `HSTS_MAX_AGE` must be a whole number of seconds, zero or more (`1.5s` and `-1h`
expiry is disabled, not invalid. 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 `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 makes on the DSN it is later handed, so what configuration accepts is exactly
@@ -882,17 +884,39 @@ 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, `TRUSTED_PROXIES` becomes nginx's address, and the port must be firewalled
to that address — the traffic between them is cleartext. to that address — the traffic between them is cleartext.
### HSTS is always sent, and is not configurable ### HSTS
Every response carries Every response carries a `Strict-Transport-Security` header unless
`Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`. Two `HSTS_MAX_AGE` is `0`. With the default `HSTS_MAX_AGE` of `87600h` (ten years)
years, every subdomain, and a `preload` token. There is no setting that changes it is:
or suppresses it.
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 Strict-Transport-Security: max-age=315360000; includeSubDomains; preload
subdomain of it — for two years, whatever else is served there. Terminate TLS on ```
a hostname you are prepared to keep on HTTPS.
`HSTS_MAX_AGE` sets `max-age`, in seconds. `includeSubDomains` and `preload` are
always part of the header. A browser that receives the header over HTTPS 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.
Browsers ignore the header when it arrives over plain HTTP.
`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` stops the header being sent, but a browser that already
received it over HTTPS does not forget it: it keeps refusing plaintext HTTP
until the `max-age` it saw runs out. Each header a browser receives over HTTPS
replaces the `max-age` it stored, so to end the policy sooner, set a short
`HSTS_MAX_AGE` such as `1s` and keep it until returning browsers have picked it
up, or have a reverse proxy send `max-age=0`.
## Backup, Restore, and Upgrades ## Backup, Restore, and Upgrades
@@ -3139,9 +3163,9 @@ any database — it only writes to record results.
Applied to all routes in this order: Applied to all routes in this order:
1. **RequestID** — Generate unique request IDs (chi built-in) 1. **RequestID** — Generate unique request IDs (chi built-in)
2. **SecurityHeaders** — Production security headers on every response (HSTS, 2. **SecurityHeaders** — Production security headers on every response (HSTS
X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP,
Permissions-Policy) Referrer-Policy, Permissions-Policy)
3. **Logging** — Structured request logging (method, URL, status, latency, 3. **Logging** — Structured request logging (method, URL, status, latency,
remote IP, client IP, user agent, request ID) remote IP, client IP, user agent, request ID)
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and 4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
@@ -3222,9 +3246,9 @@ credential check, see [The login endpoint](#the-login-endpoint).
- Session regeneration on login to prevent session fixation attacks - Session regeneration on login to prevent session fixation attacks
- Session key is a 32-byte value auto-generated on first startup and stored in - Session key is a 32-byte value auto-generated on first startup and stored in
the database the database
- Production security headers on all responses: HSTS, X-Content-Type-Options - Production security headers on all responses: HSTS (unless `HSTS_MAX_AGE` is
(`nosniff`), X-Frame-Options (`DENY`), Content-Security-Policy, `0`), X-Content-Type-Options (`nosniff`), X-Frame-Options (`DENY`),
Referrer-Policy, and Permissions-Policy Content-Security-Policy, Referrer-Policy, and Permissions-Policy
- Request body size limits (1 MB) on all form POST endpoints, enforced by - Request body size limits (1 MB) on all form POST endpoints, enforced by
middleware that runs before CSRF parses the form middleware that runs before CSRF parses the form
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all - **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all
+40
View File
@@ -68,6 +68,10 @@ const (
// authenticated activity before it expires. // authenticated activity before it expires.
defaultSessionIdleTimeout = 24 * time.Hour defaultSessionIdleTimeout = 24 * time.Hour
// defaultHSTSMaxAge is the max-age of the Strict-Transport-Security
// header when HSTS_MAX_AGE is unset: ten years.
defaultHSTSMaxAge = 87600 * time.Hour
// defaultReceiverRateLimit is the default number of requests // defaultReceiverRateLimit is the default number of requests
// per minute each client IP may send to a single webhook // per minute each client IP may send to a single webhook
// receiver entrypoint. Generous for legitimate webhook // receiver entrypoint. Generous for legitimate webhook
@@ -120,6 +124,10 @@ var ErrInvalidBindAddress = errors.New("invalid bind address")
// the Sentry SDK cannot parse as a DSN. // the Sentry SDK cannot parse as a DSN.
var ErrInvalidSentryDSN = errors.New("invalid Sentry DSN") var ErrInvalidSentryDSN = errors.New("invalid Sentry DSN")
// ErrInvalidHSTSMaxAge is returned when HSTS_MAX_AGE is set to a
// negative duration or one that is not a whole number of seconds.
var ErrInvalidHSTSMaxAge = errors.New("invalid HSTS max-age")
// ErrDotEnvUnreadable is returned when the optional .env file exists // ErrDotEnvUnreadable is returned when the optional .env file exists
// but cannot be read or parsed. A file that is not there is not an // but cannot be read or parsed. A file that is not there is not an
// error; a file that is there and broken is. // error; a file that is there and broken is.
@@ -169,6 +177,10 @@ type Config struct {
// which a session expires. Non-positive disables idle expiry. // which a session expires. Non-positive disables idle expiry.
SessionIdleTimeout time.Duration SessionIdleTimeout time.Duration
// HSTSMaxAge is the max-age of the Strict-Transport-Security
// header. Zero means the header is not sent at all.
HSTSMaxAge time.Duration
// ReceiverRateLimit is the number of requests per minute each // ReceiverRateLimit is the number of requests per minute each
// client IP may send to a single webhook receiver entrypoint. // client IP may send to a single webhook receiver entrypoint.
ReceiverRateLimit int ReceiverRateLimit int
@@ -624,6 +636,27 @@ func resolveEnvironment() (string, error) {
return environment, nil return environment, nil
} }
// resolveHSTSMaxAge reads HSTS_MAX_AGE. Zero is allowed and turns the
// Strict-Transport-Security header off. A fraction of a second is an
// error rather than rounded, because the header carries whole seconds
// and would otherwise say something other than what was set.
func resolveHSTSMaxAge() (time.Duration, error) {
maxAge, err := envDuration("HSTS_MAX_AGE", defaultHSTSMaxAge)
if err != nil {
return 0, err
}
if maxAge < 0 || maxAge%time.Second != 0 {
return 0, fmt.Errorf(
"%w: HSTS_MAX_AGE must be zero or a positive whole "+
"number of seconds, got %s",
ErrInvalidHSTSMaxAge, maxAge,
)
}
return maxAge, nil
}
// resolveListener reads the two variables that describe the HTTP // resolveListener reads the two variables that describe the HTTP
// listener: which port it claims and which address it claims it on. // listener: which port it claims and which address it claims it on.
// They are read together because neither is meaningful alone, and // They are read together because neither is meaningful alone, and
@@ -684,6 +717,11 @@ func loadFromEnv() (*Config, error) {
return nil, err return nil, err
} }
hstsMaxAge, err := resolveHSTSMaxAge()
if err != nil {
return nil, err
}
receiverRateLimit, err := envPositiveInt( receiverRateLimit, err := envPositiveInt(
"RECEIVER_RATE_LIMIT", "RECEIVER_RATE_LIMIT",
defaultReceiverRateLimit, defaultReceiverRateLimit,
@@ -723,6 +761,7 @@ func loadFromEnv() (*Config, error) {
SentryDSN: sentryDSN, SentryDSN: sentryDSN,
RetentionSweepInterval: retentionSweepInterval, RetentionSweepInterval: retentionSweepInterval,
SessionIdleTimeout: sessionIdleTimeout, SessionIdleTimeout: sessionIdleTimeout,
HSTSMaxAge: hstsMaxAge,
ReceiverRateLimit: receiverRateLimit, ReceiverRateLimit: receiverRateLimit,
TrustedProxies: trustedProxies, TrustedProxies: trustedProxies,
AllowedEgressCIDRs: allowedEgressCIDRs, AllowedEgressCIDRs: allowedEgressCIDRs,
@@ -807,6 +846,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
// disables idle expiry entirely, and that is worth showing // disables idle expiry entirely, and that is worth showing
// back to the operator. // back to the operator.
"sessionIdleTimeout", s.SessionIdleTimeout.String(), "sessionIdleTimeout", s.SessionIdleTimeout.String(),
"hstsMaxAge", s.HSTSMaxAge.String(),
"receiverRateLimit", s.ReceiverRateLimit, "receiverRateLimit", s.ReceiverRateLimit,
"trustedProxies", len(s.TrustedProxies), "trustedProxies", len(s.TrustedProxies),
"allowedEgressCIDRs", len(s.AllowedEgressCIDRs), "allowedEgressCIDRs", len(s.AllowedEgressCIDRs),
+77
View File
@@ -379,6 +379,83 @@ func testSessionIdleTimeoutSuccess(
assert.Equal(t, expected, cfg.SessionIdleTimeout) assert.Equal(t, expected, cfg.SessionIdleTimeout)
} }
func TestHSTSMaxAge(t *testing.T) {
tests := []struct {
name string
set bool
value string
expectError bool
// sentinel, when set, must be wrapped by the startup
// error; every error case must additionally name the
// variable in its message.
sentinel error
expected time.Duration
}{
{
name: caseUnsetUsesDefault,
set: false,
expected: 87600 * time.Hour,
},
{
name: caseValidValueParsed,
set: true,
value: "720h",
expected: 720 * time.Hour,
},
{
// Zero turns the header off; it is not an error.
name: "zero is accepted",
set: true,
value: "0",
expected: 0,
},
{
name: caseUnparseableFails,
set: true,
value: "10 years",
expectError: true,
},
{
// A whole number of seconds, so only the sign is wrong.
name: "negative whole seconds fail startup",
set: true,
value: "-1s",
expectError: true,
sentinel: config.ErrInvalidHSTSMaxAge,
},
{
name: "fraction of a second fails startup",
set: true,
value: "1500ms",
expectError: true,
sentinel: config.ErrInvalidHSTSMaxAge,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
configtest.ClearEnv(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("HSTS_MAX_AGE", tt.value)
}
if tt.expectError {
expectStartupErrorFor(t, "HSTS_MAX_AGE", tt.sentinel)
return
}
cfg, err := buildConfig(t)
require.NoError(t, err)
assert.Equal(t, tt.expected, cfg.HSTSMaxAge)
})
}
}
func TestDefaultDataDir(t *testing.T) { func TestDefaultDataDir(t *testing.T) {
for _, env := range []string{"", "dev", "prod"} { for _, env := range []string{"", "dev", "prod"} {
name := env name := env
+7
View File
@@ -86,6 +86,13 @@ func settingRows(cfg *config.Config) []settingRow {
"Idle session timeout (Go duration)", "Idle session timeout (Go duration)",
cfg.SessionIdleTimeout.String(), cfg.SessionIdleTimeout.String(),
}, },
{
"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",
cfg.HSTSMaxAge.String(),
},
{ {
"RECEIVER_RATE_LIMIT", "RECEIVER_RATE_LIMIT",
"Receiver requests/minute per IP per entrypoint " + "Receiver requests/minute per IP per entrypoint " +
+2
View File
@@ -76,6 +76,7 @@ func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
BindAddress: "192.0.2.10", BindAddress: "192.0.2.10",
RetentionSweepInterval: 17 * time.Minute, RetentionSweepInterval: 17 * time.Minute,
SessionIdleTimeout: 3 * time.Hour, SessionIdleTimeout: 3 * time.Hour,
HSTSMaxAge: 8760 * time.Hour,
ReceiverRateLimit: 77, ReceiverRateLimit: 77,
TrustedProxies: []netip.Prefix{ TrustedProxies: []netip.Prefix{
netip.MustParsePrefix("10.1.0.0/16"), netip.MustParsePrefix("10.1.0.0/16"),
@@ -99,6 +100,7 @@ func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
"SENTRY_DSN": "not set", "SENTRY_DSN": "not set",
"RETENTION_SWEEP_INTERVAL": "17m0s", "RETENTION_SWEEP_INTERVAL": "17m0s",
"SESSION_IDLE_TIMEOUT": "3h0m0s", "SESSION_IDLE_TIMEOUT": "3h0m0s",
"HSTS_MAX_AGE": "8760h0m0s",
"RECEIVER_RATE_LIMIT": "77", "RECEIVER_RATE_LIMIT": "77",
"TRUSTED_PROXIES": "10.1.0.0/16", "TRUSTED_PROXIES": "10.1.0.0/16",
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8", "ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
+12 -6
View File
@@ -8,6 +8,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"sync" "sync"
"time" "time"
@@ -535,18 +536,23 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
} }
// SecurityHeaders returns middleware that sets production security // SecurityHeaders returns middleware that sets production security
// headers on every response: HSTS, X-Content-Type-Options, // headers on every response: HSTS (unless HSTS_MAX_AGE is zero),
// X-Frame-Options, CSP, Referrer-Policy, and Permissions-Policy. // X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and
// Permissions-Policy.
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler { func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
maxAge := s.params.Config.HSTSMaxAge
hsts := "max-age=" + strconv.FormatInt(int64(maxAge/time.Second), 10) +
"; includeSubDomains; preload"
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func( return http.HandlerFunc(func(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
) { ) {
w.Header().Set( if maxAge > 0 {
"Strict-Transport-Security", w.Header().Set("Strict-Transport-Security", hsts)
"max-age=63072000; includeSubDomains; preload", }
)
w.Header().Set( w.Header().Set(
"X-Content-Type-Options", "nosniff", "X-Content-Type-Options", "nosniff",
) )
+64
View File
@@ -745,6 +745,70 @@ func TestNoCache_SetsHeaders(t *testing.T) {
) )
} }
// --- SecurityHeaders Middleware Tests ---
// hstsHeaders serves one request through SecurityHeaders built over
// a Config with the given HSTSMaxAge, and returns every
// Strict-Transport-Security value on the response.
func hstsHeaders(t *testing.T, maxAge time.Duration) []string {
t.Helper()
cfg := &config.Config{HSTSMaxAge: maxAge}
m := middlewaretest.New(
t, slog.New(slog.DiscardHandler), cfg,
newTestSessionManager(t, cfg),
)
handler := m.SecurityHeaders()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w.Header().Values("Strict-Transport-Security")
}
func TestSecurityHeaders_HSTS(t *testing.T) {
t.Parallel()
tests := []struct {
name string
maxAge time.Duration
expected []string
}{
{
name: "default of ten years",
maxAge: 87600 * time.Hour,
expected: []string{"max-age=315360000; includeSubDomains; preload"},
},
{
name: "custom value is sent in seconds",
maxAge: 90 * time.Minute,
expected: []string{"max-age=5400; includeSubDomains; preload"},
},
{
name: "zero sends no header",
maxAge: 0,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, hstsHeaders(t, tt.maxAge))
})
}
}
// --- MaxBodySize Middleware Tests --- // --- MaxBodySize Middleware Tests ---
const testBodyLimit int64 = 64 const testBodyLimit int64 = 64