Files
immich-folio/lib/auth.ts
Ralksta 37f7f62157 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

184 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Album authentication helpers.
* Uses HMAC tokens stored in HttpOnly cookies — no database needed.
*
* Token = HMAC-SHA256(slug + passwordSecret, authSecret)
* Cookie = lb_auth_<slug> = <token>
*/
import crypto from 'crypto';
import { getConfig, SubpageConfig } from './config';
const TOKEN_EXPIRY_HOURS = 24;
/**
* Check if a string looks like a legacy bcrypt hash.
*/
function isBcryptHash(str: string): boolean {
return str.startsWith('$2a$') || str.startsWith('$2b$') || str.startsWith('$2y$');
}
/**
* Native SCrypt verify (format: 'scrypt:salt:hash_hex')
*/
function verifyScrypt(password: string, stored: string): boolean {
if (!stored.startsWith('scrypt:')) return false;
try {
const [, salt, hashHex] = stored.split(':');
const key = crypto.scryptSync(password, salt, 64);
const expectedKey = Buffer.from(hashHex, 'hex');
if (key.length !== expectedKey.length) return false;
return crypto.timingSafeEqual(key, expectedKey);
} catch {
return false;
}
}
/**
* Helper to generate an scrypt string to print in logs.
*/
function generateScryptHash(password: string): string {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
return `scrypt:${salt}:${hash}`;
}
function hmac(data: string): string {
return crypto.createHmac('sha256', getConfig().authSecret).update(data).digest('hex');
}
/**
* 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 {
return type === 'subpage' ? `lb_auth_${key}` : `lb_auth_album_${key}`;
}
/**
* Find the SubpageConfig for a given slug.
*/
export function findSubpageBySlug(slug: string): SubpageConfig | undefined {
return getConfig().subpages.find((sp) => sp.slug === slug);
}
/**
* Find the password secret for a given subpage slug or album ID.
*/
function findPassword(key: string, type: 'subpage' | 'album'): string | undefined {
const config = getConfig();
if (type === 'subpage') {
return config.subpages.find((sp) => sp.slug === key)?.password;
}
return config.albumPasswords[key];
}
/**
* Check if a subpage slug or album ID is password-protected.
*/
export function isProtected(key: string, type: 'subpage' | 'album' = 'subpage'): boolean {
return !!findPassword(key, type);
}
/**
* Validate a password attempt and return a Set-Cookie header value on success.
* Returns null if the password is wrong.
*/
export function authenticate(
key: string,
password: string,
type: 'subpage' | 'album' = 'subpage',
): string | null {
const storedPassword = findPassword(key, type);
if (!storedPassword) return null;
let isValid = false;
if (isBcryptHash(storedPassword)) {
console.error(
`\n❌ SECURITY ERROR: ${type === 'subpage' ? 'Subpage' : 'Album'} "${key}" is using an outdated bcrypt password hash.\n` +
` Bcrypt dependency has been removed to reduce bundle size.\n` +
` Please switch temporarily to plaintext in your gallery.yaml, log in again\n` +
` to see your new secure "scrypt:..." hash in the logs, and update your file.\n`,
);
return null;
}
if (storedPassword.startsWith('scrypt:')) {
isValid = verifyScrypt(password, storedPassword);
} else {
// Plaintext fallback (deprecated)
// Hash both to a fixed length before constant-time comparison to prevent timing and length attacks
const attemptHash = crypto
.createHmac('sha256', getConfig().authSecret)
.update(password)
.digest();
const storedHash = crypto
.createHmac('sha256', getConfig().authSecret)
.update(storedPassword)
.digest();
isValid = crypto.timingSafeEqual(attemptHash, storedHash);
if (isValid) {
const recommendedHash = generateScryptHash(storedPassword);
console.warn(
`\n⚠️ SECURITY WARNING: ${type === 'subpage' ? 'Subpage' : 'Album'} "${key}" is using a plaintext password in gallery.yaml.\n` +
` Please replace it with this native secure hash:\n\n ${recommendedHash}\n`,
);
}
}
if (!isValid) return null;
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}`;
}
/**
* Check if the request has a valid auth cookie for the given subpage or album.
* Works with the cookies() API from Next.js server components.
*/
export function isAuthenticated(
key: string,
getCookie: (name: string) => string | undefined,
type: 'subpage' | 'album' = 'subpage',
): boolean {
const storedPassword = findPassword(key, type);
if (!storedPassword) return true; // not protected
const cookie = getCookie(cookieName(key, type));
if (!cookie) return false;
// 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 {
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;
}
}