Warn when production shares one rate-limit bucket (closes #149)
All checks were successful
check / check (push) Successful in 3m7s

With TRUSTED_PROXIES empty, every rate limiter keys on the connecting
peer. Production runs behind a TLS-terminating reverse proxy, so the
peer is that proxy for every request and all clients share one bucket
per limit. For the login limiter that means any remote client sending
five POSTs a minute holds the only administrative login at HTTP 429.

The empty default is correct — trusting forwarded headers from
arbitrary peers lets any client choose its own bucket — so this makes
the consequence visible rather than changing the keying, the limits or
the default:

- config logs a WARN at startup when the environment is prod and
  TRUSTED_PROXIES is empty, naming the variable, the shared bucket and
  the deniable admin login.
- The security-feature bullet's "per IP" login claim is now conditional
  on TRUSTED_PROXIES, which is the only case where it holds.
- The rate-limiting section separates the receiver case (sharing costs
  throughput, the safe direction) from the login case (sharing costs
  availability of the only admin path, not safe).
- The trusted-proxies configuration section states the consequence and
  names TRUSTED_PROXIES as the remedy.
This commit is contained in:
2026-08-12 11:42:04 +00:00
parent 339548d794
commit 0beeddd475
4 changed files with 175 additions and 13 deletions

View File

@@ -422,6 +422,38 @@ func loadFromEnv() (*Config, error) {
}, nil
}
// warnSharedRateLimitBucket logs a startup warning when a production
// deployment leaves TRUSTED_PROXIES empty.
//
// With no trusted proxies every rate limiter keys on the connecting
// peer's address. A production deployment is required to run behind a
// TLS-terminating reverse proxy, and the peer is then that proxy for
// every request, so all clients share one bucket per limiter. The
// login limiter's bucket is the dangerous one: any remote client can
// keep it full, which denies the only administrative login to
// everyone until the process restarts.
//
// The default of trusting nobody is deliberate — trusting forwarded
// headers from arbitrary peers lets any client choose its own bucket —
// so this warns rather than failing startup or changing the key.
func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) {
if !c.IsProd() || len(c.TrustedProxies) > 0 {
return
}
log.Warn(
"TRUSTED_PROXIES is empty: rate limits key on the "+
"connecting peer, so behind the reverse proxy a "+
"production deployment runs behind, every client "+
"shares one bucket per limit. Any remote client can "+
"then keep the login limit full and deny the admin "+
"login, the only administrative path, until restart. "+
"Set TRUSTED_PROXIES to your reverse proxy's address.",
"environment", c.Environment,
"trustedProxies", len(c.TrustedProxies),
)
}
// New creates a Config by reading environment variables.
//
//nolint:revive // lc parameter is required by fx even if unused.
@@ -466,5 +498,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
s.MetricsUsername != "" && s.MetricsPassword != "",
)
s.warnSharedRateLimitBucket(log)
return s, nil
}