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

@ -52,6 +52,14 @@ class Settings(BaseSettings):
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
frontend_origin: str = ""
# Reverse-proxy IPs whose X-Forwarded-For we trust for the real client IP (rate limiting). This
# is the address the proxy CONNECTS FROM as seen by the app — e.g. the WireGuard peer IP of the
# VPS Caddy front door. A request from any other peer (someone hitting the app port directly)
# is NOT trusted and its raw socket IP is used, so it can't spoof its rate-limit identity via
# XFF. Empty = trust no proxy (use the direct peer) — correct for a directly-exposed / local-dev
# deploy. Comma-separated. See auth._client_ip + the deploy docs.
trusted_proxy_ips: str = ""
# --- Outbound email (onboarding: access-request + approval notices) ---
# Gmail SMTP + App Password by default. All optional: if unset, email is skipped
# (fail-soft) and onboarding still works via in-app notifications.
@ -226,6 +234,10 @@ class Settings(BaseSettings):
def admin_email_set(self) -> set[str]:
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
@property
def trusted_proxy_ip_set(self) -> frozenset[str]:
return frozenset(p.strip() for p in self.trusted_proxy_ips.split(",") if p.strip())
@property
def app_base(self) -> str:
"""Public origin of the deployed app, derived from the OAuth redirect URL