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:
parent
2eca56d68a
commit
7297dbd2a8
5 changed files with 52 additions and 7 deletions
10
.env.example
10
.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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue