Files
immich-folio/lib/tokens.ts

85 lines
3.0 KiB
TypeScript
Raw Normal View History

/**
* Token encoding/decoding for asset IDs.
* Obfuscates Immich UUIDs in public-facing URLs using AES encryption.
*
* Uses deterministic encryption (IV derived from asset ID) so the same
* asset always produces the same token — essential for browser caching.
*/
import crypto from 'crypto';
import { getConfig } from './config';
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>
2026-07-20 18:57:57 +02:00
import { isUuid } from './uuid';
let _key: Buffer | null = null;
function getKey(): Buffer {
if (!_key) {
const authSecret = getConfig().authSecret;
_key = crypto.createHash('sha256').update(authSecret).digest();
}
return _key;
}
/**
* Encode an asset ID into an opaque URL-safe token.
* Note: The AES-GCM deterministic IV causes the identical input asset ID
* to encrypt to the exact same cipher token. This provides URL obfuscation
* and caching consistency, but it does NOT provide k-anonymous cryptographic
* security guarantees against recognizing identical items if intercepted.
*/
export function encodeAssetId(assetId: string): string {
const key = getKey();
// Deterministic IV from asset ID (same input → same token).
// SHA-256 slice → first 16 bytes. MD5 was deprecated for cryptographic use.
const iv = crypto.createHash('sha256').update(assetId).digest().subarray(0, 16);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(assetId, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
// Compact: base64url(iv + authTag + ciphertext) with v2 prefix
const payload = Buffer.concat([iv, authTag, encrypted]).toString('base64url');
return `v2:${payload}`;
}
/**
* Decode a token back into an asset ID.
* Returns null if the token is invalid.
*/
export function decodeAssetId(token: string): string | null {
// 🛡️ SECURITY: Enforce maximum token length to prevent memory exhaustion DoS
// A v2 token for a 36-char UUID is ~110 chars. 256 is generous but safe.
if (token.length > 256) return null;
try {
const key = getKey();
let decrypted: string;
if (token.startsWith('v2:')) {
const data = Buffer.from(token.slice(3), 'base64url');
if (data.length < 33) return null; // iv(16) + authTag(16) + at least 1 byte
const iv = data.subarray(0, 16);
const authTag = data.subarray(16, 32);
const encrypted = data.subarray(32);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
} else {
// Legacy aes-256-cbc fallback
const data = Buffer.from(token, 'base64url');
if (data.length < 17) return null; // iv(16) + at least 1 byte
const iv = data.subarray(0, 16);
const encrypted = data.subarray(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
// Validate it looks like a UUID
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>
2026-07-20 18:57:57 +02:00
return isUuid(decrypted) ? decrypted : null;
} catch {
return null;
}
}