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

View file

@ -82,7 +82,8 @@ Port `8080` over plain HTTP is fine for a LAN or a quick trial. For public acces
proxy (Caddy, Nginx, Traefik…) in front to terminate TLS, and set the **public URL** (the installer
prompt, or `OAUTH_REDIRECT_URL` in `.env`) to your `https://…` address — this also marks the session
cookie secure. Add that same `…/auth/callback` URL to your Google OAuth client's authorized redirect
URIs.
URIs. Behind a proxy, also set `TRUSTED_PROXY_IPS` so the rate limiters see the real client IP and
can't be bypassed via a forged `X-Forwarded-For` — see [docs/self-hosting.md](docs/self-hosting.md#https--public-access).
## Updating & backups

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."""
"""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")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
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:

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

View file

@ -79,6 +79,21 @@ public access, put a reverse proxy (Caddy, Nginx, Traefik…) in front to termin
secure). If you've already run the installer, edit `OAUTH_REDIRECT_URL` in `.env` to the https
callback URL and `docker compose -f docker-compose.selfhost.yml up -d`.
**Behind a proxy, set `TRUSTED_PROXY_IPS`.** The login / registration / password-reset rate limiters
identify callers by IP. Behind a proxy every request arrives from the proxy, so without this the
limits would apply to everyone together. Set `TRUSTED_PROXY_IPS` in `.env` to the address your proxy
connects to the app *from* (as the app sees it — e.g. its Docker/host/LAN IP, **not** the public
client address), and the app will read the real client IP from the proxy's `X-Forwarded-For` header:
```bash
TRUSTED_PROXY_IPS=172.18.0.1 # comma-separate multiple proxies
```
Only that peer is trusted, so a request sent straight to port `8080` (bypassing the proxy) can't forge
`X-Forwarded-For` to dodge the limits — it's rate-limited by its real address. Leave it empty if the
app is exposed directly with no proxy. Make sure your proxy actually sets `X-Forwarded-For` (Caddy's
`reverse_proxy` and Nginx's `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` both do).
## Download Center (media storage)
The stack includes a **Download Center**: an admin-enabled feature that saves videos to the server