Make the HSTS max-age configurable with HSTS_MAX_AGE (closes #271)
check / check (push) Successful in 16m1s

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-13 00:13:15 +00:00
committed by clawbot
parent 9199544999
commit 9b39b865ca
7 changed files with 243 additions and 24 deletions
+40
View File
@@ -68,6 +68,10 @@ const (
// authenticated activity before it expires.
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
// per minute each client IP may send to a single 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.
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
// but cannot be read or parsed. A file that is not there is not an
// 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.
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
// client IP may send to a single webhook receiver entrypoint.
ReceiverRateLimit int
@@ -624,6 +636,27 @@ func resolveEnvironment() (string, error) {
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
// listener: which port it claims and which address it claims it on.
// They are read together because neither is meaningful alone, and
@@ -684,6 +717,11 @@ func loadFromEnv() (*Config, error) {
return nil, err
}
hstsMaxAge, err := resolveHSTSMaxAge()
if err != nil {
return nil, err
}
receiverRateLimit, err := envPositiveInt(
"RECEIVER_RATE_LIMIT",
defaultReceiverRateLimit,
@@ -723,6 +761,7 @@ func loadFromEnv() (*Config, error) {
SentryDSN: sentryDSN,
RetentionSweepInterval: retentionSweepInterval,
SessionIdleTimeout: sessionIdleTimeout,
HSTSMaxAge: hstsMaxAge,
ReceiverRateLimit: receiverRateLimit,
TrustedProxies: trustedProxies,
AllowedEgressCIDRs: allowedEgressCIDRs,
@@ -807,6 +846,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
// disables idle expiry entirely, and that is worth showing
// back to the operator.
"sessionIdleTimeout", s.SessionIdleTimeout.String(),
"hstsMaxAge", s.HSTSMaxAge.String(),
"receiverRateLimit", s.ReceiverRateLimit,
"trustedProxies", len(s.TrustedProxies),
"allowedEgressCIDRs", len(s.AllowedEgressCIDRs),
+77
View File
@@ -379,6 +379,83 @@ func testSessionIdleTimeoutSuccess(
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) {
for _, env := range []string{"", "dev", "prod"} {
name := env
+7
View File
@@ -86,6 +86,13 @@ func settingRows(cfg *config.Config) []settingRow {
"Idle session timeout (Go duration)",
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 requests/minute per IP per entrypoint " +
+2
View File
@@ -76,6 +76,7 @@ func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
BindAddress: "192.0.2.10",
RetentionSweepInterval: 17 * time.Minute,
SessionIdleTimeout: 3 * time.Hour,
HSTSMaxAge: 8760 * time.Hour,
ReceiverRateLimit: 77,
TrustedProxies: []netip.Prefix{
netip.MustParsePrefix("10.1.0.0/16"),
@@ -99,6 +100,7 @@ func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
"SENTRY_DSN": "not set",
"RETENTION_SWEEP_INTERVAL": "17m0s",
"SESSION_IDLE_TIMEOUT": "3h0m0s",
"HSTS_MAX_AGE": "8760h0m0s",
"RECEIVER_RATE_LIMIT": "77",
"TRUSTED_PROXIES": "10.1.0.0/16",
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
+12 -6
View File
@@ -8,6 +8,7 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"sync"
"time"
@@ -535,18 +536,23 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
}
// SecurityHeaders returns middleware that sets production security
// headers on every response: HSTS, X-Content-Type-Options,
// X-Frame-Options, CSP, Referrer-Policy, and Permissions-Policy.
// headers on every response: HSTS (unless HSTS_MAX_AGE is zero),
// X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and
// Permissions-Policy.
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 http.HandlerFunc(func(
w http.ResponseWriter,
r *http.Request,
) {
w.Header().Set(
"Strict-Transport-Security",
"max-age=63072000; includeSubDomains; preload",
)
if maxAge > 0 {
w.Header().Set("Strict-Transport-Security", hsts)
}
w.Header().Set(
"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 ---
const testBodyLimit int64 = 64