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

@@ -2,15 +2,30 @@ import { immich } from './immich';
import { getConfig } from './config';
import { cache } from './cache';
/**
* One album's contribution to a location marker.
*
* Aggregation is deliberately kept per-album (rather than pre-summed) so the
* API layer can drop albums the viewer is not authorized to see *before*
* computing coords, counts and the cover asset. Summing here would leak
* protected albums into every marker.
*/
export interface MapAlbumEntry {
id: string;
name: string;
slug: string;
subpageSlug?: string;
photoCount: number;
latSum: number;
lngSum: number;
coverAssetId: string;
}
/** A clustered map marker — one per unique city/country. */
export interface MapLocation {
city: string;
country: string;
lat: number;
lng: number;
photoCount: number;
coverAssetId: string;
albums: { name: string; slug: string; subpageSlug?: string }[];
albums: MapAlbumEntry[];
}
/**
@@ -50,60 +65,50 @@ export async function getMapData(): Promise<MapLocation[]> {
fullAlbums.push(...chunkResults);
}
// Bucket assets by city+country
const buckets = new Map<
string,
{
latSum: number;
lngSum: number;
count: number;
coverAssetId: string;
albumIds: Set<string>;
}
>();
// Bucket assets by city+country, keeping each album's contribution separate
const buckets = new Map<string, Map<string, MapAlbumEntry>>();
for (const album of fullAlbums) {
if (!album) continue;
const meta = albumMeta.get(album.id);
if (!meta) continue;
for (const asset of album.assets) {
const exif = asset.exifInfo;
if (!exif?.latitude || !exif?.longitude || !exif?.city || !exif?.country) continue;
const key = `${exif.city}|${exif.country}`;
let bucket = buckets.get(key);
if (!bucket) {
bucket = {
let byAlbum = buckets.get(key);
if (!byAlbum) {
byAlbum = new Map();
buckets.set(key, byAlbum);
}
let entry = byAlbum.get(album.id);
if (!entry) {
entry = {
id: album.id,
name: meta.name,
slug: meta.slug,
subpageSlug: meta.subpageSlug,
photoCount: 0,
latSum: 0,
lngSum: 0,
count: 0,
coverAssetId: asset.id,
albumIds: new Set(),
};
buckets.set(key, bucket);
byAlbum.set(album.id, entry);
}
bucket.latSum += exif.latitude;
bucket.lngSum += exif.longitude;
bucket.count++;
bucket.albumIds.add(album.id);
entry.latSum += exif.latitude;
entry.lngSum += exif.longitude;
entry.photoCount++;
}
}
// Convert buckets to MapLocation[]
const locations: MapLocation[] = [];
for (const [key, bucket] of buckets) {
for (const [key, byAlbum] of buckets) {
const [city, country] = key.split('|');
const lat = bucket.latSum / bucket.count;
const lng = bucket.lngSum / bucket.count;
locations.push({
city,
country,
lat,
lng,
photoCount: bucket.count,
coverAssetId: bucket.coverAssetId,
albums: [...bucket.albumIds]
.map((id) => albumMeta.get(id))
.filter((m): m is NonNullable<typeof m> => !!m),
});
locations.push({ city, country, albums: [...byAlbum.values()] });
}
cache.set(cacheKey, locations, config.cacheTtl);