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
+20
View File
@@ -1,6 +1,7 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { handleConfigRoute, noteProbe, probe } from './config';
import { handleLockedRoute } from './locked';
import { handleOfficerRoute } from './routes';
import { getConfig } from './upstream';
@@ -27,6 +28,13 @@ import { getConfig } from './upstream';
// POST /_config/:id/activate switch to that account
// DEL /_config/:id remove it; the newest survivor is promoted if it was the active one
// * /_officer/<path> forwarded to <active url>/api/<path>, first-segment allow-list (routes.ts)
//
// GET /_locked/status { hasPin, hasPassword, unlocked, expiresAt } — the private folder's state
// POST /_locked/pin { pinCode } set one up; PUT changes it, DELETE removes it (API key is enough)
// POST /_locked/unlock { password, pinCode } — signs in, elevates, holds the session IN MEMORY
// POST /_locked/lock drops elevation and closes the session
// * /_locked/api/<path> forwarded on that session — assets, search and timeline only (locked.ts)
//
// anything else 404
//
// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind
@@ -114,6 +122,18 @@ const server = Bun.serve({
}
}
if (url.pathname === '/_locked' || url.pathname.startsWith('/_locked/')) {
if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 });
try {
return await handleLockedRoute(req, cfg, userId, url.pathname.slice('/_locked'.length));
} catch (err) {
// Never let the error text out: this surface handles a password and a PIN, and an upstream failure
// carrying either into a response body or a log line is the one leak worth being paranoid about.
console.error(`[photos] ${req.method} ${url.pathname} failed`);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
+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();
+24 -9
View File
@@ -13,6 +13,12 @@ import { callUpstream } from './upstream';
// DELIBERATELY NOT EXPOSED: admin/* (user creation, deletion, quotas), auth/* and oauth/* (session and
// password machinery — the key is the credential here, and nothing should be minting sessions through a
// dashboard), api-keys/* (a proxy that can mint its own credentials is not a proxy), sessions/*, jobs/*
//
// `auth/*` is STILL refused here, and locked.ts does not change that. Immich's private folder needs four auth
// routes (`auth/status`, `auth/pin-code`, `auth/session/unlock`, `auth/session/lock`), and they are reached
// through named endpoints on a separate `/_locked` surface that each do one thing — not by making `auth` a
// resource anything may address. The distinction is the point: the decision that was taken was "the sidecar
// may unlock the private folder", not "the sidecar may reach session and password machinery".
// (queue control), system-config/* and system-metadata/* (rewrites the deployment), libraries/* (external
// library paths and scans) and sync/*. Those either mutate the deployment or can lock the owner out of it.
// Adding one should be a decision, not an accident.
@@ -60,6 +66,23 @@ const PASSTHROUGH_HEADERS = [
'cache-control',
] as const;
/**
* Hand one Immich response back to the caller, keeping only the headers above.
*
* Shared with the locked-folder surface so both forwarders stream rather than buffer and agree on what a
* thumbnail is allowed to carry back. 204 and 304 must not have a body attached — Bun throws if one is.
*/
export function relayResponse(res: Response): Response {
const headers = new Headers();
for (const name of PASSTHROUGH_HEADERS) {
const value = res.headers.get(name);
if (value) headers.set(name, value);
}
const body = res.status === 204 || res.status === 304 ? null : res.body;
return new Response(body, { status: res.status, headers });
}
/**
* Forward one request to Immich and stream the answer back.
*
@@ -96,13 +119,5 @@ export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url:
ifNoneMatch: req.headers.get('if-none-match'),
});
const headers = new Headers();
for (const name of PASSTHROUGH_HEADERS) {
const value = res.headers.get(name);
if (value) headers.set(name, value);
}
// 204 and 304 must not carry a body, and Bun throws if one is attached.
const body = res.status === 204 || res.status === 304 ? null : res.body;
return new Response(body, { status: res.status, headers });
return relayResponse(res);
}
+56 -23
View File
@@ -79,25 +79,19 @@ async function upstreamMessage(res: Response, fallback: string): Promise<string>
}
/**
* Trade an email and password for an API key, so connecting Officer does not mean a trip to Immich's own
* settings screen.
* Trade an email and password for a SESSION token.
*
* Three calls, and the shape of them is the point:
* A session is not an API key and the difference is load-bearing in both directions: sessions are listed as
* devices, can be revoked from a phone, and authenticate with `Authorization: Bearer` rather than the
* `x-api-key` header everything else here uses — but they are also the ONLY credential Immich will elevate.
* `POST /auth/session/unlock` and `POST /sessions` both open with `if (!auth.session) throw` (verified against
* the running 3.1.0 image), so an API key can never reach the locked folder and can never mint a session to
* get there. That is why this is exported rather than inlined into mintApiKey.
*
* 1. `/auth/login` returns a SESSION token, not an API key. Storing that would be wrong — sessions are
* listed as devices, can be revoked from a phone, and authenticate with `Authorization: Bearer` rather
* than the `x-api-key` header everything else here uses.
* 2. `/api-keys` mints the durable credential. `permissions` is required and NOT optional; `all` is only
* grantable because a session has every permission — an API key can never mint a key stronger than
* itself (`Cannot grant permissions you do not have`). Asking for `all` here is deliberate: Immich keys
* are scoped, and an under-scoped one returns 403 per route, which reads as a broken feature.
* 3. `/auth/logout` ends the session we only needed to hold the key. Skipping it would leave a phantom
* "device" in the owner's Immich account for every connection made from Officer.
*
* The password is used here and nowhere else; only the key survives this function.
* The password is used here and nowhere else. Callers hold the returned token or discard it; nothing stores it.
*/
export async function mintApiKey(base: string, email: string, password: string): Promise<string> {
const login = await fetch(`${base}/api/auth/login`, {
export async function login(base: string, email: string, password: string): Promise<string> {
const res = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
@@ -105,13 +99,43 @@ export async function mintApiKey(base: string, email: string, password: string):
});
// Immich says why in the body, and its two 401s mean different things — "Incorrect email or password" versus
// "Password login has been disabled" on an OAuth-only instance. Guessing from the status would flatten them.
if (!login.ok) throw new UpstreamError(await upstreamMessage(login, 'sign-in failed'), login.status);
if (!res.ok) throw new UpstreamError(await upstreamMessage(res, 'sign-in failed'), res.status);
const session = (await login.json()) as { accessToken?: unknown };
const session = (await res.json()) as { accessToken?: unknown };
if (typeof session.accessToken !== 'string' || !session.accessToken) {
throw new UpstreamError('sign-in returned no access token', login.status);
throw new UpstreamError('sign-in returned no access token', res.status);
}
const bearer = { Authorization: `Bearer ${session.accessToken}` };
return session.accessToken;
}
/**
* End a session. Best effort by design: a session we cannot close is untidy, not a failure of whatever the
* caller was actually doing. Skipping it entirely would leave a phantom "device" in the owner's Immich
* account for every sign-in Officer makes.
*/
export async function logout(base: string, token: string): Promise<void> {
await fetch(`${base}/api/auth/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
redirect: 'manual',
}).catch(() => {});
}
/**
* Trade an email and password for an API key, so connecting Officer does not mean a trip to Immich's own
* settings screen.
*
* `/api-keys` mints the durable credential. `permissions` is required and NOT optional; `all` is only
* grantable because a session has every permission — an API key can never mint a key stronger than itself
* (`Cannot grant permissions you do not have`). Asking for `all` here is deliberate: Immich keys are scoped,
* and an under-scoped one returns 403 per route, which reads as a broken feature.
*
* The session exists only to hold the key and is closed in the `finally`. The password is used here and
* nowhere else; only the key survives this function.
*/
export async function mintApiKey(base: string, email: string, password: string): Promise<string> {
const accessToken = await login(base, email, password);
const bearer = { Authorization: `Bearer ${accessToken}` };
try {
const created = await fetch(`${base}/api/api-keys`, {
@@ -130,8 +154,7 @@ export async function mintApiKey(base: string, email: string, password: string):
}
return payload.secret;
} finally {
// Best effort: a session we cannot close is untidy, not a failure of the thing the owner asked for.
await fetch(`${base}/api/auth/logout`, { method: 'POST', headers: bearer, redirect: 'manual' }).catch(() => {});
await logout(base, accessToken);
}
}
@@ -148,13 +171,23 @@ type CallOptions = {
ifNoneMatch?: string | null;
/** Set false for the version/ping routes, which Immich serves unauthenticated. */
withKey?: boolean;
/**
* A session token to authenticate as, INSTEAD of the stored API key.
*
* Only the locked-folder surface sets this (see locked.ts). Elevation lives on a session, so a request that
* must see `visibility: 'locked'` assets has to carry one; everything else stays on the key. The two are
* mutually exclusive on purpose — sending both would leave which credential Immich actually used up to its
* header precedence rather than to this file.
*/
bearer?: string;
};
/** The single door to Immich. Everything the sidecar fetches goes through here. */
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (opts.withKey !== false) headers['x-api-key'] = cfg.key;
if (opts.bearer) headers.Authorization = `Bearer ${opts.bearer}`;
else if (opts.withKey !== false) headers['x-api-key'] = cfg.key;
if (opts.contentType) headers['Content-Type'] = opts.contentType;
if (opts.range) headers.Range = opts.range;
if (opts.ifNoneMatch) headers['If-None-Match'] = opts.ifNoneMatch;