security: fix pre-auth bypasses and restore Immich 3.x compatibility (#378)
Seven security issues from an audit, plus album pages and the map coming up empty against Immich 3.x. Verified against a real Immich 3.0.3 instance in a production build. Security: - Admin session forgery via a hardcoded 'dev-fallback-secret' signing key that bypassed the production AUTH_SECRET guard - /api/map ignored album passwords and returned working asset tokens; rewriting ?size=thumbnail to ?size=original downloaded full-resolution originals from protected albums - Admin login had no rate limit, and the client IP came from a spoofable header, so rotating it bypassed every limit in the app - Raw Immich asset UUIDs were serialized into the RSC payload - Album tokens never expired server-side; a captured token replayed forever - Path traversal into the Immich API via an unvalidated album id - /admin ran with no enforced CSP (only Report-Only reached it) TRUSTED_PROXIES is replaced by TRUSTED_PROXY_HOPS. The old variable matched proxy IPs against the socket peer address, which self-hosted Next.js never exposes, so the check could never pass: unset, the limiter read a spoofable header; set, every request shared one bucket and a single client could rate-limit the whole site. Migrates automatically. Immich 3.x: GET /albums/:id no longer embeds assets. Assets now come from POST /search/metadata with withExif: true — without that flag EXIF is omitted and grid aspect ratios, the lightbox panel and map markers stay empty. Drops support for Immich below 3.0, which now logs a warning. Upgrade: requires Immich 3.0+; set TRUSTED_PROXY_HOPS (nginx = 1); visitors of protected areas and admin sessions must log in once more. 91 unit tests (25 new); the new admin-auth and rate-limit cases were confirmed to fail against the pre-fix code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -208,14 +208,37 @@ To protect against brute-force attacks and resource exhaustion, Immich Folio inc
|
||||
- `RATE_LIMIT_RPM`: Maximum requests per minute per IP (default: `1500` for general API, `120` for map data).
|
||||
|
||||
### Trusted Proxies
|
||||
|
||||
If you are running Immich Folio behind a reverse proxy (like Nginx, Traefik, Caddy, or Cloudflare), you should configure trusted proxies to prevent IP spoofing.
|
||||
|
||||
- `TRUSTED_PROXIES`: A comma-separated list of IP addresses of your proxies.
|
||||
|
||||
Example:
|
||||
|
||||
If you run Immich Folio behind a reverse proxy (nginx, Traefik, Caddy, Cloudflare), tell it how many proxies sit in front. Without this, the rate limiter reads a client-supplied header and an attacker can pick their own bucket — defeating brute-force protection on the password endpoints.
|
||||
|
||||
- `TRUSTED_PROXY_HOPS`: Number of reverse proxies in front of the app (default: `0`).
|
||||
|
||||
```bash
|
||||
TRUSTED_PROXIES=127.0.0.1,172.18.0.1
|
||||
# nginx / Traefik / Caddy directly in front of the app
|
||||
TRUSTED_PROXY_HOPS=1
|
||||
|
||||
# Cloudflare in front of nginx
|
||||
TRUSTED_PROXY_HOPS=2
|
||||
```
|
||||
|
||||
When set, the rate limiter will only trust `X-Forwarded-For` and `X-Real-IP` headers if the request directly comes from one of these IPs. If not set, headers are trusted as a best-effort, which is less secure in public-facing environments.
|
||||
|
||||
The client IP is taken that many entries from the **right** of `X-Forwarded-For`. Proxies append the address they actually observed, so everything to the left is client-supplied and ignored — a visitor sending `X-Forwarded-For: 1.1.1.1` cannot influence which bucket they land in.
|
||||
|
||||
> ⚠️ **The app must be reachable only through the proxy.** Bind it to localhost (`127.0.0.1:3000`) or keep it on an internal Docker network. If clients can connect directly they control the entire header, and no setting can recover the real IP.
|
||||
|
||||
Matching nginx config:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
`$proxy_add_x_forwarded_for` is the important one: it *appends* the real peer address rather than overwriting the header.
|
||||
|
||||
#### Migrating from `TRUSTED_PROXIES`
|
||||
|
||||
`TRUSTED_PROXIES` has been removed. It matched proxy IPs against the socket peer address, which a self-hosted Next.js server never exposes — so the check never passed. Setting it silently put **every visitor into one shared rate-limit bucket**, letting a single client trip the limit for the entire site. If it is still set, the app assumes `TRUSTED_PROXY_HOPS=1` and logs a warning; replace it with an explicit value.
|
||||
|
||||
Reference in New Issue
Block a user