diff --git a/.env.example b/.env.example index cb17209..3e4dae1 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index cd72694..22ef321 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/app/auth.py b/backend/app/auth.py index edbb458..7a2f254 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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: diff --git a/backend/app/config.py b/backend/app/config.py index 3f75965..a4d6c10 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 94d68b6..8997a3a 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -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