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:
Ralksta
2026-07-20 18:57:57 +02:00
committed by GitHub
parent cf2e8e6ece
commit 37f7f62157
30 changed files with 821 additions and 226 deletions

View File

@@ -12,28 +12,59 @@
import { NextRequest } from 'next/server';
import { getConfig } from './config';
/** Bucket key used when the client cannot be identified with any confidence. */
const UNIDENTIFIED = 'unknown';
/**
* Resolve the client IP to use as a rate-limit bucket key.
*
* Self-hosted Next.js cannot see the socket peer address (`request.ip` is only
* populated by platforms like Vercel), so behind a reverse proxy the client IP
* has to come from a header. Headers are attacker-controlled unless a proxy
* overwrites them, which is what TRUSTED_PROXY_HOPS declares.
*
* With `TRUSTED_PROXY_HOPS=n`, the entry `n` from the *right* of
* X-Forwarded-For is used. Proxies append the address they actually saw
* (nginx: `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`), so
* everything to the left is client-supplied and ignored. A client that sends
* `X-Forwarded-For: 1.1.1.1` therefore cannot pick its own bucket.
*
* ⚠️ This holds only while the app is reachable *exclusively* through the
* proxy. Bind the server to localhost — if clients can connect directly they
* control the whole header and no parsing strategy can help.
*/
export function getClientIp(request: NextRequest): string {
const config = getConfig();
const hops = getConfig().trustedProxyHops;
// @ts-expect-error - Next.js 15+ removed request.ip from types but hosting platforms still populate it
const directIp = request.ip as string | undefined;
// The real socket peer address, where available, is never spoofable.
if (directIp) return directIp;
const xForwardedFor = request.headers.get('x-forwarded-for');
const xRealIp = request.headers.get('x-real-ip');
// If trusted proxies are configured, only trust headers if the request
// comes from one of those proxies.
if (config.trustedProxies.length > 0) {
if (directIp && config.trustedProxies.includes(directIp)) {
return xRealIp ?? xForwardedFor?.split(',')[0].trim() ?? directIp;
if (hops > 0) {
if (xForwardedFor) {
const chain = xForwardedFor
.split(',')
.map((s) => s.trim())
.filter(Boolean);
// Chain shorter than the configured hop count means the request did not
// traverse the full proxy chain — do not fall back to a spoofable entry.
return chain[chain.length - hops] ?? UNIDENTIFIED;
}
// If we have trusted proxies but the direct IP is unknown or untrusted,
// we return the direct IP (if any) and ignore potentially spoofed headers.
return directIp ?? 'unknown';
// nginx's `proxy_set_header X-Real-IP $remote_addr` overwrites rather than
// appends, so it is trustworthy — but only for a single hop, since an outer
// proxy would leave an inner proxy's value in place.
if (xRealIp && hops === 1) return xRealIp;
return UNIDENTIFIED;
}
// Fallback for self-hosted environments without explicit trusted proxies:
// We have to trust headers as a best-effort, but this is vulnerable to spoofing.
return directIp ?? xRealIp ?? xForwardedFor?.split(',')[0].trim() ?? 'unknown';
// No proxy declared: headers are best-effort only. This still separates
// honest clients into their own buckets, but a deliberate attacker can spoof
// them. Set TRUSTED_PROXY_HOPS if you run behind a reverse proxy.
return xRealIp ?? xForwardedFor?.split(',')[0].trim() ?? UNIDENTIFIED;
}
interface RateLimitEntry {