From b515880adb33877eb161f8f65d54e2917b2cd1a7 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 05:50:28 +0200 Subject: [PATCH] fix: encrypt Immich API key at rest using AES-256-GCM Per-user Immich API keys were stored as plaintext in the users table, giving any attacker with DB read access full control over each user's Immich photo server. Keys are now encrypted on write with maybe_encrypt_api_key() and decrypted at the point of use via a shared getImmichCredentials() helper. A new migration (index 66) back-fills encryption for any existing plaintext values on startup. --- server/src/db/migrations.ts | 9 ++++++ server/src/routes/immich.ts | 63 ++++++++++++++++++++----------------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 435eda0..835440a 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -463,6 +463,15 @@ function runMigrations(db: Database.Database): void { db.prepare("UPDATE app_settings SET value = ? WHERE key = 'smtp_pass'").run(encrypt_api_key(row.value)); } }, + // Encrypt any plaintext immich_api_key values in the users table + () => { + const rows = db.prepare( + "SELECT id, immich_api_key FROM users WHERE immich_api_key IS NOT NULL AND immich_api_key != '' AND immich_api_key NOT LIKE 'enc:v1:%'" + ).all() as { id: number; immich_api_key: string }[]; + for (const row of rows) { + db.prepare('UPDATE users SET immich_api_key = ? WHERE id = ?').run(encrypt_api_key(row.immich_api_key), row.id); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 8bb3e8b..7b52e98 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -4,9 +4,16 @@ import { authenticate } from '../middleware/auth'; import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; import { consumeEphemeralToken } from '../services/ephemeralTokens'; +import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; const router = express.Router(); +function getImmichCredentials(userId: number) { + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any; + if (!user?.immich_url || !user?.immich_api_key) return null; + return { immich_url: user.immich_url as string, immich_api_key: decrypt_api_key(user.immich_api_key) as string }; +} + /** Validate that an asset ID is a safe UUID-like string (no path traversal). */ function isValidAssetId(id: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100; @@ -34,10 +41,10 @@ function isValidImmichUrl(raw: string): boolean { router.get('/settings', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + const creds = getImmichCredentials(authReq.user.id); res.json({ - immich_url: user?.immich_url || '', - connected: !!(user?.immich_url && user?.immich_api_key), + immich_url: creds?.immich_url || '', + connected: !!(creds?.immich_url && creds?.immich_api_key), }); }); @@ -49,7 +56,7 @@ router.put('/settings', authenticate, (req: Request, res: Response) => { } db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( immich_url?.trim() || null, - immich_api_key?.trim() || null, + maybe_encrypt_api_key(immich_api_key), authReq.user.id ); res.json({ success: true }); @@ -57,13 +64,13 @@ router.put('/settings', authenticate, (req: Request, res: Response) => { router.get('/status', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; - if (!user?.immich_url || !user?.immich_api_key) { + const creds = getImmichCredentials(authReq.user.id); + if (!creds) { return res.json({ connected: false, error: 'Not configured' }); } try { - const resp = await fetch(`${user.immich_url}/api/users/me`, { - headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + const resp = await fetch(`${creds.immich_url}/api/users/me`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); @@ -79,13 +86,13 @@ router.get('/status', authenticate, async (req: Request, res: Response) => { router.get('/browse', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { page = '1', size = '50' } = req.query; - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; - if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(400).json({ error: 'Immich not configured' }); try { - const resp = await fetch(`${user.immich_url}/api/timeline/buckets`, { + const resp = await fetch(`${creds.immich_url}/api/timeline/buckets`, { method: 'GET', - headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(15000), }); if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch from Immich' }); @@ -100,8 +107,8 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => { router.post('/search', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { from, to } = req.body; - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; - if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(400).json({ error: 'Immich not configured' }); try { // Paginate through all results (Immich limits per-page to 1000) @@ -109,9 +116,9 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { let page = 1; const pageSize = 1000; while (true) { - const resp = await fetch(`${user.immich_url}/api/search/metadata`, { + const resp = await fetch(`${creds.immich_url}/api/search/metadata`, { method: 'POST', - headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' }, + headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' }, body: JSON.stringify({ takenAfter: from ? `${from}T00:00:00.000Z` : undefined, takenBefore: to ? `${to}T23:59:59.999Z` : undefined, @@ -219,12 +226,12 @@ router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Resp if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); // Only allow accessing own Immich credentials — prevent leaking other users' API keys - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; - if (!user?.immich_url || !user?.immich_api_key) return res.status(404).json({ error: 'Not found' }); + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(404).json({ error: 'Not found' }); try { - const resp = await fetch(`${user.immich_url}/api/assets/${assetId}`, { - headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!resp.ok) return res.status(resp.status).json({ error: 'Failed' }); @@ -275,12 +282,12 @@ router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); // Only allow accessing own Immich credentials — prevent leaking other users' API keys - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; - if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found'); + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(404).send('Not found'); try { - const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/thumbnail`, { - headers: { 'x-api-key': user.immich_api_key }, + const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/thumbnail`, { + headers: { 'x-api-key': creds.immich_api_key }, signal: AbortSignal.timeout(10000), }); if (!resp.ok) return res.status(resp.status).send('Failed'); @@ -299,12 +306,12 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); // Only allow accessing own Immich credentials — prevent leaking other users' API keys - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; - if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found'); + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(404).send('Not found'); try { - const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/original`, { - headers: { 'x-api-key': user.immich_api_key }, + const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/original`, { + headers: { 'x-api-key': creds.immich_api_key }, signal: AbortSignal.timeout(30000), }); if (!resp.ok) return res.status(resp.status).send('Failed');