photos: reach immich's private folder on an in-memory session

Locked assets are gated on elevation, and elevation lives on a session — an API key
has no `auth.session`, so no key permission or allow-list entry can reach them. The
gate sits inside the generic owner-access check, so a locked asset's thumbnail and
original are covered too, not just its listings.

So `/_locked` mints a session at unlock, holds it in memory for the elevation window,
and closes it on lock, on idle, or when the active immich account changes. Nothing new
is written to photos_config: the pin and the password are never at rest, and a full
compromise of officer's database still does not open the folder. The cost is that
unlocking asks for the immich password as well as the pin.

`auth/*` stays refused wholesale in routes.ts. The four auth routes this needs are
reached through named endpoints that each do one thing, and the elevated forward
carries three resources rather than the main allow-list.

Not yet exercised at runtime — the sidecar has not run this code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 11:02:22 +00:00
co-authored by Claude Opus 5
parent 8987898dd7
commit ea59b5f1a7
4 changed files with 431 additions and 32 deletions
+331
View File
@@ -0,0 +1,331 @@
import type { UpstreamConfig } from './upstream';
import { relayResponse } from './routes';
import { UpstreamError, callUpstream, login, logout } from './upstream';
// `/_locked` — Immich's private folder, which is the one part of the library the stored API key cannot reach.
//
// WHY THIS IS A SEPARATE SURFACE RATHER THAN A FEW MORE ENTRIES IN routes.ts
//
// Immich gates locked assets on ELEVATION, and elevation lives on a session. Verified against the running
// 3.1.0 image rather than inferred:
//
// utils/access.js requireElevatedPermission → `if (!auth.session?.hasElevatedPermission) throw`
// services/auth.service.js unlockSession → `if (!auth.session) throw 'only be used with a session token'`
// services/session.service.js create → the same guard, so a key cannot even mint a session to escape this
// repositories/access.repository.js checkOwnerAccess → `.$if(!hasElevatedPermission, visibility != locked)`
//
// An API key has no session, so `auth.session` is undefined and the check fails unconditionally. No key
// permission, scope or allow-list entry changes that. The last of those four is the one that decides the shape
// of this file: the elevation test is inside the generic owner-access check, so it covers `AssetView` and
// `AssetDownload` too — a locked asset's THUMBNAIL and ORIGINAL are gated, not merely its listings.
//
// So reading the private folder needs a session, a session needs a password, and Officer deliberately does not
// keep one (see upstream.ts: the password is used to mint a key and discarded with the stack frame). The
// resolution here is that the session is minted at unlock, held IN MEMORY for the elevation window, and closed
// on lock or idle. Nothing new is written to `photos_config`, so a full compromise of Officer's database still
// does not open the private folder — the PIN and the password are never at rest here.
//
// The cost is that unlocking asks for the Immich password as well as the PIN. That is a real cost and the
// alternative is a stored session token, which would make unlock PIN-only. It was left unbuilt because it is a
// standing security-posture change and this is not: everything below is forgotten when the process restarts.
//
// The general allow-list in routes.ts still refuses `auth/*` wholesale. This file does not widen it — it
// reaches four specific auth routes through named endpoints that each do one thing, which is the narrowing the
// request asked for rather than a door left open for anything under `auth/`.
/** What the API key alone can do, which is more than expected and worth being precise about. */
const PIN_PATH = '/api/auth/pin-code';
/**
* Immich holds elevation for 15 minutes and slides it forward while a session keeps working. We do not mirror
* that arithmetic — Immich stays the authority and a 401 collapses our copy — but a session nobody has used
* for longer than the window can never be elevated again, so it is closed rather than left open indefinitely.
*/
const IDLE_MS = 20 * 60 * 1000;
/** Immich's own rule, checked here so a typo is a clear message instead of a zod dump from upstream. */
const PIN_RE = /^\d{6}$/;
type Elevation = { accountId: number; base: string; token: string; lastUsedAt: number };
// Keyed by Officer user. The value never leaves this module and is never persisted.
const elevations = new Map<number, Elevation>();
/** Close a session upstream and forget it. Safe to call when there is nothing to forget. */
async function drop(userId: number): Promise<void> {
const current = elevations.get(userId);
if (!current) return;
elevations.delete(userId);
await logout(current.base, current.token);
}
/**
* The live session for this user, or null.
*
* Switching Immich accounts while unlocked has to invalidate it: the session belongs to the account it was
* minted against, and serving the previous library's private folder after a switch would be the worst possible
* version of the caching bug the config routes already guard against.
*/
function current(userId: number, cfg: UpstreamConfig): Elevation | null {
const found = elevations.get(userId);
if (!found) return null;
if (found.accountId !== cfg.id || Date.now() - found.lastUsedAt > IDLE_MS) {
void drop(userId);
return null;
}
return found;
}
const bad = (error: string, status = 400) => Response.json({ error }, { status });
/** 423 is the signal the UI turns back into the unlock prompt; 401 would read as "signed out of Officer". */
const locked = (error = 'the private folder is locked') => Response.json({ error, unlocked: false }, { status: 423 });
type Body = { pinCode?: unknown; newPinCode?: unknown; password?: unknown };
const readBody = async (req: Request): Promise<Body> => ((await req.json().catch(() => null)) as Body | null) ?? {};
const str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
/** Immich answers `{ message }`; anything else means we never really reached it. */
async function message(res: Response, fallback: string): Promise<string> {
const body = (await res.json().catch(() => null)) as { message?: unknown } | null;
return typeof body?.message === 'string' ? body.message : fallback;
}
/**
* The email to sign in as, taken from the key rather than from the caller.
*
* The client knows the password but has no reason to know which Immich account the stored key belongs to, and
* asking it to send an email would let a wrong one turn a valid password into "sign-in failed".
*/
async function ownerEmail(cfg: UpstreamConfig): Promise<string> {
const res = await callUpstream(cfg, { path: '/api/users/me' });
if (!res.ok) throw new UpstreamError(await message(res, 'could not identify the account'), res.status);
const me = (await res.json()) as { email?: unknown };
if (typeof me.email !== 'string' || !me.email) throw new UpstreamError('the account has no email', 500);
return me.email;
}
/**
* `GET /_locked/status` — whether a PIN exists, and whether this user is currently unlocked.
*
* Answered through the session when there is one, because only then does Immich report `isElevated` and
* `pinExpiresAt` truthfully; the API key can still answer the two questions that do not involve a session.
* `hasPassword` is here so a password-less OAuth account can be told that unlocking is impossible rather than
* being handed a form that cannot work.
*/
async function status(cfg: UpstreamConfig, userId: number): Promise<Response> {
const session = current(userId, cfg);
const res = await callUpstream(cfg, { path: '/api/auth/status', bearer: session?.token });
if (!res.ok) return bad(await message(res, 'could not read the account status'), res.status);
const info = (await res.json()) as {
pinCode?: boolean;
password?: boolean;
isElevated?: boolean;
pinExpiresAt?: string;
};
// Immich disagreeing with us is Immich winning: the window lapsed underneath a session we still hold.
if (session && !info.isElevated) await drop(userId);
return Response.json({
hasPin: !!info.pinCode,
hasPassword: !!info.password,
unlocked: !!info.isElevated,
expiresAt: info.pinExpiresAt ?? null,
});
}
/**
* `POST /_locked/unlock` — sign in, elevate, keep the session.
*
* A wrong PIN has to close the session it just opened. Immich lists every sign-in as a device, so leaking one
* per failed attempt would turn a mistyped PIN into a growing list of phantom devices in the owner's account.
*/
async function unlock(req: Request, cfg: UpstreamConfig, userId: number): Promise<Response> {
const body = await readBody(req);
const password = typeof body.password === 'string' ? body.password : '';
const pinCode = str(body.pinCode);
if (!password) return bad('the Immich account password is required to unlock');
if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits');
let token: string;
try {
token = await login(cfg.base, await ownerEmail(cfg), password);
} catch (err) {
if (err instanceof UpstreamError) return bad(err.message, err.status === 401 ? 401 : 400);
return bad(`could not reach the instance (${String(err)})`, 502);
}
const res = await callUpstream(cfg, {
path: '/api/auth/session/unlock',
method: 'POST',
bearer: token,
body: JSON.stringify({ pinCode }),
contentType: 'application/json',
});
if (!res.ok) {
await logout(cfg.base, token);
return bad(await message(res, 'the PIN was rejected'), res.status === 400 ? 400 : res.status);
}
// Replace rather than accumulate: unlocking twice should not leave the first session open.
await drop(userId);
elevations.set(userId, { accountId: cfg.id, base: cfg.base, token, lastUsedAt: Date.now() });
return Response.json({ unlocked: true });
}
/** `POST /_locked/lock` — give up elevation immediately rather than waiting for it to lapse. */
async function lock(cfg: UpstreamConfig, userId: number): Promise<Response> {
const session = current(userId, cfg);
if (session) {
await callUpstream(cfg, { path: '/api/auth/session/lock', method: 'POST', bearer: session.token });
await drop(userId);
}
return Response.json({ unlocked: false });
}
/**
* `/_locked/pin` — set up, change and remove the PIN, all on the API key.
*
* These genuinely do not need a session: `setupPinCode` and `validatePinCode` read `auth.user.id` and never
* touch `auth.session` (auth.service.js), so the key is enough. That is what makes the private folder
* something Officer can offer end to end instead of sending the owner to Immich's own settings screen first.
*/
async function pin(req: Request, cfg: UpstreamConfig, userId: number): Promise<Response> {
const body = await readBody(req);
const pinCode = str(body.pinCode);
const newPinCode = str(body.newPinCode);
const password = typeof body.password === 'string' ? body.password : '';
const call = (method: string, payload: Record<string, string>) =>
callUpstream(cfg, { path: PIN_PATH, method, body: JSON.stringify(payload), contentType: 'application/json' });
if (req.method === 'POST') {
if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits');
const res = await call('POST', { pinCode });
return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not set the PIN'), res.status);
}
// Change and reset both prove intent with the OLD pin or the account password, so the two are read together.
const proof: Record<string, string> | null = password ? { password } : pinCode ? { pinCode } : null;
if (!proof) return bad('the current PIN, or the account password, is required');
if (req.method === 'PUT') {
if (!PIN_RE.test(newPinCode)) return bad('the new PIN is six digits');
const res = await call('PUT', { ...proof, newPinCode });
return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not change the PIN'), res.status);
}
const res = await call('DELETE', proof);
if (!res.ok) return bad(await message(res, 'could not remove the PIN'), res.status);
// Immich drops elevation from every session when the PIN is reset; our copy has to go with it.
await drop(userId);
return Response.json({ hasPin: false, unlocked: false });
}
/**
* The elevated forward, deliberately a fraction of the main allow-list.
*
* Three resources, because three are enough to see the folder and move things in and out of it: read the
* assets and their bytes, list them, and change their visibility. DELETE is absent on purpose — emptying the
* trash or destroying an asset is reachable on the ordinary surface once it is out of the folder, and an
* irreversible route is not worth putting behind a credential the owner cannot see.
*/
const ELEVATED: Record<string, readonly string[]> = {
assets: ['GET', 'PUT'],
search: ['POST'],
timeline: ['GET'],
};
/** `/_locked/api/<path>` — the same shape as `/_officer/<path>`, but on the session and far narrower. */
async function forward(req: Request, url: URL, cfg: UpstreamConfig, userId: number, rest: string): Promise<Response> {
// The allow-list is checked BEFORE the session, and the order is deliberate. A resource this surface will
// never carry has to say so whether or not the folder happens to be open — answering 423 would invite a
// client to prompt for a password and a PIN to reach something that stays a 404 afterwards.
const [resource] = rest.split('/');
const allowed = resource ? ELEVATED[resource] : undefined;
if (!allowed) return Response.json({ error: 'not found' }, { status: 404 });
if (!allowed.includes(req.method)) return bad(`${req.method} not allowed on ${resource}`, 405);
const session = current(userId, cfg);
if (!session) return locked();
// Officer's own JWT arrives in `?token=` because an <img> cannot send a header. Forwarding it would write the
// owner's session credential into Immich's access log for every locked thumbnail.
const query = new URLSearchParams(url.search);
query.delete('token');
const search = query.toString();
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
const res = await callUpstream(cfg, {
path: `/api/${rest}`,
method: req.method,
query: search ? `?${search}` : '',
body: hasBody ? await req.arrayBuffer() : null,
contentType: req.headers.get('content-type'),
range: req.headers.get('range'),
ifNoneMatch: req.headers.get('if-none-match'),
bearer: session.token,
});
// The window closed underneath us. Collapse to locked so the UI prompts again instead of showing an error.
if (res.status === 401) {
await drop(userId);
return locked('the private folder locked itself again');
}
session.lastUsedAt = Date.now();
return relayResponse(res);
}
/** `subpath` is the part after `/_locked`, e.g. '/status' or '/api/assets/<id>/thumbnail'. */
export async function handleLockedRoute(
req: Request,
cfg: UpstreamConfig,
userId: number,
subpath: string,
): Promise<Response> {
const url = new URL(req.url);
const [, head, ...tail] = subpath.split('/');
if (head === 'api') return forward(req, url, cfg, userId, tail.join('/'));
if (head === 'status') {
return req.method === 'GET' ? status(cfg, userId) : bad('method not allowed', 405);
}
if (head === 'unlock') {
return req.method === 'POST' ? unlock(req, cfg, userId) : bad('method not allowed', 405);
}
if (head === 'lock') {
return req.method === 'POST' ? lock(cfg, userId) : bad('method not allowed', 405);
}
if (head === 'pin') {
const allowed = req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE';
return allowed ? pin(req, cfg, userId) : bad('method not allowed', 405);
}
return Response.json({ error: 'not found' }, { status: 404 });
}
/**
* Close sessions nobody came back for.
*
* Without this an owner who unlocks and then closes the tab leaves a signed-in device in their Immich account
* until the sidecar restarts. Elevation is long gone by then, so the session is useless to us and untidy there.
*/
setInterval(() => {
const now = Date.now();
for (const [userId, session] of elevations) {
if (now - session.lastUsedAt > IDLE_MS) void drop(userId);
}
}, 60_000).unref();