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

@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import crypto from 'crypto';
// next/headers is only used by isAdminAuthenticated(), which we do not exercise here.
vi.mock('next/headers', () => ({ cookies: async () => ({ get: () => undefined }) }));
const ADMIN_PASSWORD = 'correct-horse-battery-staple';
const AUTH_SECRET = 'test-auth-secret-32-chars-long-min';
vi.mock('../env', () => ({
env: {
get AUTH_SECRET() {
return process.env.__TEST_AUTH_SECRET;
},
get ADMIN_PASSWORD() {
return process.env.__TEST_ADMIN_PASSWORD;
},
},
}));
/** Import fresh so module-level state cannot leak between cases. */
async function loadAuth() {
vi.resetModules();
return import('../admin/auth');
}
function forgeToken(signingKey: Buffer, payload: object): string {
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = crypto.createHmac('sha256', signingKey).update(data).digest('base64url');
return `${data}.${sig}`;
}
beforeEach(() => {
process.env.__TEST_AUTH_SECRET = AUTH_SECRET;
process.env.__TEST_ADMIN_PASSWORD = ADMIN_PASSWORD;
});
afterEach(() => {
delete process.env.__TEST_AUTH_SECRET;
delete process.env.__TEST_ADMIN_PASSWORD;
vi.unstubAllEnvs();
});
describe('admin session tokens', () => {
it('accepts a token it just issued', async () => {
const { createAdminToken, verifyAdminToken } = await loadAuth();
expect(verifyAdminToken(createAdminToken())).toBe(true);
});
it('rejects a token forged with the old hardcoded dev fallback secret', async () => {
const { verifyAdminToken } = await loadAuth();
// The exact key an attacker could derive from the public source before the fix.
const legacyKey = crypto.createHash('sha256').update('admin:dev-fallback-secret').digest();
const forged = forgeToken(legacyKey, {
role: 'admin',
iat: Date.now(),
exp: Date.now() + 60_000,
});
expect(verifyAdminToken(forged)).toBe(false);
});
it('does not fall back to a guessable secret when AUTH_SECRET is unset in dev', async () => {
delete process.env.__TEST_AUTH_SECRET;
const { verifyAdminToken } = await loadAuth();
const legacyKey = crypto.createHash('sha256').update('admin:dev-fallback-secret').digest();
const forged = forgeToken(legacyKey, {
role: 'admin',
iat: Date.now(),
exp: Date.now() + 60_000,
});
expect(verifyAdminToken(forged)).toBe(false);
});
it('throws rather than signing with a guessable secret when AUTH_SECRET is unset in production', async () => {
delete process.env.__TEST_AUTH_SECRET;
vi.stubEnv('NODE_ENV', 'production');
const { createAdminToken } = await loadAuth();
expect(() => createAdminToken()).toThrow(/AUTH_SECRET/);
});
it('returns false (not throw) for a malformed cookie with a short signature', async () => {
const { verifyAdminToken } = await loadAuth();
// Pre-fix this hit crypto.timingSafeEqual outside the try block -> RangeError -> HTTP 500.
expect(() => verifyAdminToken('a.b')).not.toThrow();
expect(verifyAdminToken('a.b')).toBe(false);
expect(verifyAdminToken('not-a-token')).toBe(false);
expect(verifyAdminToken('')).toBe(false);
});
it('rejects an expired token', async () => {
const { verifyAdminToken } = await loadAuth();
const key = crypto
.createHash('sha256')
.update(`admin:${AUTH_SECRET}:${ADMIN_PASSWORD}`)
.digest();
const expired = forgeToken(key, { role: 'admin', iat: 0, exp: Date.now() - 1000 });
expect(verifyAdminToken(expired)).toBe(false);
});
it('rejects a validly signed token whose role is not admin', async () => {
const { verifyAdminToken } = await loadAuth();
const key = crypto
.createHash('sha256')
.update(`admin:${AUTH_SECRET}:${ADMIN_PASSWORD}`)
.digest();
const wrongRole = forgeToken(key, { role: 'viewer', iat: 0, exp: Date.now() + 60_000 });
expect(verifyAdminToken(wrongRole)).toBe(false);
});
it('invalidates existing sessions when ADMIN_PASSWORD is rotated', async () => {
const first = await loadAuth();
const token = first.createAdminToken();
expect(first.verifyAdminToken(token)).toBe(true);
process.env.__TEST_ADMIN_PASSWORD = 'a-brand-new-password';
const second = await loadAuth();
expect(second.verifyAdminToken(token)).toBe(false);
});
});
describe('verifyAdminPassword', () => {
it('accepts the configured password and rejects others', async () => {
const { verifyAdminPassword } = await loadAuth();
expect(verifyAdminPassword(ADMIN_PASSWORD)).toBe(true);
expect(verifyAdminPassword('wrong')).toBe(false);
expect(verifyAdminPassword(`${ADMIN_PASSWORD}x`)).toBe(false);
expect(verifyAdminPassword('')).toBe(false);
});
it('rejects everything when no ADMIN_PASSWORD is configured', async () => {
delete process.env.__TEST_ADMIN_PASSWORD;
const { verifyAdminPassword, isAdminEnabled } = await loadAuth();
expect(isAdminEnabled()).toBe(false);
expect(verifyAdminPassword('')).toBe(false);
expect(verifyAdminPassword('anything')).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
// Mock config to provide a predictable API key and subpage config
vi.mock('@/lib/config', () => ({
@@ -93,3 +93,60 @@ describe('isAuthenticated', () => {
expect(isAuthenticated('public', getCookie)).toBe(true);
});
});
describe('token expiry', () => {
function tokenFor(slug: string, password: string): string {
return authenticate(slug, password)!.split('=')[1].split(';')[0];
}
afterEach(() => {
vi.useRealTimers();
});
it('rejects a token once its embedded expiry has passed', () => {
const token = tokenFor('private', 'secret123');
const getCookie = (name: string) => (name === 'lb_auth_private' ? token : undefined);
expect(isAuthenticated('private', getCookie)).toBe(true);
// Previously the 24h lifetime lived only in the cookie's Max-Age, so a
// captured token replayed via curl worked forever.
vi.useFakeTimers();
vi.setSystemTime(Date.now() + 25 * 60 * 60 * 1000);
expect(isAuthenticated('private', getCookie)).toBe(false);
});
it('still accepts a token just before it expires', () => {
const token = tokenFor('private', 'secret123');
const getCookie = (name: string) => (name === 'lb_auth_private' ? token : undefined);
vi.useFakeTimers();
vi.setSystemTime(Date.now() + 23 * 60 * 60 * 1000);
expect(isAuthenticated('private', getCookie)).toBe(true);
});
it('rejects a token whose expiry was tampered with', () => {
const token = tokenFor('private', 'secret123');
const [, sig] = token.split('.');
const farFuture = Date.now() + 365 * 24 * 60 * 60 * 1000;
// The expiry is covered by the HMAC, so extending it invalidates the token.
const getCookie = (name: string) =>
name === 'lb_auth_private' ? `${farFuture}.${sig}` : undefined;
expect(isAuthenticated('private', getCookie)).toBe(false);
});
it('rejects malformed and legacy tokens without throwing', () => {
for (const value of ['', '.', 'abc', 'abc.def', `${Date.now() + 1000}.`, 'deadbeef']) {
const getCookie = (name: string) => (name === 'lb_auth_private' ? value : undefined);
expect(() => isAuthenticated('private', getCookie)).not.toThrow();
expect(isAuthenticated('private', getCookie)).toBe(false);
}
});
it('sets a cookie Max-Age consistent with the embedded expiry', () => {
const cookie = authenticate('private', 'secret123')!;
const maxAge = Number(cookie.match(/Max-Age=(\d+)/)![1]);
const exp = Number(cookie.split('=')[1].split(';')[0].split('.')[0]);
expect(Math.abs(exp - (Date.now() + maxAge * 1000))).toBeLessThan(2000);
});
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { immich } from '../immich';
import * as config from '../config';
import { cache } from '../cache';
// Mock the config by wrapping it in a factory
vi.mock('../config', async () => {
@@ -28,6 +29,9 @@ describe('ImmichClient', () => {
beforeEach(() => {
vi.clearAllMocks();
// getAlbum() memoises into the module-level LRU; without this, later tests
// read a previous test's album and never reach the mocked fetch.
cache.clear();
});
describe('request()', () => {
@@ -80,23 +84,105 @@ describe('ImmichClient', () => {
expect(album).toBeNull();
});
it('returns album data and filters trashed assets', async () => {
mockFetch.mockResolvedValueOnce({
/**
* Immich 3.x returns an empty `assets` array from GET /albums/:id, so
* assets come from a separate POST /search/metadata call. Route the mock by
* URL rather than by call order — the two run concurrently.
*/
function mockAlbumApi(options: {
order?: 'asc' | 'desc';
pages?: Array<{ items: unknown[]; nextPage: string | null }>;
}) {
const pages = options.pages ?? [];
const json = (body: unknown) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({
json: async () => body,
});
mockFetch.mockImplementation(async (url: string, init?: { body?: string }) => {
if (url.includes('/search/metadata')) {
const page = JSON.parse(init?.body ?? '{}').page ?? 1;
const p = pages[page - 1] ?? { items: [], nextPage: null };
return json({ assets: { items: p.items, nextPage: p.nextPage, total: p.items.length } });
}
// Mirrors the real 3.x response: assetCount set, assets empty.
return json({
id: 'album-1',
albumName: 'Name',
assets: [
{ id: 'asset-1', isTrashed: false },
{ id: 'asset-2', isTrashed: true },
],
}),
assetCount: 2,
assets: [],
order: options.order ?? 'desc',
});
});
}
const asset = (id: string, date: string, isTrashed = false) => ({
id,
isTrashed,
fileCreatedAt: date,
});
it('loads assets via metadata search and filters trashed ones', async () => {
mockAlbumApi({
pages: [
{
items: [
asset('asset-1', '2024-01-02T00:00:00Z'),
asset('asset-2', '2024-01-03T00:00:00Z', true),
],
nextPage: null,
},
],
});
const album = await immich.getAlbum('album-1');
expect(album?.assets).toHaveLength(1);
expect(album?.assets[0].id).toBe('asset-1');
});
it('requests EXIF data — without it the grid, lightbox and map go blank', async () => {
mockAlbumApi({ pages: [{ items: [], nextPage: null }] });
await immich.getAlbum('album-1');
const search = mockFetch.mock.calls.find((c: unknown[]) =>
String(c[0]).includes('/search/metadata'),
);
expect(search).toBeDefined();
const body = JSON.parse((search[1] as { body: string }).body);
expect(body.withExif).toBe(true);
expect(body.albumIds).toEqual(['album-1']);
expect(search[1].method).toBe('POST');
});
it('follows pagination until nextPage is null', async () => {
mockAlbumApi({
pages: [
{ items: [asset('a', '2024-01-01T00:00:00Z')], nextPage: '2' },
{ items: [asset('b', '2024-01-02T00:00:00Z')], nextPage: '3' },
{ items: [asset('c', '2024-01-03T00:00:00Z')], nextPage: null },
],
});
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['c', 'b', 'a']); // desc by fileCreatedAt
});
it('honours the album sort order', async () => {
const items = [
asset('old', '2024-01-01T00:00:00Z'),
asset('new', '2024-06-01T00:00:00Z'),
asset('mid', '2024-03-01T00:00:00Z'),
];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
let album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['old', 'mid', 'new']);
cache.clear(); // same album id — otherwise the asc result is reused
mockAlbumApi({ order: 'desc', pages: [{ items, nextPage: null }] });
album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['new', 'mid', 'old']);
});
});
});

View File

@@ -1,48 +1,87 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { NextRequest } from 'next/server';
let mockHops = 0;
vi.mock('@/lib/config', () => ({
getConfig: () => ({ trustedProxyHops: mockHops }),
}));
import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
describe('getClientIp', () => {
it('prioritizes request.ip when present', () => {
const req = {
ip: '1.2.3.4',
headers: new Headers({
'x-real-ip': '5.6.7.8',
'x-forwarded-for': '9.10.11.12',
}),
} as unknown as NextRequest;
function req(headers: Record<string, string>, ip?: string): NextRequest {
return { ...(ip ? { ip } : {}), headers: new Headers(headers) } as unknown as NextRequest;
}
expect(getClientIp(req)).toBe('1.2.3.4');
describe('getClientIp with no proxy configured (TRUSTED_PROXY_HOPS=0)', () => {
beforeEach(() => {
mockHops = 0;
});
it('falls back to x-real-ip if request.ip is missing', () => {
const req = {
headers: new Headers({
'x-real-ip': '5.6.7.8',
'x-forwarded-for': '9.10.11.12',
}),
} as unknown as NextRequest;
expect(getClientIp(req)).toBe('5.6.7.8');
it('prioritizes request.ip when the platform provides it', () => {
expect(req({ 'x-real-ip': '5.6.7.8' }, '1.2.3.4')).toBeTruthy();
expect(getClientIp(req({ 'x-real-ip': '5.6.7.8' }, '1.2.3.4'))).toBe('1.2.3.4');
});
it('falls back to x-forwarded-for if request.ip and x-real-ip are missing', () => {
const req = {
headers: new Headers({
'x-forwarded-for': '9.10.11.12, 13.14.15.16',
}),
} as unknown as NextRequest;
// Should take the first IP from x-forwarded-for list
expect(getClientIp(req)).toBe('9.10.11.12');
it('falls back to headers on a best-effort basis', () => {
expect(getClientIp(req({ 'x-real-ip': '5.6.7.8' }))).toBe('5.6.7.8');
expect(getClientIp(req({ 'x-forwarded-for': '9.10.11.12, 13.14.15.16' }))).toBe('9.10.11.12');
});
it('returns unknown if no IP headers are present', () => {
const req = {
headers: new Headers(),
} as unknown as NextRequest;
it('returns unknown if no IP information is available at all', () => {
expect(getClientIp(req({}))).toBe('unknown');
});
});
expect(getClientIp(req)).toBe('unknown');
describe('getClientIp behind one trusted proxy (TRUSTED_PROXY_HOPS=1, e.g. nginx)', () => {
beforeEach(() => {
mockHops = 1;
});
it('takes the rightmost X-Forwarded-For entry, which the proxy appended itself', () => {
// nginx `$proxy_add_x_forwarded_for` appends the real peer address, so the
// last entry is authoritative no matter what the client sent.
expect(getClientIp(req({ 'x-forwarded-for': '203.0.113.9' }))).toBe('203.0.113.9');
});
it('ignores client-supplied entries to the left of the appended one', () => {
// The attacker sends `X-Forwarded-For: 1.1.1.1`; nginx appends their real IP.
const spoofed = req({ 'x-forwarded-for': '1.1.1.1, 203.0.113.9' });
expect(getClientIp(spoofed)).toBe('203.0.113.9');
});
it('cannot be pinned to an attacker-chosen bucket by a long forged chain', () => {
const forged = req({ 'x-forwarded-for': '1.1.1.1, 2.2.2.2, 3.3.3.3, 203.0.113.9' });
expect(getClientIp(forged)).toBe('203.0.113.9');
});
it('does not trust a spoofed X-Real-IP over the appended X-Forwarded-For entry', () => {
const spoofed = req({ 'x-real-ip': '1.1.1.1', 'x-forwarded-for': '9.9.9.9, 203.0.113.9' });
expect(getClientIp(spoofed)).toBe('203.0.113.9');
});
it('accepts X-Real-IP when the proxy sets only that header (nginx overwrites it)', () => {
expect(getClientIp(req({ 'x-real-ip': '203.0.113.9' }))).toBe('203.0.113.9');
});
it('refuses to identify a request that did not traverse the proxy', () => {
// No proxy headers at all, yet hops are configured: the request reached the
// app directly. Fall back to unknown rather than to a spoofable value.
expect(getClientIp(req({}))).toBe('unknown');
});
});
describe('getClientIp behind two trusted proxies (TRUSTED_PROXY_HOPS=2)', () => {
beforeEach(() => {
mockHops = 2;
});
it('skips both proxy hops from the right', () => {
const r = req({ 'x-forwarded-for': '203.0.113.9, 10.0.0.5' });
expect(getClientIp(r)).toBe('203.0.113.9');
});
it('returns unknown when the chain is shorter than the configured hop count', () => {
expect(getClientIp(req({ 'x-forwarded-for': '10.0.0.5' }))).toBe('unknown');
});
});

View File

@@ -5,14 +5,20 @@
import crypto from 'crypto';
import { env } from '../env';
import { resolveAuthSecret } from '../secret';
import { cookies } from 'next/headers';
const COOKIE_NAME = 'folio_admin_session';
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
function getSigningKey(): Buffer {
const secret = env.AUTH_SECRET || 'dev-fallback-secret';
return crypto.createHash('sha256').update(`admin:${secret}`).digest();
// Bind the key to ADMIN_PASSWORD as well, so rotating the password
// immediately invalidates every outstanding session token.
const secret = resolveAuthSecret();
return crypto
.createHash('sha256')
.update(`admin:${secret}:${env.ADMIN_PASSWORD ?? ''}`)
.digest();
}
/** Create a signed session token. */
@@ -37,7 +43,12 @@ export function verifyAdminToken(token: string): boolean {
const key = getSigningKey();
const expectedSig = crypto.createHmac('sha256', key).update(data).digest('base64url');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
// timingSafeEqual throws on length mismatch — guard first so a malformed
// cookie yields a clean 401 instead of an unhandled RangeError (500).
const sigBuf = Buffer.from(sig);
const expectedBuf = Buffer.from(expectedSig);
if (sigBuf.length !== expectedBuf.length) return false;
if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) {
return false;
}

View File

@@ -48,9 +48,17 @@ function hmac(data: string): string {
return crypto.createHmac('sha256', getConfig().authSecret).update(data).digest('hex');
}
function authToken(key: string, passwordSecret: string): string {
// Use the hash/password as part of the HMAC for a secure, unique session token
return hmac(`${key}:${passwordSecret}`);
/**
* Build a session token of the form `<expiryEpochMs>.<hmac>`.
*
* The expiry is part of the signed payload, so it cannot be extended by the
* client. Without it the token was a pure function of (slug, password) and
* therefore valid forever — the 24h lifetime existed only as the cookie's
* Max-Age, which the client enforces and an attacker replaying a captured
* token simply ignores.
*/
function authToken(key: string, passwordSecret: string, expiresAt: number): string {
return `${expiresAt}.${hmac(`${key}:${passwordSecret}:${expiresAt}`)}`;
}
function cookieName(key: string, type: 'subpage' | 'album'): string {
@@ -132,8 +140,8 @@ export function authenticate(
if (!isValid) return null;
const token = authToken(key, storedPassword);
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
const token = authToken(key, storedPassword, Date.now() + maxAge * 1000);
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
return `${cookieName(key, type)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict${secure}`;
@@ -154,10 +162,21 @@ export function isAuthenticated(
const cookie = getCookie(cookieName(key, type));
if (!cookie) return false;
const expected = authToken(key, storedPassword);
// Legacy tokens (bare HMAC, no expiry) do not parse here and are rejected;
// the visitor simply re-enters the password.
const sep = cookie.indexOf('.');
if (sep === -1) return false;
const expiresAt = Number(cookie.slice(0, sep));
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return false;
const expected = authToken(key, storedPassword, expiresAt);
// Constant-time comparison to prevent timing attacks
try {
return crypto.timingSafeEqual(Buffer.from(cookie, 'hex'), Buffer.from(expected, 'hex'));
const a = Buffer.from(cookie, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
} catch {
return false;
}

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;
}

View File

@@ -11,7 +11,7 @@ export interface Env {
CACHE_TTL: number;
RATE_LIMIT_RPM: number;
AUTH_SECRET?: string;
TRUSTED_PROXIES: string[];
TRUSTED_PROXY_HOPS: number;
WEBHOOK_SECRET?: string;
ADMIN_PASSWORD?: string;
}
@@ -39,6 +39,26 @@ function parseEnv(): Env {
const rateLimit =
rateLimitStr && !isNaN(parseInt(rateLimitStr, 10)) ? parseInt(rateLimitStr, 10) : 1500;
// Number of reverse proxies in front of the app. Determines how far from the
// right of X-Forwarded-For the real client IP sits. nginx/Traefik/Caddy = 1;
// add 1 for each additional layer (e.g. Cloudflare in front of nginx = 2).
const hopsStr = process.env.TRUSTED_PROXY_HOPS;
let trustedProxyHops = hopsStr && !isNaN(parseInt(hopsStr, 10)) ? parseInt(hopsStr, 10) : 0;
// TRUSTED_PROXIES (removed) matched proxy IPs against the socket peer address,
// which self-hosted Next.js never exposes — so it silently disabled per-client
// rate limiting and lumped every visitor into one shared bucket. Migrate the
// common single-proxy case rather than leaving those deploys broken.
if (trustedProxyHops === 0 && process.env.TRUSTED_PROXIES) {
trustedProxyHops = 1;
console.warn(
'\n⚠️ TRUSTED_PROXIES is deprecated and has no effect. Assuming TRUSTED_PROXY_HOPS=1.\n' +
' Set TRUSTED_PROXY_HOPS explicitly (nginx/Traefik/Caddy = 1) and remove TRUSTED_PROXIES.\n',
);
}
if (trustedProxyHops < 0) trustedProxyHops = 0;
return {
IMMICH_API_URL: apiUrl,
IMMICH_API_KEY: apiKey as string,
@@ -47,10 +67,7 @@ function parseEnv(): Env {
CACHE_TTL: Math.max(0, cacheTtl),
RATE_LIMIT_RPM: Math.max(1, rateLimit),
AUTH_SECRET: process.env.AUTH_SECRET,
TRUSTED_PROXIES: (process.env.TRUSTED_PROXIES || '')
.split(',')
.map((ip) => ip.trim())
.filter((ip) => ip !== ''),
TRUSTED_PROXY_HOPS: trustedProxyHops,
WEBHOOK_SECRET: process.env.WEBHOOK_SECRET || undefined,
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || undefined,
};

View File

@@ -80,16 +80,19 @@ class ImmichClient {
return getConfig();
}
private async request<T>(endpoint: string): Promise<T | null> {
private async request<T>(endpoint: string, body?: unknown): Promise<T | null> {
if (this.config.needsSetup) return null;
const url = `${this.config.immich.apiUrl}${endpoint}`;
try {
const res = await fetch(url, {
method: body === undefined ? 'GET' : 'POST',
headers: {
'x-api-key': this.config.immich.apiKey,
Accept: 'application/json',
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (!res.ok) {
@@ -320,6 +323,49 @@ class ImmichClient {
/**
* Get a single album with its assets.
*/
/**
* Fetch every asset belonging to an album.
*
* Immich 3.x no longer embeds assets in `GET /albums/:id` — the response
* still carries `assetCount` but `assets` comes back empty, and
* `?withoutAssets=false` does not change that. Metadata search is the
* supported replacement.
*
* `withExif` is required: without it the response omits `exifInfo`, and the
* grid (aspect ratios), the lightbox EXIF panel and the map (GPS) all go
* blank even though the images themselves load.
*/
private async fetchAlbumAssets(albumId: string): Promise<ImmichAsset[]> {
const PAGE_SIZE = 1000; // Immich rejects size > 1000 with a validation error
const MAX_PAGES = 100; // Backstop against a malformed nextPage looping forever
const assets: ImmichAsset[] = [];
let page = 1;
for (let i = 0; i < MAX_PAGES; i++) {
const res = await this.request<{
assets?: { items?: ImmichAsset[]; nextPage?: string | null };
}>('/search/metadata', {
albumIds: [albumId],
withExif: true,
size: PAGE_SIZE,
page,
});
const items = res?.assets?.items;
if (!items?.length) break;
assets.push(...items);
// nextPage is a string page number, or null on the last page. The
// response's `total` only counts the current page, so it cannot bound
// this loop.
const next = Number(res?.assets?.nextPage);
if (!Number.isFinite(next) || next <= page) break;
page = next;
}
return assets;
}
async getAlbum(albumId: string): Promise<ImmichAlbum | null> {
// Security: only serve configured albums
if (!this.config.albums.includes(albumId)) {
@@ -337,11 +383,34 @@ class ImmichClient {
const promise = (async () => {
try {
const album = await this.request<ImmichAlbum>(`/albums/${encodeURIComponent(albumId)}`);
// Metadata and assets come from two different endpoints (see
// fetchAlbumAssets); they are independent, so fetch them together.
const [album, assets] = await Promise.all([
this.request<ImmichAlbum>(`/albums/${encodeURIComponent(albumId)}`),
this.fetchAlbumAssets(albumId),
]);
if (!album) return null;
// An album that Immich says has photos but returns none is almost
// always a server older than 3.0, where metadata search behaves
// differently. Say so instead of rendering a silently empty gallery.
if (album.assetCount > 0 && assets.length === 0) {
console.warn(
`[Immich] Album "${album.albumName}" reports ${album.assetCount} assets but the metadata search returned none. ` +
`Immich Folio requires Immich 3.0 or newer.`,
);
}
// Filter out trashed assets
album.assets = (album.assets || []).filter((a) => !a.isTrashed);
album.assets = assets.filter((a) => !a.isTrashed);
// The album endpoint used to return assets in the album's configured
// order. Metadata search happens to default to fileCreatedAt desc, but
// that is not contractual — sort explicitly so 'asc' albums are right.
const dir = album.order === 'asc' ? 1 : -1;
album.assets.sort(
(a, b) => dir * (Date.parse(a.fileCreatedAt) - Date.parse(b.fileCreatedAt)),
);
const name = this.config.albumOverrides[album.id] ?? album.albumName;
const description = this.config.albumDescriptions[album.id] ?? album.description ?? '';

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);

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 {

35
lib/secret.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* Single source of truth for AUTH_SECRET resolution.
*
* Fails closed in production: a missing AUTH_SECRET throws rather than falling
* back to a guessable constant. In development an ephemeral per-process secret
* is generated instead, so sessions simply do not survive a restart.
*/
import crypto from 'crypto';
import { env } from './env';
let _fallbackSecret: string | null = null;
let _warned = false;
export function resolveAuthSecret(): string {
const secret = env.AUTH_SECRET;
if (secret) return 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');
}
if (!_warned) {
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',
);
_warned = true;
}
return _fallbackSecret;
}

View File

@@ -8,6 +8,7 @@
import crypto from 'crypto';
import { getConfig } from './config';
import { isUuid } from './uuid';
let _key: Buffer | null = null;
@@ -72,8 +73,7 @@ export function decodeAssetId(token: string): string | null {
}
// Validate it looks like a UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(decrypted) ? decrypted : null;
return isUuid(decrypted) ? decrypted : null;
} catch {
return null;
}

14
lib/uuid.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Shared UUID validation.
*
* Immich object IDs are interpolated into upstream API URLs, so anything that
* reaches `fetch()` must be shape-checked first: the URL constructor resolves
* `..` segments, which would otherwise turn an album ID into an arbitrary
* Immich endpoint reached with the server's API key.
*/
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isUuid(value: string): boolean {
return UUID_REGEX.test(value);
}