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

@@ -1,5 +1,5 @@
import crypto from 'crypto';
import { env } from '../env';
import { resolveAuthSecret } from '../secret';
import { loadYaml, clearYamlCache, validateUuid } from './parser';
import { resolveTheme, VALID_LAYOUTS } from './theme';
import {
@@ -17,7 +17,6 @@ export * from './schema';
export * from './theme';
let _config: AppConfig | null = null;
let _fallbackSecret: string | null = null;
/** Invalidate the cached config so the next getConfig() call re-reads YAML files. */
export function invalidateConfigCache(): void {
@@ -58,22 +57,7 @@ export function getConfig(): AppConfig {
const apiUrl = env.IMMICH_API_URL;
const apiKey = env.IMMICH_API_KEY;
let { AUTH_SECRET } = env;
if (!AUTH_SECRET) {
if (process.env.NODE_ENV === 'production') {
throw new Error(
'SECURITY ERROR: AUTH_SECRET is not set in production. Please set a long random string as AUTH_SECRET in your .env.',
);
}
if (!_fallbackSecret) {
_fallbackSecret = crypto.randomBytes(32).toString('hex');
}
console.warn(
'\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Generating a temporary random secret for this session.\n Please set a long random string as AUTH_SECRET in your .env for better security.\n',
);
AUTH_SECRET = _fallbackSecret;
}
const authSecret = AUTH_SECRET;
const authSecret = resolveAuthSecret();
const gallery = loadYaml<GalleryYaml>('gallery.yaml');
const settings = loadYaml<SettingsYaml>('settings.yaml') || {};
@@ -109,7 +93,7 @@ export function getConfig(): AppConfig {
albumHeroImages: {},
cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM,
trustedProxies: env.TRUSTED_PROXIES,
trustedProxyHops: env.TRUSTED_PROXY_HOPS,
needsSetup: true,
};
return _config;
@@ -293,7 +277,7 @@ export function getConfig(): AppConfig {
albumHeroImages,
cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM,
trustedProxies: env.TRUSTED_PROXIES,
trustedProxyHops: env.TRUSTED_PROXY_HOPS,
needsSetup: false,
};
return _config;

View File

@@ -100,7 +100,7 @@ export interface AppConfig {
albumHeroImages: Record<string, string>;
cacheTtl: number;
rateLimitRpm: number;
trustedProxies: string[];
trustedProxyHops: number;
needsSetup?: boolean;
}