feat(auth): SA3 — trusted-proxy X-Forwarded-For for rate limiting

_client_ip trusted the first X-Forwarded-For hop unconditionally, so anyone able to
reach the app port could forge XFF and dodge the login/register/reset/demo rate limits.
Now trust XFF ONLY when the request's socket peer is a configured reverse proxy
(settings.trusted_proxy_ips, e.g. the VPS Caddy's WireGuard peer IP), and take the
RIGHTMOST entry — the client our proxy actually saw and appended, immune to a client
pre-seeding a fake XFF. A request from any other peer (hitting the port directly) is
keyed on its real socket IP, so XFF can't be forged to bypass the limits.

New TRUSTED_PROXY_IPS env (empty default = no proxy, use the direct peer). Documented in
.env.example, docs/self-hosting.md, README. Unit-verified against spoof-through-proxy and
direct-bypass cases.
This commit is contained in:
npeter83 2026-07-12 03:42:41 +02:00
parent 2eca56d68a
commit 7297dbd2a8
5 changed files with 52 additions and 7 deletions

View file

@ -31,6 +31,16 @@ ADMIN_EMAILS=
# Optional: origin of a separately-served frontend dev server (enables CORS). Leave empty in production.
FRONTEND_ORIGIN=
# Reverse-proxy trust for rate limiting. If you run the app behind a reverse proxy (Caddy/Nginx/
# Traefik…) that terminates TLS, set this to the IP the proxy CONNECTS FROM as the app sees it — the
# proxy's address on the app's network (e.g. the Docker/host/VPN IP), NOT the public client IP. Then
# the login/registration/password-reset rate limiters key on the real client (read from the proxy's
# X-Forwarded-For) instead of the proxy's own IP. Requests arriving from any OTHER peer (e.g. someone
# hitting the container port directly) are not trusted and are rate-limited by their real socket IP,
# so X-Forwarded-For can't be forged to dodge the limits. Comma-separated. Leave EMPTY if the app is
# directly exposed with no proxy (then the direct socket IP is used). Example: TRUSTED_PROXY_IPS=10.10.0.1
TRUSTED_PROXY_IPS=
# Optional YouTube Data API key (Google Cloud Console -> Credentials -> Create API key).
# When set, all public reads (channels/videos/playlist backfill + enrichment) use the key
# instead of a user's OAuth token, so 24/7 backfill never depends on a refresh token that