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

@ -173,12 +173,19 @@ def get_or_create_demo_user(db: Session) -> User:
def _client_ip(request: Request) -> str:
"""Best-effort client IP for rate limiting. Behind our reverse proxy (Caddy/NPM) the
real client is the first X-Forwarded-For hop; fall back to the socket peer otherwise."""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
"""Best-effort client IP for rate limiting. X-Forwarded-For is client-controlled, so we trust it
ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips
the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS
the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a
client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the
proxy) is untrusted we use its real socket IP, so it can't spoof its rate-limit identity."""
peer = request.client.host if request.client else "unknown"
if peer in settings.trusted_proxy_ip_set:
xff = request.headers.get("x-forwarded-for")
parts = [p.strip() for p in (xff or "").split(",") if p.strip()]
if parts:
return parts[-1]
return peer
def upsert_pending_invite(db: Session, email: str) -> Invite | None: