From c9341eda3fe1831862c7c672afb2970040fba010 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 04:09:09 +0200 Subject: [PATCH 01/25] fix: remove RCE vector from admin update endpoint. The POST /api/admin/update endpoint ran git pull, npm install, and npm run build via execSync, potentially giving any compromised admin account full code execution on the host in case repository is compromised. TREK ships as a Docker image so runtime self-updating is unnecessary. - Remove the /update route and child_process import from admin.ts - Remove the installUpdate API client method - Replace the live-update modal with an info-only panel showing docker pull instructions and a link to the GitHub release - Drop the updating/updateResult state and handleInstallUpdate handler --- client/src/api/client.ts | 1 - client/src/pages/AdminPage.tsx | 238 +++++++++------------------------ server/src/routes/admin.ts | 44 ------ 3 files changed, 60 insertions(+), 223 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 9c414d0..f42b0c3 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -163,7 +163,6 @@ export const adminApi = { addons: () => apiClient.get('/admin/addons').then(r => r.data), updateAddon: (id: number | string, data: Record) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), - installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data), getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data), updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data), packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data), diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index b1c44db..3c5e928 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -16,7 +16,7 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager' import AuditLogPanel from '../components/Admin/AuditLogPanel' import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel' import PermissionsPanel from '../components/Admin/PermissionsPanel' -import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react' +import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' interface AdminUser { @@ -121,8 +121,6 @@ export default function AdminPage(): React.ReactElement { // Version check & update const [updateInfo, setUpdateInfo] = useState(null) const [showUpdateModal, setShowUpdateModal] = useState(false) - const [updating, setUpdating] = useState(false) - const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null) const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() const navigate = useNavigate() @@ -177,26 +175,6 @@ export default function AdminPage(): React.ReactElement { } } - const handleInstallUpdate = async () => { - setUpdating(true) - setUpdateResult(null) - try { - await adminApi.installUpdate() - setUpdateResult('success') - // Server is restarting — poll until it comes back, then reload - const poll = setInterval(async () => { - try { - await authApi.getAppConfig() - clearInterval(poll) - window.location.reload() - } catch { /* still restarting */ } - }, 2000) - } catch { - setUpdateResult('error') - setUpdating(false) - } - } - const handleToggleRegistration = async (value) => { setAllowRegistration(value) try { @@ -394,23 +372,13 @@ export default function AdminPage(): React.ReactElement { {t('admin.update.button')} )} - {updateInfo.is_docker ? ( - - ) : ( - - )} + )} @@ -1303,78 +1271,37 @@ export default function AdminPage(): React.ReactElement { )} - {/* Update confirmation popup — matches backup restore style */} + {/* Update instructions popup */} {showUpdateModal && (
{ if (!updating) setShowUpdateModal(false) }} + onClick={() => setShowUpdateModal(false)} >
e.stopPropagation()} style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }} className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700" > - {updateResult === 'success' ? ( - <> -
-
- -
-
-

{t('admin.update.success')}

-
-
-
- -

{t('admin.update.reloadHint')}

-
- - ) : updateResult === 'error' ? ( - <> -
-
- -
-
-

{t('admin.update.failed')}

-
-
-
- -
- - ) : ( - <> - {/* Red header */} -
-
- -
-
-

{t('admin.update.confirmTitle')}

-

- v{updateInfo?.current} → v{updateInfo?.latest} -

-
-
+
+
+ +
+
+

{t('admin.update.howTo')}

+

+ v{updateInfo?.current} → v{updateInfo?.latest} +

+
+
- {/* Body */} -
- {updateInfo?.is_docker ? ( - <> -

- {t('admin.update.dockerText').replace('{version}', `v${updateInfo.latest}`)} -

+
+

+ {t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)} +

-
+
{`docker pull mauriceboe/nomad:latest docker stop nomad && docker rm nomad docker run -d --name nomad \\ @@ -1383,87 +1310,42 @@ docker run -d --name nomad \\ -v /opt/nomad/uploads:/app/uploads \\ --restart unless-stopped \\ mauriceboe/nomad:latest`} -
+
-
-
- - {t('admin.update.dataInfo')} -
-
- - ) : ( - <> -

- {updateInfo && t('admin.update.confirmText').replace('{current}', `v${updateInfo.current}`).replace('{version}', `v${updateInfo.latest}`)} -

- -
-
- - {t('admin.update.dataInfo')} -
-
- -
-
- - - {t('admin.update.backupHint')}{' '} - - -
-
- -
-
- - {t('admin.update.warning')} -
-
- - )} +
+
+ + {t('admin.update.dataInfo')}
+
- {/* Footer */} -
- - {!updateInfo?.is_docker && ( - - )} + {updateInfo?.release_url && ( + - - )} + )} +
+ +
+ +
)} diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 674aa31..eac08dd 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -1,7 +1,6 @@ import express, { Request, Response } from 'express'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; -import { execSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import { db } from '../db/database'; @@ -326,49 +325,6 @@ router.get('/version-check', async (_req: Request, res: Response) => { } }); -router.post('/update', async (req: Request, res: Response) => { - const rootDir = path.resolve(__dirname, '../../..'); - const serverDir = path.resolve(__dirname, '../..'); - const clientDir = path.join(rootDir, 'client'); - const steps: { step: string; success?: boolean; output?: string; version?: string }[] = []; - - try { - const pullOutput = execSync('git pull origin main', { cwd: rootDir, timeout: 60000, encoding: 'utf8' }); - steps.push({ step: 'git pull', success: true, output: pullOutput.trim() }); - - execSync('npm install --production --ignore-scripts', { cwd: serverDir, timeout: 120000, encoding: 'utf8' }); - steps.push({ step: 'npm install (server)', success: true }); - - if (process.env.NODE_ENV === 'production') { - execSync('npm install --ignore-scripts', { cwd: clientDir, timeout: 120000, encoding: 'utf8' }); - execSync('npm run build', { cwd: clientDir, timeout: 120000, encoding: 'utf8' }); - steps.push({ step: 'npm install + build (client)', success: true }); - } - - delete require.cache[require.resolve('../../package.json')]; - const { version: newVersion } = require('../../package.json'); - steps.push({ step: 'version', version: newVersion }); - - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.system_update', - resource: newVersion, - ip: getClientIp(req), - }); - res.json({ success: true, steps, restarting: true }); - - setTimeout(() => { - console.log('[Update] Restarting after update...'); - process.exit(0); - }, 1000); - } catch (err: unknown) { - console.error(err); - steps.push({ step: 'error', success: false, output: 'Internal error' }); - res.status(500).json({ success: false, steps }); - } -}); - // ── Invite Tokens ─────────────────────────────────────────────────────────── router.get('/invites', (_req: Request, res: Response) => { From ccb5f9df1faa34ca0151fcd6fd32bfd0639d59dc Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 04:19:52 +0200 Subject: [PATCH 02/25] fix: wrap each migration in a transaction and surface swallowed errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the migration runner called each migration function directly with no transaction wrapping and updated schema_version only after all pending migrations had run. A mid-migration failure (e.g. disk full after ALTER TABLE but before CREATE INDEX) would leave the schema in a partially-applied state with no rollback path. On the next restart the broken migration would be skipped — because schema_version had not advanced — but only if the failure was noticed at all. ~43 catch {} blocks silently discarded every error, including non-idempotency errors such as disk-full or corruption, making it impossible to know a migration had failed. Changes: - Each migration now runs inside db.transaction(); better-sqlite3 rolls back automatically on throw. - schema_version is updated after every individual migration succeeds, so a failure does not cause already-applied migrations to re-run. - A migration that throws after rollback logs FATAL and calls process.exit(1), refusing to start with a broken schema. - All catch {} blocks on ALTER TABLE ADD COLUMN re-throw any error that is not "duplicate column name", so only the expected idempotency case is swallowed. - Genuinely optional steps (INSERT OR IGNORE, UPDATE data-copy, DROP TABLE IF EXISTS) now log a warning instead of discarding the error entirely. --- server/src/db/migrations.ts | 119 ++++++++++++++++++++---------------- 1 file changed, 68 insertions(+), 51 deletions(-) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index cb5c256..541f18e 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -60,17 +60,17 @@ function runMigrations(db: Database.Database): void { } }, () => { - try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in TEXT'); } catch {} - try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_out TEXT'); } catch {} - try { db.exec('ALTER TABLE day_accommodations ADD COLUMN confirmation TEXT'); } catch {} + try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_out TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE day_accommodations ADD COLUMN confirmation TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE places ADD COLUMN end_time TEXT'); } catch {} + try { db.exec('ALTER TABLE places ADD COLUMN end_time TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_status TEXT DEFAULT \'none\''); } catch {} - try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_notes TEXT'); } catch {} - try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_datetime TEXT'); } catch {} + try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_status TEXT DEFAULT \'none\''); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_notes TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_datetime TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec(` UPDATE day_assignments SET @@ -85,7 +85,7 @@ function runMigrations(db: Database.Database): void { } }, () => { - try { db.exec('ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL'); } catch {} + try { db.exec('ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { db.exec(` @@ -144,18 +144,22 @@ function runMigrations(db: Database.Database): void { `); try { db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 1, 6)").run(); - } catch {} + } catch (err: any) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } }, () => { - try { db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_time TEXT'); } catch {} - try { db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_end_time TEXT'); } catch {} + try { db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_time TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_end_time TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec(` UPDATE day_assignments SET assignment_time = (SELECT place_time FROM places WHERE places.id = day_assignments.place_id), assignment_end_time = (SELECT end_time FROM places WHERE places.id = day_assignments.place_id) `); - } catch {} + } catch (err: any) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } }, () => { db.exec(` @@ -184,26 +188,26 @@ function runMigrations(db: Database.Database): void { `); }, () => { - try { db.exec('ALTER TABLE collab_messages ADD COLUMN deleted INTEGER DEFAULT 0'); } catch {} + try { db.exec('ALTER TABLE collab_messages ADD COLUMN deleted INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); } catch {} - try { db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); } catch {} + try { db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch {} + try { db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); } catch {} + try { db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE trip_files ADD COLUMN uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {} - try { db.exec('ALTER TABLE trip_files ADD COLUMN starred INTEGER DEFAULT 0'); } catch {} - try { db.exec('ALTER TABLE trip_files ADD COLUMN deleted_at TEXT'); } catch {} + try { db.exec('ALTER TABLE trip_files ADD COLUMN uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE trip_files ADD COLUMN starred INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE trip_files ADD COLUMN deleted_at TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL'); } catch {} - try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch {} + try { db.exec('ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { db.exec(`CREATE TABLE IF NOT EXISTS invite_tokens ( @@ -217,8 +221,8 @@ function runMigrations(db: Database.Database): void { )`); }, () => { - try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch {} - try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); } catch {} + try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { db.exec(`CREATE TABLE IF NOT EXISTS packing_category_assignees ( @@ -243,7 +247,9 @@ function runMigrations(db: Database.Database): void { sort_order INTEGER NOT NULL DEFAULT 0 )`); // Recreate items table with category_id FK (replaces old template_id-based schema) - try { db.exec('DROP TABLE IF EXISTS packing_template_items'); } catch {} + try { db.exec('DROP TABLE IF EXISTS packing_template_items'); } catch (err: any) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } db.exec(`CREATE TABLE packing_template_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL REFERENCES packing_template_categories(id) ON DELETE CASCADE, @@ -261,8 +267,8 @@ function runMigrations(db: Database.Database): void { sort_order INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); - try { db.exec('ALTER TABLE packing_items ADD COLUMN weight_grams INTEGER'); } catch {} - try { db.exec('ALTER TABLE packing_items ADD COLUMN bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL'); } catch {} + try { db.exec('ALTER TABLE packing_items ADD COLUMN weight_grams INTEGER'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE packing_items ADD COLUMN bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { db.exec(`CREATE TABLE IF NOT EXISTS visited_countries ( @@ -287,12 +293,12 @@ function runMigrations(db: Database.Database): void { }, () => { // Configurable weekend days - try { db.exec("ALTER TABLE vacay_plans ADD COLUMN weekend_days TEXT DEFAULT '0,6'"); } catch {} + try { db.exec("ALTER TABLE vacay_plans ADD COLUMN weekend_days TEXT DEFAULT '0,6'"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Immich integration - try { db.exec("ALTER TABLE users ADD COLUMN immich_url TEXT"); } catch {} - try { db.exec("ALTER TABLE users ADD COLUMN immich_api_key TEXT"); } catch {} + try { db.exec("ALTER TABLE users ADD COLUMN immich_url TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec("ALTER TABLE users ADD COLUMN immich_api_key TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } db.exec(`CREATE TABLE IF NOT EXISTS trip_photos ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, @@ -305,7 +311,9 @@ function runMigrations(db: Database.Database): void { // Add memories addon try { db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)").run('memories', 'Photos', 'trip', 'Image', 0, 7); - } catch {} + } catch (err: any) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } }, () => { // Allow files to be linked to multiple reservations/assignments @@ -323,15 +331,15 @@ function runMigrations(db: Database.Database): void { }, () => { // Add day_plan_position to reservations for persistent transport ordering in day timeline - try { db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); } catch {} + try { db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Add paid_by_user_id to budget_items for expense tracking / settlement - try { db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); } catch {} + try { db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Add target_date to bucket_list for optional visit planning - try { db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); } catch {} + try { db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Notification preferences per user @@ -351,10 +359,10 @@ function runMigrations(db: Database.Database): void { }, () => { // Add missing notification preference columns for existing tables - try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_vacay_invite INTEGER DEFAULT 1'); } catch {} - try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_photos_shared INTEGER DEFAULT 1'); } catch {} - try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); } catch {} - try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); } catch {} + try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_vacay_invite INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_photos_shared INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Public share links for read-only trip access @@ -373,11 +381,11 @@ function runMigrations(db: Database.Database): void { }, () => { // Add permission columns to share_tokens - try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_map INTEGER DEFAULT 1'); } catch {} - try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_bookings INTEGER DEFAULT 1'); } catch {} - try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_packing INTEGER DEFAULT 0'); } catch {} - try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); } catch {} - try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); } catch {} + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_map INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_bookings INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_packing INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Audit log @@ -396,7 +404,7 @@ function runMigrations(db: Database.Database): void { }, () => { // MFA backup/recovery codes - try { db.exec('ALTER TABLE users ADD COLUMN mfa_backup_codes TEXT'); } catch {} + try { db.exec('ALTER TABLE users ADD COLUMN mfa_backup_codes TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, // MCP long-lived API tokens () => db.exec(` @@ -415,7 +423,9 @@ function runMigrations(db: Database.Database): void { try { db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)") .run('mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'integration', 'Terminal', 0, 12); - } catch {} + } catch (err: any) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } }, // Index on mcp_tokens.token_hash () => db.exec(` @@ -425,25 +435,32 @@ function runMigrations(db: Database.Database): void { () => { try { db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'mcp'").run(); - } catch {} + } catch (err: any) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } }, () => { - try { db.exec('ALTER TABLE places ADD COLUMN route_geometry TEXT'); } catch {} + try { db.exec('ALTER TABLE places ADD COLUMN route_geometry TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0'); } catch {} + try { db.exec('ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE trips ADD COLUMN reminder_days INTEGER DEFAULT 3'); } catch {} + try { db.exec('ALTER TABLE trips ADD COLUMN reminder_days INTEGER DEFAULT 3'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, ]; if (currentVersion < migrations.length) { for (let i = currentVersion; i < migrations.length; i++) { console.log(`[DB] Running migration ${i + 1}/${migrations.length}`); - migrations[i](); + try { + db.transaction(() => migrations[i]())(); + } catch (err) { + console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err); + process.exit(1); + } + db.prepare('UPDATE schema_version SET version = ?').run(i + 1); } - db.prepare('UPDATE schema_version SET version = ?').run(migrations.length); console.log(`[DB] Migrations complete — schema version ${migrations.length}`); } } From 701a8ab03abf46524a15a5cf9f79d16a8f344359 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 04:22:57 +0200 Subject: [PATCH 03/25] fix: route db helper functions through the null-safe proxy getPlaceWithTags, canAccessTrip, and isOwner were calling _db! directly, bypassing the Proxy that guards against null-dereference during a backup restore. When the restore handler briefly sets _db = null, any concurrent request hitting these helpers would crash with an unhandled TypeError instead of receiving a clean 503-style error. Replace all four _db! accesses with the exported db proxy so the guard ("Database connection is not available") fires consistently. --- server/src/db/database.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 0b59233..0c6c009 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -84,7 +84,7 @@ interface PlaceWithTags extends Place { } function getPlaceWithTags(placeId: number | string): PlaceWithTags | null { - const place = _db!.prepare(` + const place = db.prepare(` SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id @@ -93,7 +93,7 @@ function getPlaceWithTags(placeId: number | string): PlaceWithTags | null { if (!place) return null; - const tags = _db!.prepare(` + const tags = db.prepare(` SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ? @@ -117,7 +117,7 @@ interface TripAccess { } function canAccessTrip(tripId: number | string, userId: number): TripAccess | undefined { - return _db!.prepare(` + return db.prepare(` SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) @@ -125,7 +125,7 @@ function canAccessTrip(tripId: number | string, userId: number): TripAccess | un } function isOwner(tripId: number | string, userId: number): boolean { - return !!_db!.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId); + return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId); } export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner }; From bba50f038b25bc904cf57801f7a123ded75a8924 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 04:27:50 +0200 Subject: [PATCH 04/25] fix: encrypt OIDC client secret at rest using AES-256-GCM The oidc_client_secret was written to app_settings as plaintext, unlike Maps and OpenWeather API keys which are protected with apiKeyCrypto. An attacker with read access to the SQLite file (e.g. via a backup download) could obtain the secret and impersonate the application with the identity provider. - Encrypt on write in PUT /api/admin/oidc via maybe_encrypt_api_key - Decrypt on read in GET /api/admin/oidc and in getOidcConfig() (oidc.ts) before passing the secret to the OIDC client library - Add a startup migration that encrypts any existing plaintext value already present in the database --- server/src/db/migrations.ts | 8 ++++++++ server/src/routes/admin.ts | 5 +++-- server/src/routes/oidc.ts | 3 ++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 541f18e..37e5c4f 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1,4 +1,5 @@ import Database from 'better-sqlite3'; +import { encrypt_api_key } from '../services/apiKeyCrypto'; function runMigrations(db: Database.Database): void { db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)'); @@ -448,6 +449,13 @@ function runMigrations(db: Database.Database): void { () => { try { db.exec('ALTER TABLE trips ADD COLUMN reminder_days INTEGER DEFAULT 3'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, + // Encrypt any plaintext oidc_client_secret left in app_settings + () => { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_secret'").get() as { value: string } | undefined; + if (row?.value && !row.value.startsWith('enc:v1:')) { + db.prepare("UPDATE app_settings SET value = ? WHERE key = 'oidc_client_secret'").run(encrypt_api_key(row.value)); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index eac08dd..e100165 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -9,6 +9,7 @@ import { AuthRequest, User, Addon } from '../types'; import { writeAudit, getClientIp, logInfo } from '../services/auditLog'; import { getAllPermissions, savePermissions, PERMISSION_ACTIONS } from '../services/permissions'; import { revokeUserSessions } from '../mcp'; +import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; const router = express.Router(); @@ -232,7 +233,7 @@ router.get('/audit-log', (req: Request, res: Response) => { router.get('/oidc', (_req: Request, res: Response) => { const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || ''; - const secret = get('oidc_client_secret'); + const secret = decrypt_api_key(get('oidc_client_secret')); res.json({ issuer: get('oidc_issuer'), client_id: get('oidc_client_id'), @@ -247,7 +248,7 @@ router.put('/oidc', (req: Request, res: Response) => { const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || ''); set('oidc_issuer', issuer); set('oidc_client_id', client_id); - if (client_secret !== undefined) set('oidc_client_secret', client_secret); + if (client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(client_secret) ?? ''); set('oidc_display_name', display_name); set('oidc_only', oidc_only ? 'true' : 'false'); const authReq = req as AuthRequest; diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts index f600237..7f38dd8 100644 --- a/server/src/routes/oidc.ts +++ b/server/src/routes/oidc.ts @@ -5,6 +5,7 @@ import jwt from 'jsonwebtoken'; import { db } from '../db/database'; import { JWT_SECRET } from '../config'; import { User } from '../types'; +import { decrypt_api_key } from '../services/apiKeyCrypto'; interface OidcDiscoveryDoc { authorization_endpoint: string; @@ -57,7 +58,7 @@ function getOidcConfig() { const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; const issuer = process.env.OIDC_ISSUER || get('oidc_issuer'); const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id'); - const clientSecret = process.env.OIDC_CLIENT_SECRET || get('oidc_client_secret'); + const clientSecret = process.env.OIDC_CLIENT_SECRET || decrypt_api_key(get('oidc_client_secret')); const displayName = process.env.OIDC_DISPLAY_NAME || get('oidc_display_name') || 'SSO'; if (!issuer || !clientId || !clientSecret) return null; return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName }; From 1b28bd96d40d775a267972cdb6d2921a03565f03 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 04:33:17 +0200 Subject: [PATCH 05/25] fix: encrypt SMTP password at rest using AES-256-GCM The smtp_pass setting was stored as plaintext in app_settings, exposing SMTP credentials to anyone with database read access. Apply the same encrypt_api_key/decrypt_api_key pattern already used for OIDC client secrets and API keys. A new migration transparently re-encrypts any existing plaintext value on startup; decrypt_api_key handles legacy plaintext gracefully so in-flight reads remain safe during upgrade. --- server/src/db/migrations.ts | 7 +++++++ server/src/routes/auth.ts | 3 ++- server/src/services/notifications.ts | 3 ++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 37e5c4f..435eda0 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -456,6 +456,13 @@ function runMigrations(db: Database.Database): void { db.prepare("UPDATE app_settings SET value = ? WHERE key = 'oidc_client_secret'").run(encrypt_api_key(row.value)); } }, + // Encrypt any plaintext smtp_pass left in app_settings + () => { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_pass'").get() as { value: string } | undefined; + if (row?.value && !row.value.startsWith('enc:v1:')) { + db.prepare("UPDATE app_settings SET value = ? WHERE key = 'smtp_pass'").run(encrypt_api_key(row.value)); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 679d20d..d1b211c 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -18,7 +18,7 @@ import { randomBytes, createHash } from 'crypto'; import { revokeUserSessions } from '../mcp'; import { AuthRequest, OptionalAuthRequest, User } from '../types'; import { writeAudit, getClientIp } from '../services/auditLog'; -import { decrypt_api_key, maybe_encrypt_api_key } from '../services/apiKeyCrypto'; +import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../services/apiKeyCrypto'; import { startTripReminders } from '../scheduler'; authenticator.options = { window: 1 }; @@ -665,6 +665,7 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => { } // Don't save masked password if (key === 'smtp_pass' && val === '••••••••') continue; + if (key === 'smtp_pass') val = encrypt_api_key(val); db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val); } } diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index 53e4e5c..06f5f76 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -1,6 +1,7 @@ import nodemailer from 'nodemailer'; import fetch from 'node-fetch'; import { db } from '../db/database'; +import { decrypt_api_key } from './apiKeyCrypto'; import { logInfo, logDebug, logError } from './auditLog'; // ── Types ────────────────────────────────────────────────────────────────── @@ -32,7 +33,7 @@ function getSmtpConfig(): SmtpConfig | null { const host = process.env.SMTP_HOST || getAppSetting('smtp_host'); const port = process.env.SMTP_PORT || getAppSetting('smtp_port'); const user = process.env.SMTP_USER || getAppSetting('smtp_user'); - const pass = process.env.SMTP_PASS || getAppSetting('smtp_pass'); + const pass = process.env.SMTP_PASS || decrypt_api_key(getAppSetting('smtp_pass')) || ''; const from = process.env.SMTP_FROM || getAppSetting('smtp_from'); if (!host || !port || !from) return null; return { host, port: parseInt(port, 10), user: user || '', pass: pass || '', from, secure: parseInt(port, 10) === 465 }; From 0ee53e7b388c3b74aa742f82f2a806dcd2bbd055 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 04:36:27 +0200 Subject: [PATCH 06/25] fix: prevent OIDC redirect URI construction from untrusted X-Forwarded-Host The OIDC login route silently fell back to building the redirect URI from X-Forwarded-Host/X-Forwarded-Proto when APP_URL was not configured. An attacker could set X-Forwarded-Host: attacker.example.com to redirect the authorization code to their own server after the user authenticates. Remove the header-derived fallback entirely. If APP_URL is not set (via env or the app_url DB setting), the OIDC login endpoint now returns a 500 error rather than trusting attacker-controlled request headers. Document APP_URL in .env.example as required for OIDC use. --- server/.env.example | 2 ++ server/src/routes/oidc.ts | 10 +++------- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/server/.env.example b/server/.env.example index 2aae179..490447c 100644 --- a/server/.env.example +++ b/server/.env.example @@ -8,6 +8,8 @@ ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and FORCE_HTTPS=false # Redirect HTTP → HTTPS behind a TLS proxy TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For +APP_URL=https://trek.example.com # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP + OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL OIDC_CLIENT_ID=trek # OpenID Connect client ID OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts index 7f38dd8..0c3686f 100644 --- a/server/src/routes/oidc.ts +++ b/server/src/routes/oidc.ts @@ -123,14 +123,10 @@ router.get('/login', async (req: Request, res: Response) => { const doc = await discover(config.issuer); const state = crypto.randomBytes(32).toString('hex'); const appUrl = process.env.APP_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value; - let redirectUri: string; - if (appUrl) { - redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`; - } else { - const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol; - const host = (req.headers['x-forwarded-host'] as string) || req.headers.host; - redirectUri = `${proto}://${host}/api/auth/oidc/callback`; + if (!appUrl) { + return res.status(500).json({ error: 'APP_URL is not configured. OIDC cannot be used.' }); } + const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`; const inviteToken = req.query.invite as string | undefined; pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken }); From 78695b4e03378433613a084c32197b69458bd016 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 05:42:27 +0200 Subject: [PATCH 07/25] fix: replace JWT tokens in URL query params with short-lived ephemeral tokens Addresses CWE-598: long-lived JWTs were exposed in WebSocket URLs, file download links, and Immich asset proxy URLs, leaking into server logs, browser history, and Referer headers. - Add ephemeralTokens service: in-memory single-use tokens with per-purpose TTLs (ws=30s, download/immich=60s), max 10k entries, periodic cleanup - Add POST /api/auth/ws-token and POST /api/auth/resource-token endpoints - WebSocket auth now consumes an ephemeral token instead of verifying the JWT directly from the URL; client fetches a fresh token before each connect - File download ?token= query param now accepts ephemeral tokens; Bearer header path continues to accept JWTs for programmatic access - Immich asset proxy replaces authFromQuery JWT injection with ephemeral token consumption - Client: new getAuthUrl() utility, AuthedImg/ImmichImg components, and async onClick handlers replace the synchronous authUrl() pattern throughout FileManager, PlaceInspector, and MemoriesPanel - Add OIDC_DISCOVERY_URL env var and oidc_discovery_url DB setting to allow overriding the auto-constructed discovery endpoint (required for Authentik and similar providers); exposed in the admin UI and .env.example --- client/src/api/authUrl.ts | 19 ++++++ client/src/api/websocket.ts | 39 +++++++++-- client/src/components/Files/FileManager.tsx | 64 ++++++++++++------- .../src/components/Memories/MemoriesPanel.tsx | 27 +++++--- .../src/components/Planner/PlaceInspector.tsx | 11 +--- client/src/pages/AdminPage.tsx | 16 ++++- server/.env.example | 1 + server/src/index.ts | 2 + server/src/routes/admin.ts | 4 +- server/src/routes/auth.ts | 21 ++++++ server/src/routes/files.ts | 25 +++++--- server/src/routes/immich.ts | 18 ++++-- server/src/routes/oidc.ts | 16 +++-- server/src/services/ephemeralTokens.ts | 54 ++++++++++++++++ server/src/websocket.ts | 37 ++++++----- 15 files changed, 267 insertions(+), 87 deletions(-) create mode 100644 client/src/api/authUrl.ts create mode 100644 server/src/services/ephemeralTokens.ts diff --git a/client/src/api/authUrl.ts b/client/src/api/authUrl.ts new file mode 100644 index 0000000..433f09d --- /dev/null +++ b/client/src/api/authUrl.ts @@ -0,0 +1,19 @@ +export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise { + const jwt = localStorage.getItem('auth_token') + if (!jwt || !url) return url + try { + const resp = await fetch('/api/auth/resource-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${jwt}`, + }, + body: JSON.stringify({ purpose }), + }) + if (!resp.ok) return url + const { token } = await resp.json() + return `${url}${url.includes('?') ? '&' : '?'}token=${token}` + } catch { + return url + } +} diff --git a/client/src/api/websocket.ts b/client/src/api/websocket.ts index bde9815..757f953 100644 --- a/client/src/api/websocket.ts +++ b/client/src/api/websocket.ts @@ -12,6 +12,7 @@ const activeTrips = new Set() let currentToken: string | null = null let refetchCallback: RefetchCallback | null = null let mySocketId: string | null = null +let connecting = false export function getSocketId(): string | null { return mySocketId @@ -21,9 +22,28 @@ export function setRefetchCallback(fn: RefetchCallback | null): void { refetchCallback = fn } -function getWsUrl(token: string): string { +function getWsUrl(wsToken: string): string { const protocol = location.protocol === 'https:' ? 'wss' : 'ws' - return `${protocol}://${location.host}/ws?token=${token}` + return `${protocol}://${location.host}/ws?token=${wsToken}` +} + +async function fetchWsToken(jwt: string): Promise { + try { + const resp = await fetch('/api/auth/ws-token', { + method: 'POST', + headers: { 'Authorization': `Bearer ${jwt}` }, + }) + if (resp.status === 401) { + // JWT expired — stop reconnecting + currentToken = null + return null + } + if (!resp.ok) return null + const { token } = await resp.json() + return token as string + } catch { + return null + } } function handleMessage(event: MessageEvent): void { @@ -52,12 +72,23 @@ function scheduleReconnect(): void { reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY) } -function connectInternal(token: string, _isReconnect = false): void { +async function connectInternal(token: string, _isReconnect = false): Promise { + if (connecting) return if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { return } - const url = getWsUrl(token) + connecting = true + const wsToken = await fetchWsToken(token) + connecting = false + + if (!wsToken) { + // currentToken may have been cleared on 401; only schedule reconnect if still active + if (currentToken) scheduleReconnect() + return + } + + const url = getWsUrl(wsToken) socket = new WebSocket(url) socket.onopen = () => { diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 3fcf8e2..3dc6044 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -1,5 +1,5 @@ import ReactDOM from 'react-dom' -import { useState, useCallback, useRef } from 'react' +import { useState, useCallback, useRef, useEffect } from 'react' import { useDropzone } from 'react-dropzone' import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react' import { useToast } from '../shared/Toast' @@ -9,11 +9,7 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' -function authUrl(url: string): string { - const token = localStorage.getItem('auth_token') - if (!token || !url || url.includes('token=')) return url - return `${url}${url.includes('?') ? '&' : '?'}token=${token}` -} +import { getAuthUrl } from '../../api/authUrl' function isImage(mimeType) { if (!mimeType) return false @@ -49,6 +45,10 @@ interface ImageLightboxProps { function ImageLightbox({ file, onClose }: ImageLightboxProps) { const { t } = useTranslation() + const [imgSrc, setImgSrc] = useState('') + useEffect(() => { + getAuthUrl(file.url, 'download').then(setImgSrc) + }, [file.url]) return (
e.stopPropagation()}> {file.original_name}
{file.original_name}
- + @@ -76,6 +80,15 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) { ) } +// Authenticated image — fetches a short-lived download token and renders the image +function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) { + const [authSrc, setAuthSrc] = useState('') + useEffect(() => { + getAuthUrl(src, 'download').then(setAuthSrc) + }, [src]) + return authSrc ? : null +} + // Source badge interface SourceBadgeProps { icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }> @@ -292,6 +305,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, } const [previewFile, setPreviewFile] = useState(null) + const [previewFileUrl, setPreviewFileUrl] = useState('') + useEffect(() => { + if (previewFile) { + getAuthUrl(previewFile.url, 'download').then(setPreviewFileUrl) + } else { + setPreviewFileUrl('') + } + }, [previewFile?.url]) const [assignFileId, setAssignFileId] = useState(null) const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => { @@ -322,8 +343,6 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, if (file.reservation_id) allLinkedResIds.add(file.reservation_id) for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid) const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean) - const fileUrl = authUrl(file.url) - return (
{/* Icon or thumbnail */}
!isTrash && openFile({ ...file, url: fileUrl })} + onClick={() => !isTrash && openFile(file)} style={{ flexShrink: 0, width: 36, height: 36, borderRadius: 8, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -345,7 +364,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, }} > {isImage(file.mime_type) - ? + ? : (() => { const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?' const isPdf = file.mime_type === 'application/pdf' @@ -366,7 +385,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, )} {!isTrash && file.starred ? : null} !isTrash && openFile({ ...file, url: fileUrl })} + onClick={() => !isTrash && openFile(file)} style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }} > {file.original_name} @@ -416,7 +435,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}> } - @@ -633,12 +652,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{previewFile.original_name}
- e.currentTarget.style.color = 'var(--text-primary)'} - onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}> +

- PDF herunterladen +

diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 74a72a0..1db08e9 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -3,6 +3,15 @@ import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPi import apiClient from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' +import { getAuthUrl } from '../../api/authUrl' + +function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { + const [src, setSrc] = useState('') + useEffect(() => { + getAuthUrl(baseUrl, 'immich').then(setSrc) + }, [baseUrl]) + return src ? : null +} // ── Types ─────────────────────────────────────────────────────────────────── @@ -57,6 +66,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const [lightboxUserId, setLightboxUserId] = useState(null) const [lightboxInfo, setLightboxInfo] = useState(null) const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false) + const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('') // ── Init ────────────────────────────────────────────────────────────────── @@ -167,13 +177,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Helpers ─────────────────────────────────────────────────────────────── - const token = useAuthStore(s => s.token) - - const thumbnailUrl = (assetId: string, userId: number) => - `/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}` - - const originalUrl = (assetId: string, userId: number) => - `/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}` + const thumbnailBaseUrl = (assetId: string, userId: number) => + `/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}` const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id) const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared) @@ -328,7 +333,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa outline: isSelected ? '3px solid var(--text-primary)' : 'none', outlineOffset: -3, }}> - {isSelected && (
{ setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) + setLightboxOriginalSrc('') + getAuthUrl(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`, 'immich').then(setLightboxOriginalSrc) setLightboxInfoLoading(true) apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`) .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) }}> - {/* Other user's avatar */} @@ -592,7 +599,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}> diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 6b78182..1fe4ace 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -1,10 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' - -function authUrl(url: string): string { - const token = localStorage.getItem('auth_token') - if (!token || !url) return url - return `${url}${url.includes('?') ? '&' : '?'}token=${token}` -} +import { getAuthUrl } from '../../api/authUrl' import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' import { mapsApi } from '../../api/client' @@ -587,11 +582,11 @@ export default function PlaceInspector({ {filesExpanded && placeFiles.length > 0 && ( )} diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 3c5e928..c682e75 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -44,6 +44,7 @@ interface OidcConfig { client_secret_set: boolean display_name: string oidc_only: boolean + discovery_url: string } interface UpdateInfo { @@ -84,7 +85,7 @@ export default function AdminPage(): React.ReactElement { useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, []) // OIDC config - const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false }) + const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false, discovery_url: '' }) const [savingOidc, setSavingOidc] = useState(false) // Registration toggle @@ -879,6 +880,17 @@ export default function AdminPage(): React.ReactElement { />

{t('admin.oidcIssuerHint')}

+
+ + setOidcConfig(c => ({ ...c, discovery_url: e.target.value }))} + placeholder='https://auth.example.com/application/o/trek/.well-known/openid-configuration' + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +

Override the auto-constructed discovery URL. Required for providers like Authentik where the endpoint is not at {'/.well-known/openid-configuration'}.

+
{ setSavingOidc(true) try { - const payload: Record = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only } + const payload: Record = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only, discovery_url: oidcConfig.discovery_url } if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret await adminApi.updateOidc(payload) toast.success(t('admin.oidcSaved')) diff --git a/server/.env.example b/server/.env.example index 490447c..8fad512 100644 --- a/server/.env.example +++ b/server/.env.example @@ -17,5 +17,6 @@ OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button OIDC_ONLY=true # Disable local password auth entirely (SSO only) OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role +OIDC_DISCOVERY_URL= # Override the auto-constructed discovery endpoint (e.g. Authentik: https://auth.example.com/application/o/trek/.well-known/openid-configuration) DEMO_MODE=false # Demo mode - resets data hourly diff --git a/server/src/index.ts b/server/src/index.ts index 8cde5c3..e588d30 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -278,6 +278,8 @@ const server = app.listen(PORT, () => { scheduler.start(); scheduler.startTripReminders(); scheduler.startDemoReset(); + const { startTokenCleanup } = require('./services/ephemeralTokens'); + startTokenCleanup(); import('./websocket').then(({ setupWebSocket }) => { setupWebSocket(server); }); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index e100165..421b8a1 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -240,17 +240,19 @@ router.get('/oidc', (_req: Request, res: Response) => { client_secret_set: !!secret, display_name: get('oidc_display_name'), oidc_only: get('oidc_only') === 'true', + discovery_url: get('oidc_discovery_url'), }); }); router.put('/oidc', (req: Request, res: Response) => { - const { issuer, client_id, client_secret, display_name, oidc_only } = req.body; + const { issuer, client_id, client_secret, display_name, oidc_only, discovery_url } = req.body; const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || ''); set('oidc_issuer', issuer); set('oidc_client_id', client_id); if (client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(client_secret) ?? ''); set('oidc_display_name', display_name); set('oidc_only', oidc_only ? 'true' : 'false'); + set('oidc_discovery_url', discovery_url); const authReq = req as AuthRequest; writeAudit({ userId: authReq.user.id, diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index d1b211c..62922e0 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -20,6 +20,7 @@ import { AuthRequest, OptionalAuthRequest, User } from '../types'; import { writeAudit, getClientIp } from '../services/auditLog'; import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../services/apiKeyCrypto'; import { startTripReminders } from '../scheduler'; +import { createEphemeralToken } from '../services/ephemeralTokens'; authenticator.options = { window: 1 }; @@ -951,4 +952,24 @@ router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => res.json({ success: true }); }); +// Short-lived single-use token for WebSocket connections (avoids JWT in WS URL) +router.post('/ws-token', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const token = createEphemeralToken(authReq.user.id, 'ws'); + if (!token) return res.status(503).json({ error: 'Service unavailable' }); + res.json({ token }); +}); + +// Short-lived single-use token for direct resource URLs (file downloads, Immich assets) +router.post('/resource-token', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { purpose } = req.body as { purpose?: string }; + if (purpose !== 'download' && purpose !== 'immich') { + return res.status(400).json({ error: 'Invalid purpose' }); + } + const token = createEphemeralToken(authReq.user.id, purpose); + if (!token) return res.status(503).json({ error: 'Service unavailable' }); + res.json({ token }); +}); + export default router; diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index 6da1b0a..13df3cd 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -6,6 +6,7 @@ import { v4 as uuidv4 } from 'uuid'; import jwt from 'jsonwebtoken'; import { JWT_SECRET } from '../config'; import { db, canAccessTrip } from '../db/database'; +import { consumeEphemeralToken } from '../services/ephemeralTokens'; import { authenticate, demoUploadBlock } from '../middleware/auth'; import { requireTripAccess } from '../middleware/tripAccess'; import { broadcast } from '../websocket'; @@ -84,17 +85,25 @@ function getPlaceFiles(tripId: string | number, placeId: number) { router.get('/:id/download', (req: Request, res: Response) => { const { tripId, id } = req.params; - // Accept token from Authorization header or query parameter + // Accept token from Authorization header (JWT) or query parameter (ephemeral token) const authHeader = req.headers['authorization']; - const token = (authHeader && authHeader.split(' ')[1]) || (req.query.token as string); - if (!token) return res.status(401).json({ error: 'Authentication required' }); + const bearerToken = authHeader && authHeader.split(' ')[1]; + const queryToken = req.query.token as string | undefined; + + if (!bearerToken && !queryToken) return res.status(401).json({ error: 'Authentication required' }); let userId: number; - try { - const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number }; - userId = decoded.id; - } catch { - return res.status(401).json({ error: 'Invalid or expired token' }); + if (bearerToken) { + try { + const decoded = jwt.verify(bearerToken, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number }; + userId = decoded.id; + } catch { + return res.status(401).json({ error: 'Invalid or expired token' }); + } + } else { + const uid = consumeEphemeralToken(queryToken!, 'download'); + if (!uid) return res.status(401).json({ error: 'Invalid or expired token' }); + userId = uid; } const trip = verifyTripOwnership(tripId, userId); diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index ef3891b..8bb3e8b 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -1,8 +1,9 @@ -import express, { Request, Response } from 'express'; +import express, { Request, Response, NextFunction } from 'express'; import { db } from '../db/database'; import { authenticate } from '../middleware/auth'; import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; +import { consumeEphemeralToken } from '../services/ephemeralTokens'; const router = express.Router(); @@ -254,11 +255,16 @@ router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Resp // ── Proxy Immich Assets ───────────────────────────────────────────────────── -// Asset proxy routes accept token via query param (for src usage) -function authFromQuery(req: Request, res: Response, next: Function) { - const token = req.query.token as string; - if (token && !req.headers.authorization) { - req.headers.authorization = `Bearer ${token}`; +// Asset proxy routes accept ephemeral token via query param (for src usage) +function authFromQuery(req: Request, res: Response, next: NextFunction) { + const queryToken = req.query.token as string | undefined; + if (queryToken) { + const userId = consumeEphemeralToken(queryToken, 'immich'); + if (!userId) return res.status(401).send('Invalid or expired token'); + const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any; + if (!user) return res.status(401).send('User not found'); + (req as AuthRequest).user = user; + return next(); } return (authenticate as any)(req, res, next); } diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts index 0c3686f..a62d306 100644 --- a/server/src/routes/oidc.ts +++ b/server/src/routes/oidc.ts @@ -60,22 +60,24 @@ function getOidcConfig() { const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id'); const clientSecret = process.env.OIDC_CLIENT_SECRET || decrypt_api_key(get('oidc_client_secret')); const displayName = process.env.OIDC_DISPLAY_NAME || get('oidc_display_name') || 'SSO'; + const discoveryUrl = process.env.OIDC_DISCOVERY_URL || get('oidc_discovery_url') || null; if (!issuer || !clientId || !clientSecret) return null; - return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName }; + return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName, discoveryUrl }; } let discoveryCache: OidcDiscoveryDoc | null = null; let discoveryCacheTime = 0; const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour -async function discover(issuer: string) { - if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === issuer) { +async function discover(issuer: string, discoveryUrl?: string | null) { + const url = discoveryUrl || `${issuer}/.well-known/openid-configuration`; + if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === url) { return discoveryCache; } - const res = await fetch(`${issuer}/.well-known/openid-configuration`); + const res = await fetch(url); if (!res.ok) throw new Error('Failed to fetch OIDC discovery document'); const doc = await res.json() as OidcDiscoveryDoc; - doc._issuer = issuer; + doc._issuer = url; discoveryCache = doc; discoveryCacheTime = Date.now(); return doc; @@ -120,7 +122,7 @@ router.get('/login', async (req: Request, res: Response) => { } try { - const doc = await discover(config.issuer); + const doc = await discover(config.issuer, config.discoveryUrl); const state = crypto.randomBytes(32).toString('hex'); const appUrl = process.env.APP_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value; if (!appUrl) { @@ -172,7 +174,7 @@ router.get('/callback', async (req: Request, res: Response) => { } try { - const doc = await discover(config.issuer); + const doc = await discover(config.issuer, config.discoveryUrl); const tokenRes = await fetch(doc.token_endpoint, { method: 'POST', diff --git a/server/src/services/ephemeralTokens.ts b/server/src/services/ephemeralTokens.ts new file mode 100644 index 0000000..0d1c12b --- /dev/null +++ b/server/src/services/ephemeralTokens.ts @@ -0,0 +1,54 @@ +import crypto from 'crypto'; + +const TTL: Record = { + ws: 30_000, + download: 60_000, + immich: 60_000, +}; + +const MAX_STORE_SIZE = 10_000; + +interface TokenEntry { + userId: number; + purpose: string; + expiresAt: number; +} + +const store = new Map(); + +export function createEphemeralToken(userId: number, purpose: string): string | null { + if (store.size >= MAX_STORE_SIZE) return null; + const token = crypto.randomBytes(32).toString('hex'); + const ttl = TTL[purpose] ?? 60_000; + store.set(token, { userId, purpose, expiresAt: Date.now() + ttl }); + return token; +} + +export function consumeEphemeralToken(token: string, purpose: string): number | null { + const entry = store.get(token); + if (!entry) return null; + store.delete(token); + if (entry.purpose !== purpose || Date.now() > entry.expiresAt) return null; + return entry.userId; +} + +let cleanupInterval: ReturnType | null = null; + +export function startTokenCleanup(): void { + if (cleanupInterval) return; + cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [token, entry] of store) { + if (now > entry.expiresAt) store.delete(token); + } + }, 60_000); + // Allow process to exit even if interval is active + if (cleanupInterval.unref) cleanupInterval.unref(); +} + +export function stopTokenCleanup(): void { + if (cleanupInterval) { + clearInterval(cleanupInterval); + cleanupInterval = null; + } +} diff --git a/server/src/websocket.ts b/server/src/websocket.ts index 69b1d7e..3bc310c 100644 --- a/server/src/websocket.ts +++ b/server/src/websocket.ts @@ -1,7 +1,6 @@ import { WebSocketServer, WebSocket } from 'ws'; -import jwt from 'jsonwebtoken'; -import { JWT_SECRET } from './config'; import { db, canAccessTrip } from './db/database'; +import { consumeEphemeralToken } from './services/ephemeralTokens'; import { User } from './types'; import http from 'http'; @@ -70,27 +69,27 @@ function setupWebSocket(server: http.Server): void { return; } - let user: User | undefined; - try { - const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number }; - user = db.prepare( - 'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?' - ).get(decoded.id) as User | undefined; - if (!user) { - nws.close(4001, 'User not found'); - return; - } - const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true'; - const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true; - if (requireMfa && !mfaOk) { - nws.close(4403, 'MFA required'); - return; - } - } catch (err: unknown) { + const userId = consumeEphemeralToken(token, 'ws'); + if (!userId) { nws.close(4001, 'Invalid or expired token'); return; } + let user: User | undefined; + user = db.prepare( + 'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?' + ).get(userId) as User | undefined; + if (!user) { + nws.close(4001, 'User not found'); + return; + } + const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true'; + const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true; + if (requireMfa && !mfaOk) { + nws.close(4403, 'MFA required'); + return; + } + nws.isAlive = true; const sid = nextSocketId++; socketId.set(nws, sid); From b515880adb33877eb161f8f65d54e2917b2cd1a7 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 05:50:28 +0200 Subject: [PATCH 08/25] 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'); From dfdd473eca6f60b9ef4ef0f9e2836fc10e5a4e00 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 05:54:03 +0200 Subject: [PATCH 09/25] fix: validate uploaded backup DB before restore Before swapping in a restored database, run PRAGMA integrity_check and verify the five core TREK tables (users, trips, trip_members, places, days) are present. This blocks restoring corrupt, empty, or unrelated SQLite files that would otherwise crash the app immediately after swap, and prevents a malicious admin from hot-swapping a crafted database with forged users or permissions. --- server/src/routes/backup.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts index de47375..53a772c 100644 --- a/server/src/routes/backup.ts +++ b/server/src/routes/backup.ts @@ -4,6 +4,7 @@ import unzipper from 'unzipper'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; +import Database from 'better-sqlite3'; import { authenticate, adminOnly } from '../middleware/auth'; import * as scheduler from '../scheduler'; import { db, closeDb, reinitialize } from '../db/database'; @@ -159,6 +160,34 @@ async function restoreFromZip(zipPath: string, res: Response, audit?: RestoreAud return res.status(400).json({ error: 'Invalid backup: travel.db not found' }); } + let uploadedDb: InstanceType | null = null; + try { + uploadedDb = new Database(extractedDb, { readonly: true }); + + const integrityResult = uploadedDb.prepare('PRAGMA integrity_check').get() as { integrity_check: string }; + if (integrityResult.integrity_check !== 'ok') { + fs.rmSync(extractDir, { recursive: true, force: true }); + return res.status(400).json({ error: `Uploaded database failed integrity check: ${integrityResult.integrity_check}` }); + } + + const requiredTables = ['users', 'trips', 'trip_members', 'places', 'days']; + const existingTables = uploadedDb + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .all() as { name: string }[]; + const tableNames = new Set(existingTables.map(t => t.name)); + for (const table of requiredTables) { + if (!tableNames.has(table)) { + fs.rmSync(extractDir, { recursive: true, force: true }); + return res.status(400).json({ error: `Uploaded database is missing required table: ${table}. This does not appear to be a TREK backup.` }); + } + } + } catch (err) { + fs.rmSync(extractDir, { recursive: true, force: true }); + return res.status(400).json({ error: 'Uploaded file is not a valid SQLite database' }); + } finally { + uploadedDb?.close(); + } + closeDb(); try { From 6f5550dc5066b7ba4f3ce42e7b3a41a8a8918728 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 06:31:45 +0200 Subject: [PATCH 10/25] fix: decouple at-rest encryption from JWT_SECRET, add JWT rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a dedicated ENCRYPTION_KEY for encrypting stored secrets (API keys, MFA TOTP, SMTP password, OIDC client secret) so that rotating the JWT signing secret no longer invalidates encrypted data, and a compromised JWT_SECRET no longer exposes stored credentials. - server/src/config.ts: add ENCRYPTION_KEY (auto-generated to data/.encryption_key if not set, same pattern as JWT_SECRET); switch JWT_SECRET to `export let` so updateJwtSecret() keeps the CJS module binding live for all importers without restart - apiKeyCrypto.ts, mfaCrypto.ts: derive encryption keys from ENCRYPTION_KEY instead of JWT_SECRET - admin POST /rotate-jwt-secret: generates a new 32-byte hex secret, persists it to data/.jwt_secret, updates the live in-process binding via updateJwtSecret(), and writes an audit log entry - Admin panel (Settings → Danger Zone): "Rotate JWT Secret" button with a confirmation modal warning that all sessions will be invalidated; on success the acting admin is logged out immediately - docker-compose.yml, .env.example, README, Helm chart (values.yaml, secret.yaml, deployment.yaml, NOTES.txt, README): document ENCRYPTION_KEY and its upgrade migration path --- README.md | 2 + chart/README.md | 3 +- chart/templates/NOTES.txt | 25 +++++---- chart/templates/deployment.yaml | 6 +++ chart/templates/secret.yaml | 3 ++ chart/values.yaml | 15 ++++-- client/src/api/client.ts | 1 + client/src/pages/AdminPage.tsx | 80 ++++++++++++++++++++++++++++- docker-compose.yml | 1 + server/.env.example | 4 ++ server/src/config.ts | 53 ++++++++++++++++--- server/src/routes/admin.ts | 25 +++++++++ server/src/services/apiKeyCrypto.ts | 4 +- server/src/services/mfaCrypto.ts | 4 +- 14 files changed, 199 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 65755a5..d70b8f1 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ services: - NODE_ENV=production - PORT=3000 - JWT_SECRET=${JWT_SECRET:-} # Auto-generated if not set; persist across restarts for stable sessions + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Auto-generated if not set. If upgrading, set to your old JWT_SECRET value to keep existing encrypted secrets readable. - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details @@ -246,6 +247,7 @@ trek.yourdomain.com { | `PORT` | Server port | `3000` | | `NODE_ENV` | Environment (`production` / `development`) | `production` | | `JWT_SECRET` | JWT signing secret; auto-generated and saved to `data/` if not set | Auto-generated | +| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC); auto-generated and saved to `data/` if not set. **Upgrading:** set to your old `JWT_SECRET` value to keep existing encrypted data readable, then re-save credentials to migrate | Auto-generated | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | diff --git a/chart/README.md b/chart/README.md index c5689b9..1e02981 100644 --- a/chart/README.md +++ b/chart/README.md @@ -29,5 +29,6 @@ See `values.yaml` for more options. ## Notes - Ingress is off by default. Enable and configure hosts for your domain. - PVCs require a default StorageClass or specify one as needed. -- JWT_SECRET must be set for production use. +- `JWT_SECRET` should be set for production use; auto-generated and persisted to the data PVC if not provided. +- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Auto-generated and persisted to the data PVC if not provided. **Upgrading:** if a previous version used `JWT_SECRET`-derived encryption, set `secretEnv.ENCRYPTION_KEY` to your old `JWT_SECRET` value to keep existing encrypted data readable, then re-save credentials via the admin panel. - If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically. diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt index 45a1993..5698c1b 100644 --- a/chart/templates/NOTES.txt +++ b/chart/templates/NOTES.txt @@ -1,13 +1,18 @@ -1. JWT_SECRET handling: - - By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`. - - To generate a random JWT_SECRET at install, set `generateJwtSecret: true`. - - To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`). +1. Secret handling (JWT_SECRET and ENCRYPTION_KEY): + - By default, the chart creates a Kubernetes Secret from the values in `secretEnv.JWT_SECRET` and `secretEnv.ENCRYPTION_KEY`. + - To generate random values for both at install (preserved across upgrades), set `generateJwtSecret: true`. + - To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must contain a key matching `existingSecretKey` (defaults to `JWT_SECRET`) and optionally an `ENCRYPTION_KEY` key. If `ENCRYPTION_KEY` is absent from the external secret, the server auto-generates and persists it to the data volume. -2. Example usage: - - Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret` - - Generate a random secret: `--set generateJwtSecret=true` +2. ENCRYPTION_KEY notes: + - Encrypts stored API keys, MFA secrets, SMTP password, and OIDC client secret at rest. + - If left empty, auto-generated by the server and saved to the data PVC — safe as long as the PVC persists. + - Upgrading from a version that used JWT_SECRET for encryption: set `secretEnv.ENCRYPTION_KEY` to your old JWT_SECRET value to keep existing encrypted data readable, then re-save credentials via the admin panel. + +3. Example usage: + - Set explicit secrets: `--set secretEnv.JWT_SECRET=your_secret --set secretEnv.ENCRYPTION_KEY=your_enc_key` + - Generate random secrets: `--set generateJwtSecret=true` - Use an existing secret: `--set existingSecret=my-k8s-secret` - - Use a custom key in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY` + - Use a custom key for JWT in existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_JWT_KEY` -3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence. - If using `existingSecret`, ensure the referenced secret and key exist in the target namespace. +4. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence. + If using `existingSecret`, ensure the referenced secret and keys exist in the target namespace. diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 25169f6..20e5ac8 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -41,6 +41,12 @@ spec: secretKeyRef: name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} key: {{ .Values.existingSecretKey | default "JWT_SECRET" }} + - name: ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: ENCRYPTION_KEY + optional: true volumeMounts: - name: data mountPath: /app/data diff --git a/chart/templates/secret.yaml b/chart/templates/secret.yaml index 6ead7f1..24e7915 100644 --- a/chart/templates/secret.yaml +++ b/chart/templates/secret.yaml @@ -8,6 +8,7 @@ metadata: type: Opaque data: {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }} + ENCRYPTION_KEY: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }} {{- end }} {{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }} @@ -23,7 +24,9 @@ type: Opaque stringData: {{- if and $existingSecret $existingSecret.data }} {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "JWT_SECRET") | b64dec }} + ENCRYPTION_KEY: {{ index $existingSecret.data "ENCRYPTION_KEY" | b64dec }} {{- else }} {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }} + ENCRYPTION_KEY: {{ randAlphaNum 32 }} {{- end }} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 0d9d0c9..83bea2a 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -19,15 +19,22 @@ env: # NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. -# JWT secret configuration +# Secret environment variables stored in a Kubernetes Secret secretEnv: - # If set, use this value for JWT_SECRET (base64-encoded in secret.yaml) + # JWT signing secret. Auto-generated and persisted to the data PVC if not set. JWT_SECRET: "" + # At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC, etc.). + # Auto-generated and persisted to the data PVC if not set. + # Upgrading from a version that used JWT_SECRET for encryption: set this to your + # old JWT_SECRET value to keep existing encrypted data readable, then re-save + # credentials via the admin panel and rotate to a fresh random key. + ENCRYPTION_KEY: "" -# If true, a random JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET) +# If true, random values for JWT_SECRET and ENCRYPTION_KEY are generated at install +# and preserved across upgrades (overrides secretEnv values) generateJwtSecret: false -# If set, use an existing Kubernetes secret for JWT_SECRET +# If set, use an existing Kubernetes secret for JWT_SECRET (and optionally ENCRYPTION_KEY) existingSecret: "" existingSecretKey: JWT_SECRET diff --git a/client/src/api/client.ts b/client/src/api/client.ts index f42b0c3..ed4f5b9 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -185,6 +185,7 @@ export const adminApi = { deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data), getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data), updatePermissions: (permissions: Record) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data), + rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data), } export const addonsApi = { diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index c682e75..d4d6306 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -16,7 +16,7 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager' import AuditLogPanel from '../components/Admin/AuditLogPanel' import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel' import PermissionsPanel from '../components/Admin/PermissionsPanel' -import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react' +import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, GitBranch, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' interface AdminUser { @@ -123,10 +123,13 @@ export default function AdminPage(): React.ReactElement { const [updateInfo, setUpdateInfo] = useState(null) const [showUpdateModal, setShowUpdateModal] = useState(false) - const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() + const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, logout } = useAuthStore() const navigate = useNavigate() const toast = useToast() + const [showRotateJwtModal, setShowRotateJwtModal] = useState(false) + const [rotatingJwt, setRotatingJwt] = useState(false) + useEffect(() => { loadData() loadAppConfig() @@ -1132,6 +1135,31 @@ export default function AdminPage(): React.ReactElement {
+ + {/* Danger Zone */} +
+
+

+ + Danger Zone +

+
+
+
+
+

Rotate JWT Secret

+

Generate a new JWT signing secret. All active sessions will be invalidated immediately.

+
+ +
+
+
)} @@ -1361,6 +1389,54 @@ docker run -d --name nomad \\
)} + + {/* Rotate JWT Secret confirmation modal */} + setShowRotateJwtModal(false)} + title="Rotate JWT Secret" + size="sm" + footer={ +
+ + +
+ } + > +
+
+ +
+
+

Warning, this will invalidate all sessions and log you out.

+

A new JWT secret will be generated immediately. Every logged-in user — including you — will be signed out and will need to log in again.

+
+
+
) } diff --git a/docker-compose.yml b/docker-compose.yml index 37e1123..8d27b48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: - NODE_ENV=production - PORT=3000 - JWT_SECRET=${JWT_SECRET:-} # Auto-generated if not set; persist across restarts for stable sessions + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Auto-generated if not set. If upgrading, set to your old JWT_SECRET value to keep existing encrypted secrets readable. - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links diff --git a/server/.env.example b/server/.env.example index 8fad512..1d13427 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,6 +1,10 @@ PORT=3001 # Port to run the server on NODE_ENV=development # development = development mode; production = production mode JWT_SECRET=your-super-secret-jwt-key-change-in-production # Auto-generated if not set; persist across restarts for stable sessions +# ENCRYPTION_KEY= # Separate key for encrypting stored secrets (API keys, MFA, SMTP, OIDC, etc.) +# Auto-generated and persisted to ./data/.encryption_key if not set. +# Upgrade: set to your old JWT_SECRET value if you have existing encrypted data from a previous installation. +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details diff --git a/server/src/config.ts b/server/src/config.ts index adbc559..f42c25b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -2,19 +2,19 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -let JWT_SECRET: string = process.env.JWT_SECRET || ''; +let _jwtSecret: string = process.env.JWT_SECRET || ''; -if (!JWT_SECRET) { +if (!_jwtSecret) { const dataDir = path.resolve(__dirname, '../data'); const secretFile = path.join(dataDir, '.jwt_secret'); try { - JWT_SECRET = fs.readFileSync(secretFile, 'utf8').trim(); + _jwtSecret = fs.readFileSync(secretFile, 'utf8').trim(); } catch { - JWT_SECRET = crypto.randomBytes(32).toString('hex'); + _jwtSecret = crypto.randomBytes(32).toString('hex'); try { if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); - fs.writeFileSync(secretFile, JWT_SECRET, { mode: 0o600 }); + fs.writeFileSync(secretFile, _jwtSecret, { mode: 0o600 }); console.log('Generated and saved JWT secret to', secretFile); } catch (writeErr: unknown) { console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr); @@ -25,4 +25,45 @@ if (!JWT_SECRET) { const JWT_SECRET_IS_GENERATED = !process.env.JWT_SECRET; -export { JWT_SECRET, JWT_SECRET_IS_GENERATED }; +// export let so TypeScript's CJS output keeps exports.JWT_SECRET live +// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret) +export let JWT_SECRET = _jwtSecret; + +// Called by the admin rotate-jwt-secret endpoint to update the in-process +// binding that all middleware and route files reference. +export function updateJwtSecret(newSecret: string): void { + JWT_SECRET = newSecret; +} + +// ENCRYPTION_KEY is used to derive at-rest encryption keys for stored secrets +// (API keys, MFA TOTP secrets, SMTP password, OIDC client secret, etc.). +// Keeping it separate from JWT_SECRET means you can rotate session tokens without +// invalidating all stored encrypted data, and vice-versa. +// +// Upgrade note: if you already have encrypted data stored under a previous build +// that used JWT_SECRET for encryption, set ENCRYPTION_KEY to the value of your +// old JWT_SECRET so existing encrypted values continue to decrypt correctly. +// After re-saving all credentials via the admin panel you can switch to a new +// random ENCRYPTION_KEY. +let ENCRYPTION_KEY: string = process.env.ENCRYPTION_KEY || ''; + +if (!ENCRYPTION_KEY) { + const dataDir = path.resolve(__dirname, '../data'); + const keyFile = path.join(dataDir, '.encryption_key'); + + try { + ENCRYPTION_KEY = fs.readFileSync(keyFile, 'utf8').trim(); + } catch { + ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex'); + try { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(keyFile, ENCRYPTION_KEY, { mode: 0o600 }); + console.log('Generated and saved encryption key to', keyFile); + } catch (writeErr: unknown) { + console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr); + console.warn('Encrypted secrets will be unreadable after restart. Set ENCRYPTION_KEY env var for persistent encryption.'); + } + } +} + +export { JWT_SECRET_IS_GENERATED, ENCRYPTION_KEY }; diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 421b8a1..5b62298 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -10,6 +10,7 @@ import { writeAudit, getClientIp, logInfo } from '../services/auditLog'; import { getAllPermissions, savePermissions, PERMISSION_ACTIONS } from '../services/permissions'; import { revokeUserSessions } from '../mcp'; import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; +import { updateJwtSecret } from '../config'; const router = express.Router(); @@ -559,4 +560,28 @@ router.delete('/mcp-tokens/:id', (req: Request, res: Response) => { res.json({ success: true }); }); +router.post('/rotate-jwt-secret', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const newSecret = crypto.randomBytes(32).toString('hex'); + const dataDir = path.resolve(__dirname, '../../data'); + const secretFile = path.join(dataDir, '.jwt_secret'); + try { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(secretFile, newSecret, { mode: 0o600 }); + } catch (err: unknown) { + return res.status(500).json({ error: 'Failed to persist new JWT secret to disk' }); + } + updateJwtSecret(newSecret); + writeAudit({ + user_id: authReq.user?.id ?? null, + username: authReq.user?.username ?? 'unknown', + action: 'admin.rotate_jwt_secret', + target_type: 'system', + target_id: null, + details: null, + ip: getClientIp(req), + }); + res.json({ success: true }); +}); + export default router; diff --git a/server/src/services/apiKeyCrypto.ts b/server/src/services/apiKeyCrypto.ts index 881b840..0ad9cb2 100644 --- a/server/src/services/apiKeyCrypto.ts +++ b/server/src/services/apiKeyCrypto.ts @@ -1,10 +1,10 @@ import * as crypto from 'crypto'; -import { JWT_SECRET } from '../config'; +import { ENCRYPTION_KEY } from '../config'; const ENCRYPTED_PREFIX = 'enc:v1:'; function get_key() { - return crypto.createHash('sha256').update(`${JWT_SECRET}:api_keys:v1`).digest(); + return crypto.createHash('sha256').update(`${ENCRYPTION_KEY}:api_keys:v1`).digest(); } export function encrypt_api_key(plain: unknown) { diff --git a/server/src/services/mfaCrypto.ts b/server/src/services/mfaCrypto.ts index 748f9bd..2a64743 100644 --- a/server/src/services/mfaCrypto.ts +++ b/server/src/services/mfaCrypto.ts @@ -1,8 +1,8 @@ import crypto from 'crypto'; -import { JWT_SECRET } from '../config'; +import { ENCRYPTION_KEY } from '../config'; function getKey(): Buffer { - return crypto.createHash('sha256').update(`${JWT_SECRET}:mfa:v1`).digest(); + return crypto.createHash('sha256').update(`${ENCRYPTION_KEY}:mfa:v1`).digest(); } /** Encrypt TOTP secret for storage in SQLite. */ From e10f6bf9afbc5dc60a56064a2170036f1a65790f Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 06:38:38 +0200 Subject: [PATCH 11/25] =?UTF-8?q?fix:=20remove=20JWT=5FSECRET=20env=20var?= =?UTF-8?q?=20=E2=80=94=20server=20manages=20it=20exclusively?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting JWT_SECRET via environment variable was broken by design: the admin panel rotation updates the in-memory binding and persists the new value to data/.jwt_secret, but an env var would silently override it on the next restart, reverting the rotation. The server now always loads JWT_SECRET from data/.jwt_secret (auto-generating it on first start), making the file the single source of truth. Rotation is handled exclusively through the admin panel. - config.ts: drop process.env.JWT_SECRET fallback and JWT_SECRET_IS_GENERATED export; always read from / write to data/.jwt_secret - index.ts: remove the now-obsolete JWT_SECRET startup warning - .env.example, docker-compose.yml, README: remove JWT_SECRET entries - Helm chart: remove JWT_SECRET from secretEnv, secret.yaml, and deployment.yaml; rename generateJwtSecret → generateEncryptionKey and update NOTES.txt and README accordingly --- README.md | 2 -- chart/README.md | 3 +-- chart/templates/NOTES.txt | 32 +++++++++++++++++------------ chart/templates/deployment.yaml | 7 +------ chart/templates/secret.yaml | 13 +++++------- chart/values.yaml | 15 +++++++------- docker-compose.yml | 1 - server/.env.example | 4 ++-- server/src/config.ts | 36 ++++++++++++++++----------------- server/src/index.ts | 4 ---- 10 files changed, 52 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index d70b8f1..7237f6c 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,6 @@ services: environment: - NODE_ENV=production - PORT=3000 - - JWT_SECRET=${JWT_SECRET:-} # Auto-generated if not set; persist across restarts for stable sessions - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Auto-generated if not set. If upgrading, set to your old JWT_SECRET value to keep existing encrypted secrets readable. - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) @@ -246,7 +245,6 @@ trek.yourdomain.com { | **Core** | | | | `PORT` | Server port | `3000` | | `NODE_ENV` | Environment (`production` / `development`) | `production` | -| `JWT_SECRET` | JWT signing secret; auto-generated and saved to `data/` if not set | Auto-generated | | `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC); auto-generated and saved to `data/` if not set. **Upgrading:** set to your old `JWT_SECRET` value to keep existing encrypted data readable, then re-save credentials to migrate | Auto-generated | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | diff --git a/chart/README.md b/chart/README.md index 1e02981..e6c7cb2 100644 --- a/chart/README.md +++ b/chart/README.md @@ -14,7 +14,6 @@ This is a minimal Helm chart for deploying the TREK app. ```sh helm install trek ./chart \ - --set secretEnv.JWT_SECRET=your_jwt_secret \ --set ingress.enabled=true \ --set ingress.hosts[0].host=yourdomain.com ``` @@ -29,6 +28,6 @@ See `values.yaml` for more options. ## Notes - Ingress is off by default. Enable and configure hosts for your domain. - PVCs require a default StorageClass or specify one as needed. -- `JWT_SECRET` should be set for production use; auto-generated and persisted to the data PVC if not provided. +- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed. - `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Auto-generated and persisted to the data PVC if not provided. **Upgrading:** if a previous version used `JWT_SECRET`-derived encryption, set `secretEnv.ENCRYPTION_KEY` to your old `JWT_SECRET` value to keep existing encrypted data readable, then re-save credentials via the admin panel. - If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically. diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt index 5698c1b..3fae1fc 100644 --- a/chart/templates/NOTES.txt +++ b/chart/templates/NOTES.txt @@ -1,18 +1,24 @@ -1. Secret handling (JWT_SECRET and ENCRYPTION_KEY): - - By default, the chart creates a Kubernetes Secret from the values in `secretEnv.JWT_SECRET` and `secretEnv.ENCRYPTION_KEY`. - - To generate random values for both at install (preserved across upgrades), set `generateJwtSecret: true`. - - To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must contain a key matching `existingSecretKey` (defaults to `JWT_SECRET`) and optionally an `ENCRYPTION_KEY` key. If `ENCRYPTION_KEY` is absent from the external secret, the server auto-generates and persists it to the data volume. +1. ENCRYPTION_KEY handling: + - ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. + - By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml. + - To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`. + - To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must + contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`). + - If left empty, the server auto-generates and persists the key to the data PVC — safe as long as + the PVC persists. + - Upgrading from a version that used JWT_SECRET for encryption: set `secretEnv.ENCRYPTION_KEY` to + your old JWT_SECRET value, then re-save credentials via the admin panel. -2. ENCRYPTION_KEY notes: - - Encrypts stored API keys, MFA secrets, SMTP password, and OIDC client secret at rest. - - If left empty, auto-generated by the server and saved to the data PVC — safe as long as the PVC persists. - - Upgrading from a version that used JWT_SECRET for encryption: set `secretEnv.ENCRYPTION_KEY` to your old JWT_SECRET value to keep existing encrypted data readable, then re-save credentials via the admin panel. +2. JWT_SECRET is managed entirely by the server: + - Auto-generated on first start and persisted to the data PVC (data/.jwt_secret). + - Rotate it via the admin panel (Settings → Danger Zone → Rotate JWT Secret). + - No Helm configuration needed or supported. 3. Example usage: - - Set explicit secrets: `--set secretEnv.JWT_SECRET=your_secret --set secretEnv.ENCRYPTION_KEY=your_enc_key` - - Generate random secrets: `--set generateJwtSecret=true` + - Set an explicit encryption key: `--set secretEnv.ENCRYPTION_KEY=your_enc_key` + - Generate a random key at install: `--set generateEncryptionKey=true` - Use an existing secret: `--set existingSecret=my-k8s-secret` - - Use a custom key for JWT in existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_JWT_KEY` + - Use a custom key name in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_ENC_KEY` -4. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence. - If using `existingSecret`, ensure the referenced secret and keys exist in the target namespace. +4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are + set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace. diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 20e5ac8..df5884b 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -36,16 +36,11 @@ spec: - configMapRef: name: {{ include "trek.fullname" . }}-config env: - - name: JWT_SECRET - valueFrom: - secretKeyRef: - name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} - key: {{ .Values.existingSecretKey | default "JWT_SECRET" }} - name: ENCRYPTION_KEY valueFrom: secretKeyRef: name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} - key: ENCRYPTION_KEY + key: {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }} optional: true volumeMounts: - name: data diff --git a/chart/templates/secret.yaml b/chart/templates/secret.yaml index 24e7915..204e91c 100644 --- a/chart/templates/secret.yaml +++ b/chart/templates/secret.yaml @@ -1,4 +1,4 @@ -{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }} +{{- if and (not .Values.existingSecret) (not .Values.generateEncryptionKey) }} apiVersion: v1 kind: Secret metadata: @@ -7,11 +7,10 @@ metadata: app: {{ include "trek.name" . }} type: Opaque data: - {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }} - ENCRYPTION_KEY: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }} + {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }} {{- end }} -{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }} +{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }} {{- $secretName := printf "%s-secret" (include "trek.fullname" .) }} {{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }} apiVersion: v1 @@ -23,10 +22,8 @@ metadata: type: Opaque stringData: {{- if and $existingSecret $existingSecret.data }} - {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "JWT_SECRET") | b64dec }} - ENCRYPTION_KEY: {{ index $existingSecret.data "ENCRYPTION_KEY" | b64dec }} + {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }} {{- else }} - {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }} - ENCRYPTION_KEY: {{ randAlphaNum 32 }} + {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }} {{- end }} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 83bea2a..8613327 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -19,10 +19,10 @@ env: # NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. -# Secret environment variables stored in a Kubernetes Secret +# Secret environment variables stored in a Kubernetes Secret. +# JWT_SECRET is managed entirely by the server (auto-generated into the data PVC, +# rotatable via the admin panel) — it is not configured here. secretEnv: - # JWT signing secret. Auto-generated and persisted to the data PVC if not set. - JWT_SECRET: "" # At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC, etc.). # Auto-generated and persisted to the data PVC if not set. # Upgrading from a version that used JWT_SECRET for encryption: set this to your @@ -30,13 +30,12 @@ secretEnv: # credentials via the admin panel and rotate to a fresh random key. ENCRYPTION_KEY: "" -# If true, random values for JWT_SECRET and ENCRYPTION_KEY are generated at install -# and preserved across upgrades (overrides secretEnv values) -generateJwtSecret: false +# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades +generateEncryptionKey: false -# If set, use an existing Kubernetes secret for JWT_SECRET (and optionally ENCRYPTION_KEY) +# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY existingSecret: "" -existingSecretKey: JWT_SECRET +existingSecretKey: ENCRYPTION_KEY persistence: enabled: true diff --git a/docker-compose.yml b/docker-compose.yml index 8d27b48..bfa0092 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,6 @@ services: environment: - NODE_ENV=production - PORT=3000 - - JWT_SECRET=${JWT_SECRET:-} # Auto-generated if not set; persist across restarts for stable sessions - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Auto-generated if not set. If upgrading, set to your old JWT_SECRET value to keep existing encrypted secrets readable. - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details diff --git a/server/.env.example b/server/.env.example index 1d13427..8396628 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,9 +1,9 @@ PORT=3001 # Port to run the server on NODE_ENV=development # development = development mode; production = production mode -JWT_SECRET=your-super-secret-jwt-key-change-in-production # Auto-generated if not set; persist across restarts for stable sessions # ENCRYPTION_KEY= # Separate key for encrypting stored secrets (API keys, MFA, SMTP, OIDC, etc.) # Auto-generated and persisted to ./data/.encryption_key if not set. -# Upgrade: set to your old JWT_SECRET value if you have existing encrypted data from a previous installation. +# Upgrade from a version that used JWT_SECRET for encryption: set to your old JWT_SECRET value so +# existing encrypted data remains readable, then re-save credentials via the admin panel. # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details diff --git a/server/src/config.ts b/server/src/config.ts index f42c25b..4fdeb2b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -2,29 +2,28 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -let _jwtSecret: string = process.env.JWT_SECRET || ''; +const dataDir = path.resolve(__dirname, '../data'); -if (!_jwtSecret) { - const dataDir = path.resolve(__dirname, '../data'); - const secretFile = path.join(dataDir, '.jwt_secret'); +// JWT_SECRET is always managed by the server — auto-generated on first start and +// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it +// via environment variable (env var would override a rotation on next restart). +const jwtSecretFile = path.join(dataDir, '.jwt_secret'); +let _jwtSecret: string; +try { + _jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim(); +} catch { + _jwtSecret = crypto.randomBytes(32).toString('hex'); try { - _jwtSecret = fs.readFileSync(secretFile, 'utf8').trim(); - } catch { - _jwtSecret = crypto.randomBytes(32).toString('hex'); - try { - if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); - fs.writeFileSync(secretFile, _jwtSecret, { mode: 0o600 }); - console.log('Generated and saved JWT secret to', secretFile); - } catch (writeErr: unknown) { - console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr); - console.warn('Sessions will reset on server restart. Set JWT_SECRET env var for persistent sessions.'); - } + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 }); + console.log('Generated and saved JWT secret to', jwtSecretFile); + } catch (writeErr: unknown) { + console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr); + console.warn('Sessions will reset on server restart.'); } } -const JWT_SECRET_IS_GENERATED = !process.env.JWT_SECRET; - // export let so TypeScript's CJS output keeps exports.JWT_SECRET live // (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret) export let JWT_SECRET = _jwtSecret; @@ -48,7 +47,6 @@ export function updateJwtSecret(newSecret: string): void { let ENCRYPTION_KEY: string = process.env.ENCRYPTION_KEY || ''; if (!ENCRYPTION_KEY) { - const dataDir = path.resolve(__dirname, '../data'); const keyFile = path.join(dataDir, '.encryption_key'); try { @@ -66,4 +64,4 @@ if (!ENCRYPTION_KEY) { } } -export { JWT_SECRET_IS_GENERATED, ENCRYPTION_KEY }; +export { ENCRYPTION_KEY }; diff --git a/server/src/index.ts b/server/src/index.ts index e588d30..c79bcc8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,4 @@ import 'dotenv/config'; -import { JWT_SECRET_IS_GENERATED } from './config'; import express, { Request, Response, NextFunction } from 'express'; import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy'; import cors from 'cors'; @@ -268,9 +267,6 @@ const server = app.listen(PORT, () => { '──────────────────────────────────────', ]; banner.forEach(l => console.log(l)); - if (JWT_SECRET_IS_GENERATED) { - sLogWarn('[SECURITY WARNING] JWT_SECRET was auto-generated. Sessions will not persist across restarts. Set JWT_SECRET env var for production use.'); - } if (process.env.DEMO_MODE === 'true') sLogInfo('Demo mode: ENABLED'); if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') { sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!'); From b109c1340a0725211b68bdbdfe58d3d13d3e2f5d Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 06:43:01 +0200 Subject: [PATCH 12/25] fix: prevent ICS header injection in calendar export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three vulnerabilities patched in the /export.ics route: - esc() now handles bare \r and CRLF sequences — the previous regex only matched \n, leaving \r intact and allowing CRLF injection via \r\n - reservation DESCRIPTION field was built from unescaped user data (type, confirmation_number, notes, airline, flight/train numbers, airports) and written raw into ICS output; now passed through esc() - Content-Disposition filename used ICS escaping instead of HTTP header sanitization; replaced with a character allowlist to prevent " and \r\n injection into the response header --- server/src/routes/trips.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts index f400a65..d88576e 100644 --- a/server/src/routes/trips.ts +++ b/server/src/routes/trips.ts @@ -406,7 +406,12 @@ router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => { const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(req.params.id) as any[]; const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[]; - const esc = (s: string) => s.replace(/[\\;,\n]/g, m => m === '\n' ? '\\n' : '\\' + m); + const esc = (s: string) => s + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r?\n/g, '\\n') + .replace(/\r/g, ''); const fmtDate = (d: string) => d.replace(/-/g, ''); const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; const uid = (id: number, type: string) => `trek-${type}-${id}@trek`; @@ -454,14 +459,14 @@ router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => { ics += `SUMMARY:${esc(r.title)}\r\n`; let desc = r.type ? `Type: ${r.type}` : ''; - if (r.confirmation_number) desc += `\\nConfirmation: ${r.confirmation_number}`; - if (meta.airline) desc += `\\nAirline: ${meta.airline}`; - if (meta.flight_number) desc += `\\nFlight: ${meta.flight_number}`; - if (meta.departure_airport) desc += `\\nFrom: ${meta.departure_airport}`; - if (meta.arrival_airport) desc += `\\nTo: ${meta.arrival_airport}`; - if (meta.train_number) desc += `\\nTrain: ${meta.train_number}`; - if (r.notes) desc += `\\n${r.notes}`; - if (desc) ics += `DESCRIPTION:${desc}\r\n`; + if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`; + if (meta.airline) desc += `\nAirline: ${meta.airline}`; + if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`; + if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`; + if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`; + if (meta.train_number) desc += `\nTrain: ${meta.train_number}`; + if (r.notes) desc += `\n${r.notes}`; + if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`; ics += `END:VEVENT\r\n`; } @@ -469,7 +474,8 @@ router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => { ics += 'END:VCALENDAR\r\n'; res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename="${esc(trip.title || 'trek-trip')}.ics"`); + const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_'); + res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}.ics"`); res.send(ics); }); From ce8d498f2d045835661a0367812e113b6190dcec Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 06:47:20 +0200 Subject: [PATCH 13/25] fix: add independent rate limiter for MFA verification endpoints TOTP brute-force is a realistic attack once a password is compromised: with no independent throttle, an attacker shared the login budget (10 attempts) across /login, /register, and /mfa/verify-login, and /mfa/enable had no rate limiting at all. - Add a dedicated `mfaAttempts` store so MFA limits are tracked separately from login attempts - Introduce `mfaLimiter` (5 attempts / 15 min) applied to both /mfa/verify-login and /mfa/enable - Refactor `rateLimiter()` to accept an optional store parameter, keeping all existing call-sites unchanged - Include mfaAttempts in the periodic cleanup interval --- server/src/routes/auth.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 62922e0..6cfc40d 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -113,23 +113,27 @@ const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes const RATE_LIMIT_CLEANUP = 5 * 60 * 1000; // 5 minutes const loginAttempts = new Map(); +const mfaAttempts = new Map(); setInterval(() => { const now = Date.now(); for (const [key, record] of loginAttempts) { if (now - record.first >= RATE_LIMIT_WINDOW) loginAttempts.delete(key); } + for (const [key, record] of mfaAttempts) { + if (now - record.first >= RATE_LIMIT_WINDOW) mfaAttempts.delete(key); + } }, RATE_LIMIT_CLEANUP); -function rateLimiter(maxAttempts: number, windowMs: number) { +function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempts) { return (req: Request, res: Response, next: NextFunction) => { const key = req.ip || 'unknown'; const now = Date.now(); - const record = loginAttempts.get(key); + const record = store.get(key); if (record && record.count >= maxAttempts && now - record.first < windowMs) { return res.status(429).json({ error: 'Too many attempts. Please try again later.' }); } if (!record || now - record.first >= windowMs) { - loginAttempts.set(key, { count: 1, first: now }); + store.set(key, { count: 1, first: now }); } else { record.count++; } @@ -137,6 +141,7 @@ function rateLimiter(maxAttempts: number, windowMs: number) { }; } const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW); +const mfaLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, mfaAttempts); function isOidcOnlyMode(): boolean { const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; @@ -778,7 +783,7 @@ router.get('/travel-stats', authenticate, (req: Request, res: Response) => { }); }); -router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => { +router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => { const { mfa_token, code } = req.body as { mfa_token?: string; code?: string }; if (!mfa_token || !code) { return res.status(400).json({ error: 'Verification token and code are required' }); @@ -846,7 +851,7 @@ router.post('/mfa/setup', authenticate, (req: Request, res: Response) => { }); }); -router.post('/mfa/enable', authenticate, (req: Request, res: Response) => { +router.post('/mfa/enable', authenticate, mfaLimiter, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { code } = req.body as { code?: string }; if (!code) { From e03505dca2d361eacd2f607326a8b73acad765c6 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 07:02:53 +0200 Subject: [PATCH 14/25] fix: enforce consistent password policy across all auth flows Replace duplicated inline validation with a shared validatePassword() utility that checks minimum length (8), rejects repetitive and common passwords, and requires uppercase, lowercase, a digit, and a special character. - Add server/src/services/passwordPolicy.ts as single source of truth - Apply to registration, password change, and admin create/edit user (admin routes previously had zero validation) - Fix client min-length mismatch (6 vs 8) in RegisterPage and LoginPage - Add client-side password length guard to AdminPage forms - Update register.passwordTooShort and settings.passwordWeak i18n keys in all 12 locales to reflect the corrected requirements --- client/src/i18n/translations/ar.ts | 4 ++-- client/src/i18n/translations/br.ts | 4 ++-- client/src/i18n/translations/cs.ts | 4 ++-- client/src/i18n/translations/de.ts | 4 ++-- client/src/i18n/translations/en.ts | 4 ++-- client/src/i18n/translations/es.ts | 4 ++-- client/src/i18n/translations/fr.ts | 4 ++-- client/src/i18n/translations/hu.ts | 4 ++-- client/src/i18n/translations/it.ts | 4 ++-- client/src/i18n/translations/nl.ts | 4 ++-- client/src/i18n/translations/ru.ts | 4 ++-- client/src/i18n/translations/zh.ts | 4 ++-- client/src/pages/AdminPage.tsx | 12 +++++++++++- client/src/pages/LoginPage.tsx | 2 +- client/src/pages/RegisterPage.tsx | 2 +- server/src/routes/admin.ts | 8 ++++++++ server/src/routes/auth.ts | 17 +++++------------ server/src/services/passwordPolicy.ts | 27 +++++++++++++++++++++++++++ 18 files changed, 77 insertions(+), 39 deletions(-) create mode 100644 server/src/services/passwordPolicy.ts diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 1974c6f..f38a2c7 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -255,7 +255,7 @@ const ar: Record = { 'settings.passwordRequired': 'أدخل كلمة المرور الحالية والجديدة', 'settings.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل', 'settings.passwordMismatch': 'كلمتا المرور غير متطابقتين', - 'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم', + 'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم ورمز خاص', 'settings.passwordChanged': 'تم تغيير كلمة المرور بنجاح', 'settings.deleteAccount': 'حذف الحساب', 'settings.deleteAccountTitle': 'هل تريد حذف حسابك؟', @@ -354,7 +354,7 @@ const ar: Record = { // Register 'register.passwordMismatch': 'كلمتا المرور غير متطابقتين', - 'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 6 أحرف على الأقل', + 'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل', 'register.failed': 'فشل التسجيل', 'register.getStarted': 'ابدأ الآن', 'register.subtitle': 'أنشئ حسابًا وابدأ التخطيط لرحلات أحلامك.', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 5ae2943..0f0cef1 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -225,7 +225,7 @@ const br: Record = { 'settings.passwordRequired': 'Informe a senha atual e a nova', 'settings.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres', 'settings.passwordMismatch': 'As senhas não coincidem', - 'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula e número', + 'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula, número e um caractere especial', 'settings.passwordChanged': 'Senha alterada com sucesso', 'settings.deleteAccount': 'Excluir conta', 'settings.deleteAccountTitle': 'Excluir sua conta?', @@ -349,7 +349,7 @@ const br: Record = { // Register 'register.passwordMismatch': 'As senhas não coincidem', - 'register.passwordTooShort': 'A senha deve ter pelo menos 6 caracteres', + 'register.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres', 'register.failed': 'Falha no cadastro', 'register.getStarted': 'Começar', 'register.subtitle': 'Crie uma conta e comece a planejar suas viagens.', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index a57b3b7..b5c96ae 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -203,7 +203,7 @@ const cs: Record = { 'settings.passwordRequired': 'Zadejte prosím současné i nové heslo', 'settings.passwordTooShort': 'Heslo musí mít alespoň 8 znaků', 'settings.passwordMismatch': 'Hesla se neshodují', - 'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno a číslici', + 'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno, číslici a speciální znak', 'settings.passwordChanged': 'Heslo bylo úspěšně změněno', 'settings.deleteAccount': 'Smazat účet', 'settings.deleteAccountTitle': 'Smazat váš účet?', @@ -350,7 +350,7 @@ const cs: Record = { // Registrace (Register) 'register.passwordMismatch': 'Hesla se neshodují', - 'register.passwordTooShort': 'Heslo musí mít alespoň 6 znaků', + 'register.passwordTooShort': 'Heslo musí mít alespoň 8 znaků', 'register.failed': 'Registrace se nezdařila', 'register.getStarted': 'Začínáme', 'register.subtitle': 'Vytvořte si účet a začněte plánovat svou vysněnou cestu.', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index f998bfd..e83a4a3 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -250,7 +250,7 @@ const de: Record = { 'settings.passwordRequired': 'Bitte aktuelles und neues Passwort eingeben', 'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein', 'settings.passwordMismatch': 'Passwörter stimmen nicht überein', - 'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben und eine Zahl enthalten', + 'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten', 'settings.passwordChanged': 'Passwort erfolgreich geändert', 'settings.deleteAccount': 'Löschen', 'settings.deleteAccountTitle': 'Account wirklich löschen?', @@ -349,7 +349,7 @@ const de: Record = { // Register 'register.passwordMismatch': 'Passwörter stimmen nicht überein', - 'register.passwordTooShort': 'Passwort muss mindestens 6 Zeichen lang sein', + 'register.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein', 'register.failed': 'Registrierung fehlgeschlagen', 'register.getStarted': 'Jetzt starten', 'register.subtitle': 'Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 15aba3b..816b4ad 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -250,7 +250,7 @@ const en: Record = { 'settings.passwordRequired': 'Please enter current and new password', 'settings.passwordTooShort': 'Password must be at least 8 characters', 'settings.passwordMismatch': 'Passwords do not match', - 'settings.passwordWeak': 'Password must contain uppercase, lowercase, and a number', + 'settings.passwordWeak': 'Password must contain uppercase, lowercase, a number, and a special character', 'settings.passwordChanged': 'Password changed successfully', 'settings.mustChangePassword': 'You must change your password before you can continue. Please set a new password below.', 'settings.deleteAccount': 'Delete account', @@ -350,7 +350,7 @@ const en: Record = { // Register 'register.passwordMismatch': 'Passwords do not match', - 'register.passwordTooShort': 'Password must be at least 6 characters', + 'register.passwordTooShort': 'Password must be at least 8 characters', 'register.failed': 'Registration failed', 'register.getStarted': 'Get Started', 'register.subtitle': 'Create an account and start planning your dream trips.', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 48dd65a..5a1f180 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -347,7 +347,7 @@ const es: Record = { // Register 'register.passwordMismatch': 'Las contraseñas no coinciden', - 'register.passwordTooShort': 'La contraseña debe tener al menos 6 caracteres', + 'register.passwordTooShort': 'La contraseña debe tener al menos 8 caracteres', 'register.failed': 'Falló el registro', 'register.getStarted': 'Empezar', 'register.subtitle': 'Crea una cuenta y empieza a planificar tus viajes.', @@ -1425,7 +1425,7 @@ const es: Record = { // Settings (2.6.2) 'settings.currentPasswordRequired': 'La contraseña actual es obligatoria', - 'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas y números', + 'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas, números y un carácter especial', // Permissions 'admin.tabs.permissions': 'Permisos', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 3550b4f..55b334b 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -250,7 +250,7 @@ const fr: Record = { 'settings.passwordRequired': 'Veuillez saisir le mot de passe actuel et le nouveau', 'settings.passwordTooShort': 'Le mot de passe doit comporter au moins 8 caractères', 'settings.passwordMismatch': 'Les mots de passe ne correspondent pas', - 'settings.passwordWeak': 'Le mot de passe doit contenir des majuscules, des minuscules et un chiffre', + 'settings.passwordWeak': 'Le mot de passe doit contenir des majuscules, des minuscules, un chiffre et un caractère spécial', 'settings.passwordChanged': 'Mot de passe modifié avec succès', 'settings.deleteAccount': 'Supprimer le compte', 'settings.deleteAccountTitle': 'Supprimer votre compte ?', @@ -349,7 +349,7 @@ const fr: Record = { // Register 'register.passwordMismatch': 'Les mots de passe ne correspondent pas', - 'register.passwordTooShort': 'Le mot de passe doit comporter au moins 6 caractères', + 'register.passwordTooShort': 'Le mot de passe doit comporter au moins 8 caractères', 'register.failed': 'Échec de l\'inscription', 'register.getStarted': 'Commencer', 'register.subtitle': 'Créez un compte et commencez à planifier vos voyages de rêve.', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 3815d73..67928e8 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -201,7 +201,7 @@ const hu: Record = { 'settings.passwordRequired': 'Kérjük, add meg a jelenlegi és az új jelszót', 'settings.currentPasswordRequired': 'A jelenlegi jelszó megadása kötelező', 'settings.passwordTooShort': 'A jelszónak legalább 8 karakter hosszúnak kell lennie', - 'settings.passwordWeak': 'A jelszónak tartalmaznia kell nagybetűt, kisbetűt és számot', + 'settings.passwordWeak': 'A jelszónak tartalmaznia kell nagybetűt, kisbetűt, számot és speciális karaktert', 'settings.passwordMismatch': 'A jelszavak nem egyeznek', 'settings.passwordChanged': 'Jelszó sikeresen módosítva', 'settings.deleteAccount': 'Törlés', @@ -349,7 +349,7 @@ const hu: Record = { // Regisztráció 'register.passwordMismatch': 'A jelszavak nem egyeznek', - 'register.passwordTooShort': 'A jelszónak legalább 6 karakter hosszúnak kell lennie', + 'register.passwordTooShort': 'A jelszónak legalább 8 karakter hosszúnak kell lennie', 'register.failed': 'Regisztráció sikertelen', 'register.getStarted': 'Kezdjük', 'register.subtitle': 'Hozz létre egy fiókot, és kezdd el megtervezni álomutazásaidat.', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 53acacc..85ed1b2 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -202,7 +202,7 @@ const it: Record = { 'settings.passwordRequired': 'Inserisci la password attuale e quella nuova', 'settings.passwordTooShort': 'La password deve contenere almeno 8 caratteri', 'settings.passwordMismatch': 'Le password non corrispondono', - 'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole e un numero', + 'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole, un numero e un carattere speciale', 'settings.passwordChanged': 'Password cambiata con successo', 'settings.deleteAccount': 'Elimina account', 'settings.deleteAccountTitle': 'Eliminare il tuo account?', @@ -349,7 +349,7 @@ const it: Record = { // Register 'register.passwordMismatch': 'Le password non corrispondono', - 'register.passwordTooShort': 'La password deve contenere almeno 6 caratteri', + 'register.passwordTooShort': 'La password deve contenere almeno 8 caratteri', 'register.failed': 'Registrazione fallita', 'register.getStarted': 'Inizia', 'register.subtitle': 'Crea un account e inizia a programmare i viaggi dei tuoi sogni.', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 7f8a985..e448a87 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -250,7 +250,7 @@ const nl: Record = { 'settings.passwordRequired': 'Voer het huidige en nieuwe wachtwoord in', 'settings.passwordTooShort': 'Wachtwoord moet minimaal 8 tekens bevatten', 'settings.passwordMismatch': 'Wachtwoorden komen niet overeen', - 'settings.passwordWeak': 'Wachtwoord moet hoofdletters, kleine letters en een cijfer bevatten', + 'settings.passwordWeak': 'Wachtwoord moet hoofdletters, kleine letters, een cijfer en een speciaal teken bevatten', 'settings.passwordChanged': 'Wachtwoord succesvol gewijzigd', 'settings.deleteAccount': 'Account verwijderen', 'settings.deleteAccountTitle': 'Account verwijderen?', @@ -349,7 +349,7 @@ const nl: Record = { // Register 'register.passwordMismatch': 'Wachtwoorden komen niet overeen', - 'register.passwordTooShort': 'Wachtwoord moet minimaal 6 tekens bevatten', + 'register.passwordTooShort': 'Wachtwoord moet minimaal 8 tekens bevatten', 'register.failed': 'Registratie mislukt', 'register.getStarted': 'Aan de slag', 'register.subtitle': 'Maak een account aan en begin met het plannen van je droomreizen.', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index cd23c2d..0710cdb 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -250,7 +250,7 @@ const ru: Record = { 'settings.passwordRequired': 'Введите текущий и новый пароль', 'settings.passwordTooShort': 'Пароль должен содержать не менее 8 символов', 'settings.passwordMismatch': 'Пароли не совпадают', - 'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы и цифру', + 'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы, цифру и специальный символ', 'settings.passwordChanged': 'Пароль успешно изменён', 'settings.deleteAccount': 'Удалить аккаунт', 'settings.deleteAccountTitle': 'Удалить ваш аккаунт?', @@ -349,7 +349,7 @@ const ru: Record = { // Register 'register.passwordMismatch': 'Пароли не совпадают', - 'register.passwordTooShort': 'Пароль должен содержать не менее 6 символов', + 'register.passwordTooShort': 'Пароль должен содержать не менее 8 символов', 'register.failed': 'Ошибка регистрации', 'register.getStarted': 'Начать', 'register.subtitle': 'Создайте аккаунт и начните планировать поездки мечты.', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 0e887df..47bab93 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -250,7 +250,7 @@ const zh: Record = { 'settings.passwordRequired': '请输入当前密码和新密码', 'settings.passwordTooShort': '密码至少需要 8 个字符', 'settings.passwordMismatch': '两次输入的密码不一致', - 'settings.passwordWeak': '密码必须包含大写字母、小写字母和数字', + 'settings.passwordWeak': '密码必须包含大写字母、小写字母、数字和特殊字符', 'settings.passwordChanged': '密码修改成功', 'settings.deleteAccount': '删除账户', 'settings.deleteAccountTitle': '确定删除账户?', @@ -349,7 +349,7 @@ const zh: Record = { // Register 'register.passwordMismatch': '两次输入的密码不一致', - 'register.passwordTooShort': '密码至少需要 6 个字符', + 'register.passwordTooShort': '密码至少需要 8 个字符', 'register.failed': '注册失败', 'register.getStarted': '开始使用', 'register.subtitle': '创建账户,开始规划你的梦想旅行。', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index d4d6306..d5aad0e 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -253,6 +253,10 @@ export default function AdminPage(): React.ReactElement { toast.error(t('admin.toast.fieldsRequired')) return } + if (createForm.password.trim().length < 8) { + toast.error(t('settings.passwordTooShort')) + return + } try { const data = await adminApi.createUser(createForm) setUsers(prev => [data.user, ...prev]) @@ -308,7 +312,13 @@ export default function AdminPage(): React.ReactElement { email: editForm.email.trim() || undefined, role: editForm.role, } - if (editForm.password.trim()) payload.password = editForm.password.trim() + if (editForm.password.trim()) { + if (editForm.password.trim().length < 8) { + toast.error(t('settings.passwordTooShort')) + return + } + payload.password = editForm.password.trim() + } const data = await adminApi.updateUser(editingUser.id, payload) setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u)) setEditingUser(null) diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 47e244c..1f8283b 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -150,7 +150,7 @@ export default function LoginPage(): React.ReactElement { } if (mode === 'register') { if (!username.trim()) { setError('Username is required'); setIsLoading(false); return } - if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return } + if (password.length < 8) { setError('Password must be at least 8 characters'); setIsLoading(false); return } await register(username, email, password, inviteToken || undefined) } else { const result = await login(email, password) diff --git a/client/src/pages/RegisterPage.tsx b/client/src/pages/RegisterPage.tsx index 762d1f1..824a615 100644 --- a/client/src/pages/RegisterPage.tsx +++ b/client/src/pages/RegisterPage.tsx @@ -26,7 +26,7 @@ export default function RegisterPage(): React.ReactElement { return } - if (password.length < 6) { + if (password.length < 8) { setError(t('register.passwordTooShort')) return } diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 5b62298..cfe6544 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -10,6 +10,7 @@ import { writeAudit, getClientIp, logInfo } from '../services/auditLog'; import { getAllPermissions, savePermissions, PERMISSION_ACTIONS } from '../services/permissions'; import { revokeUserSessions } from '../mcp'; import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; +import { validatePassword } from '../services/passwordPolicy'; import { updateJwtSecret } from '../config'; const router = express.Router(); @@ -47,6 +48,9 @@ router.post('/users', (req: Request, res: Response) => { return res.status(400).json({ error: 'Username, email and password are required' }); } + const pwCheck = validatePassword(password.trim()); + if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason }); + if (role && !['user', 'admin'].includes(role)) { return res.status(400).json({ error: 'Invalid role' }); } @@ -97,6 +101,10 @@ router.put('/users/:id', (req: Request, res: Response) => { if (conflict) return res.status(409).json({ error: 'Email already taken' }); } + if (password) { + const pwCheck = validatePassword(password); + if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason }); + } const passwordHash = password ? bcrypt.hashSync(password, 12) : null; db.prepare(` diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 6cfc40d..f86357e 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -10,6 +10,7 @@ import fetch from 'node-fetch'; import { authenticator } from 'otplib'; import QRCode from 'qrcode'; import { db } from '../db/database'; +import { validatePassword } from '../services/passwordPolicy'; import { authenticate, optionalAuth, demoUploadBlock } from '../middleware/auth'; import { JWT_SECRET } from '../config'; import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto'; @@ -268,13 +269,8 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { return res.status(400).json({ error: 'Username, email and password are required' }); } - if (password.length < 8) { - return res.status(400).json({ error: 'Password must be at least 8 characters' }); - } - - if (!/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) { - return res.status(400).json({ error: 'Password must contain at least one uppercase letter, one lowercase letter, and one number' }); - } + const pwCheck = validatePassword(password); + if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason }); const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { @@ -382,11 +378,8 @@ router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req const { current_password, new_password } = req.body; if (!current_password) return res.status(400).json({ error: 'Current password is required' }); if (!new_password) return res.status(400).json({ error: 'New password is required' }); - if (new_password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); - - if (!/[A-Z]/.test(new_password) || !/[a-z]/.test(new_password) || !/[0-9]/.test(new_password)) { - return res.status(400).json({ error: 'Password must contain at least one uppercase letter, one lowercase letter, and one number' }); - } + const pwCheck = validatePassword(new_password); + if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason }); const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(authReq.user.id) as { password_hash: string } | undefined; if (!user || !bcrypt.compareSync(current_password, user.password_hash)) { diff --git a/server/src/services/passwordPolicy.ts b/server/src/services/passwordPolicy.ts new file mode 100644 index 0000000..ad817d1 --- /dev/null +++ b/server/src/services/passwordPolicy.ts @@ -0,0 +1,27 @@ +const COMMON_PASSWORDS = new Set([ + 'password', '12345678', '123456789', '1234567890', 'password1', + 'qwerty123', 'iloveyou', 'admin123', 'letmein12', 'welcome1', + 'monkey123', 'dragon12', 'master12', 'qwerty12', 'abc12345', + 'trustno1', 'baseball', 'football', 'shadow12', 'michael1', + 'jennifer', 'superman', 'abcdefgh', 'abcd1234', 'password123', + 'admin1234', 'changeme', 'welcome123', 'passw0rd', 'p@ssword', +]); + +export function validatePassword(password: string): { ok: boolean; reason?: string } { + if (password.length < 8) return { ok: false, reason: 'Password must be at least 8 characters' }; + + if (/^(.)\1+$/.test(password)) { + return { ok: false, reason: 'Password is too repetitive' }; + } + + if (COMMON_PASSWORDS.has(password.toLowerCase())) { + return { ok: false, reason: 'Password is too common. Please choose a unique password.' }; + } + + if (!/[A-Z]/.test(password)) return { ok: false, reason: 'Password must contain at least one uppercase letter' }; + if (!/[a-z]/.test(password)) return { ok: false, reason: 'Password must contain at least one lowercase letter' }; + if (!/[0-9]/.test(password)) return { ok: false, reason: 'Password must contain at least one number' }; + if (!/[^A-Za-z0-9]/.test(password)) return { ok: false, reason: 'Password must contain at least one special character' }; + + return { ok: true }; +} From 7a314a92b15af9e112517471f314da559240f1cb Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 07:53:46 +0200 Subject: [PATCH 15/25] fix: add SSRF protection for link preview and Immich URL - Create server/src/utils/ssrfGuard.ts with checkSsrf() and createPinnedAgent() - Resolves DNS before allowing outbound requests to catch hostnames that map to private IPs (closes the TOCTOU gap in the old inline checks) - Always blocks loopback (127.x, ::1) and link-local/metadata (169.254.x) - RFC-1918, CGNAT (100.64/10), and IPv6 ULA ranges blocked by default; opt-in via ALLOW_INTERNAL_NETWORK=true for self-hosters running Immich on a local network - createPinnedAgent() pins node-fetch to the validated IP, preventing DNS rebinding between the check and the actual connection - Replace isValidImmichUrl() (hostname-string check, no DNS resolution) with checkSsrf(); make PUT /integrations/immich/settings async - Audit log entry (immich.private_ip_configured) written when a user saves an Immich URL that resolves to a private IP - Response includes a warning field surfaced as a toast in the UI - Replace ~20 lines of duplicated inline SSRF logic in the link-preview handler with a single checkSsrf() call + pinned agent - Document ALLOW_INTERNAL_NETWORK in README, docker-compose.yml, server/.env.example, chart/values.yaml, chart/templates/configmap.yaml, and chart/README.md --- README.md | 2 + chart/README.md | 1 + chart/templates/configmap.yaml | 3 + chart/values.yaml | 5 +- client/src/pages/SettingsPage.tsx | 3 +- docker-compose.yml | 1 + server/.env.example | 1 + server/src/routes/collab.ts | 29 ++----- server/src/routes/immich.ts | 59 ++++++++------- server/src/services/auditLog.ts | 4 + server/src/utils/ssrfGuard.ts | 122 ++++++++++++++++++++++++++++++ 11 files changed, 180 insertions(+), 50 deletions(-) create mode 100644 server/src/utils/ssrfGuard.ts diff --git a/README.md b/README.md index 7237f6c..003afa4 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ services: - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details + # - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich is on your local network (RFC-1918 IPs) volumes: - ./data:/app/data - ./uploads:/app/uploads @@ -251,6 +252,7 @@ trek.yourdomain.com { | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | | `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` | | `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` | +| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` | | **OIDC / SSO** | | | | `OIDC_ISSUER` | OpenID Connect provider URL | — | | `OIDC_CLIENT_ID` | OIDC client ID | — | diff --git a/chart/README.md b/chart/README.md index e6c7cb2..955e926 100644 --- a/chart/README.md +++ b/chart/README.md @@ -31,3 +31,4 @@ See `values.yaml` for more options. - `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed. - `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Auto-generated and persisted to the data PVC if not provided. **Upgrading:** if a previous version used `JWT_SECRET`-derived encryption, set `secretEnv.ENCRYPTION_KEY` to your old `JWT_SECRET` value to keep existing encrypted data readable, then re-save credentials via the admin panel. - If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically. +- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless. diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 7a7ed6a..7e0a5a3 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -10,3 +10,6 @@ data: {{- if .Values.env.ALLOWED_ORIGINS }} ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }} {{- end }} + {{- if .Values.env.ALLOW_INTERNAL_NETWORK }} + ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }} + {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 8613327..8c5968f 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -16,7 +16,10 @@ env: NODE_ENV: production PORT: 3000 # ALLOWED_ORIGINS: "" -# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. + # NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. + # ALLOW_INTERNAL_NETWORK: "false" + # Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address. + # Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked. # Secret environment variables stored in a Kubernetes Secret. diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 36d29a1..70410d5 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -145,7 +145,8 @@ export default function SettingsPage(): React.ReactElement { const handleSaveImmich = async () => { setSaving(s => ({ ...s, immich: true })) try { - await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined }) + const saveRes = await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined }) + if (saveRes.data.warning) toast.warn(saveRes.data.warning) toast.success(t('memories.saved')) // Test connection const res = await apiClient.get('/integrations/immich/status') diff --git a/docker-compose.yml b/docker-compose.yml index bfa0092..42979a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy - TRUST_PROXY=1 # Number of trusted proxies (for X-Forwarded-For / real client IP) + - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless. - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL - OIDC_CLIENT_ID=trek # OpenID Connect client ID - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret diff --git a/server/.env.example b/server/.env.example index 8396628..1dcabc0 100644 --- a/server/.env.example +++ b/server/.env.example @@ -11,6 +11,7 @@ LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level detail ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links FORCE_HTTPS=false # Redirect HTTP → HTTPS behind a TLS proxy TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For +ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked. APP_URL=https://trek.example.com # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts index 98be75a..15f1779 100644 --- a/server/src/routes/collab.ts +++ b/server/src/routes/collab.ts @@ -9,6 +9,7 @@ import { broadcast } from '../websocket'; import { validateStringLengths } from '../middleware/validate'; import { checkPermission } from '../services/permissions'; import { AuthRequest, CollabNote, CollabPoll, CollabMessage, TripFile } from '../types'; +import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard'; interface ReactionRow { emoji: string; @@ -513,35 +514,19 @@ router.get('/link-preview', authenticate, async (req: Request, res: Response) => try { const parsed = new URL(url); - if (!['http:', 'https:'].includes(parsed.protocol)) { - return res.status(400).json({ error: 'Only HTTP(S) URLs are allowed' }); - } - const hostname = parsed.hostname.toLowerCase(); - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || - hostname === '0.0.0.0' || hostname.endsWith('.local') || hostname.endsWith('.internal') || - /^10\./.test(hostname) || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || /^192\.168\./.test(hostname) || - /^169\.254\./.test(hostname) || hostname === '[::1]' || hostname.startsWith('fc') || hostname.startsWith('fd') || hostname.startsWith('fe80')) { - return res.status(400).json({ error: 'Private/internal URLs are not allowed' }); - } - - const dns = require('dns').promises; - let resolved: { address: string }; - try { - resolved = await dns.lookup(parsed.hostname); - } catch { - return res.status(400).json({ error: 'Could not resolve hostname' }); - } - const ip = resolved.address; - if (/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.|169\.254\.|::1|::ffff:(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.))/.test(ip)) { - return res.status(400).json({ error: 'Private/internal URLs are not allowed' }); + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) { + return res.status(400).json({ error: ssrf.error }); } const nodeFetch = require('node-fetch'); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); - nodeFetch(url, { redirect: 'error', + nodeFetch(url, { + redirect: 'error', signal: controller.signal, + agent: createPinnedAgent(ssrf.resolvedIp!, parsed.protocol), headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' }, }) .then((r: { ok: boolean; text: () => Promise }) => { diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 7b52e98..6e08f55 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -5,6 +5,8 @@ import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; import { consumeEphemeralToken } from '../services/ephemeralTokens'; import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; +import { checkSsrf } from '../utils/ssrfGuard'; +import { writeAudit, getClientIp } from '../services/auditLog'; const router = express.Router(); @@ -19,24 +21,6 @@ function isValidAssetId(id: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100; } -/** Validate that an Immich URL is a safe HTTP(S) URL (no internal/metadata IPs). */ -function isValidImmichUrl(raw: string): boolean { - try { - const url = new URL(raw); - if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; - const hostname = url.hostname.toLowerCase(); - // Block metadata endpoints and localhost - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false; - if (hostname === '169.254.169.254' || hostname === 'metadata.google.internal') return false; - // Block link-local and loopback ranges - if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.')) return false; - if (hostname.endsWith('.internal') || hostname.endsWith('.local')) return false; - return true; - } catch { - return false; - } -} - // ── Immich Connection Settings ────────────────────────────────────────────── router.get('/settings', authenticate, (req: Request, res: Response) => { @@ -48,17 +32,40 @@ router.get('/settings', authenticate, (req: Request, res: Response) => { }); }); -router.put('/settings', authenticate, (req: Request, res: Response) => { +router.put('/settings', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { immich_url, immich_api_key } = req.body; - if (immich_url && !isValidImmichUrl(immich_url.trim())) { - return res.status(400).json({ error: 'Invalid Immich URL. Must be a valid HTTP(S) URL.' }); + + if (immich_url) { + const ssrf = await checkSsrf(immich_url.trim()); + if (!ssrf.allowed) { + return res.status(400).json({ error: `Invalid Immich URL: ${ssrf.error}` }); + } + db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( + immich_url.trim(), + maybe_encrypt_api_key(immich_api_key), + authReq.user.id + ); + if (ssrf.isPrivate) { + writeAudit({ + userId: authReq.user.id, + action: 'immich.private_ip_configured', + ip: getClientIp(req), + details: { immich_url: immich_url.trim(), resolved_ip: ssrf.resolvedIp }, + }); + return res.json({ + success: true, + warning: `Immich URL resolves to a private IP address (${ssrf.resolvedIp}). Make sure this is intentional.`, + }); + } + } else { + db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( + null, + maybe_encrypt_api_key(immich_api_key), + authReq.user.id + ); } - db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( - immich_url?.trim() || null, - maybe_encrypt_api_key(immich_api_key), - authReq.user.id - ); + res.json({ success: true }); }); diff --git a/server/src/services/auditLog.ts b/server/src/services/auditLog.ts index 527d514..03476ec 100644 --- a/server/src/services/auditLog.ts +++ b/server/src/services/auditLog.ts @@ -108,6 +108,7 @@ const ACTION_LABELS: Record = { 'admin.user_role_change': 'changed user role', 'admin.user_delete': 'deleted user', 'admin.invite_create': 'created invite', + 'immich.private_ip_configured': 'configured Immich with private IP', }; /** Best-effort; never throws — failures are logged only. */ @@ -158,6 +159,9 @@ function buildInfoSummary(action: string, details?: Record): st if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`); return parts.length ? ` (${parts.join(', ')})` : ''; } + if (action === 'immich.private_ip_configured') { + return details.resolved_ip ? ` url=${details.immich_url} ip=${details.resolved_ip}` : ''; + } return ''; } diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts new file mode 100644 index 0000000..6882cdc --- /dev/null +++ b/server/src/utils/ssrfGuard.ts @@ -0,0 +1,122 @@ +import dns from 'dns/promises'; +import http from 'http'; +import https from 'https'; + +const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK === 'true'; + +export interface SsrfResult { + allowed: boolean; + resolvedIp?: string; + isPrivate: boolean; + error?: string; +} + +// Always blocked — no override possible +function isAlwaysBlocked(ip: string): boolean { + // Strip IPv6 brackets + const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip; + + // Loopback + if (/^127\./.test(addr) || addr === '::1') return true; + // Unspecified + if (/^0\./.test(addr)) return true; + // Link-local / cloud metadata + if (/^169\.254\./.test(addr) || /^fe80:/i.test(addr)) return true; + // IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x + if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true; + + return false; +} + +// Blocked unless ALLOW_INTERNAL_NETWORK=true +function isPrivateNetwork(ip: string): boolean { + const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip; + + // RFC-1918 private ranges + if (/^10\./.test(addr)) return true; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true; + if (/^192\.168\./.test(addr)) return true; + // CGNAT / Tailscale shared address space (100.64.0.0/10) + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true; + // IPv6 ULA (fc00::/7) + if (/^f[cd]/i.test(addr)) return true; + // IPv4-mapped RFC-1918 + if (/^::ffff:10\./i.test(addr)) return true; + if (/^::ffff:172\.(1[6-9]|2\d|3[01])\./i.test(addr)) return true; + if (/^::ffff:192\.168\./i.test(addr)) return true; + + return false; +} + +function isInternalHostname(hostname: string): boolean { + const h = hostname.toLowerCase(); + return h.endsWith('.local') || h.endsWith('.internal') || h === 'localhost'; +} + +export async function checkSsrf(rawUrl: string): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return { allowed: false, isPrivate: false, error: 'Invalid URL' }; + } + + if (!['http:', 'https:'].includes(url.protocol)) { + return { allowed: false, isPrivate: false, error: 'Only HTTP and HTTPS URLs are allowed' }; + } + + const hostname = url.hostname.toLowerCase(); + + // Block internal hostname suffixes (no override — these are too easy to abuse) + if (isInternalHostname(hostname) && hostname !== 'localhost') { + return { allowed: false, isPrivate: false, error: 'Requests to .local/.internal domains are not allowed' }; + } + + // Resolve hostname to IP + let resolvedIp: string; + try { + const result = await dns.lookup(hostname); + resolvedIp = result.address; + } catch { + return { allowed: false, isPrivate: false, error: 'Could not resolve hostname' }; + } + + if (isAlwaysBlocked(resolvedIp)) { + return { + allowed: false, + isPrivate: true, + resolvedIp, + error: 'Requests to loopback and link-local addresses are not allowed', + }; + } + + if (isPrivateNetwork(resolvedIp) || isInternalHostname(hostname)) { + if (!ALLOW_INTERNAL_NETWORK) { + return { + allowed: false, + isPrivate: true, + resolvedIp, + error: 'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.', + }; + } + return { allowed: true, isPrivate: true, resolvedIp }; + } + + return { allowed: true, isPrivate: false, resolvedIp }; +} + +/** + * Returns an http/https Agent whose `lookup` function is pinned to the + * already-validated IP. This prevents DNS rebinding (TOCTOU) by ensuring + * the outbound connection goes to the IP we checked, not a re-resolved one. + */ +export function createPinnedAgent(resolvedIp: string, protocol: string): http.Agent | https.Agent { + const options = { + lookup: (_hostname: string, _opts: unknown, callback: (err: Error | null, addr: string, family: number) => void) => { + // Determine address family from IP format + const family = resolvedIp.includes(':') ? 6 : 4; + callback(null, resolvedIp, family); + }, + }; + return protocol === 'https:' ? new https.Agent(options) : new http.Agent(options); +} From 358afd24287442a96e3cee4586dabc73ffe6d6b0 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 08:38:02 +0200 Subject: [PATCH 16/25] fix: require ENCRYPTION_KEY at startup instead of auto-generating Auto-generating and persisting the key to data/.encryption_key co-locates the key with the database, defeating encryption at rest if an attacker can read the data directory. It also silently loses all encrypted secrets if the data volume is recreated. Replace the auto-generation fallback with a hard startup error that tells operators exactly what to do: - Upgraders from the JWT_SECRET-derived encryption era: set ENCRYPTION_KEY to their old JWT_SECRET so existing ciphertext remains readable. - Fresh installs: generate a key with `openssl rand -hex 32`. --- server/src/config.ts | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/server/src/config.ts b/server/src/config.ts index 4fdeb2b..1663478 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -44,24 +44,14 @@ export function updateJwtSecret(newSecret: string): void { // old JWT_SECRET so existing encrypted values continue to decrypt correctly. // After re-saving all credentials via the admin panel you can switch to a new // random ENCRYPTION_KEY. -let ENCRYPTION_KEY: string = process.env.ENCRYPTION_KEY || ''; +const ENCRYPTION_KEY: string = process.env.ENCRYPTION_KEY || ''; if (!ENCRYPTION_KEY) { - const keyFile = path.join(dataDir, '.encryption_key'); - - try { - ENCRYPTION_KEY = fs.readFileSync(keyFile, 'utf8').trim(); - } catch { - ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex'); - try { - if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); - fs.writeFileSync(keyFile, ENCRYPTION_KEY, { mode: 0o600 }); - console.log('Generated and saved encryption key to', keyFile); - } catch (writeErr: unknown) { - console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr); - console.warn('Encrypted secrets will be unreadable after restart. Set ENCRYPTION_KEY env var for persistent encryption.'); - } - } + console.error('FATAL: ENCRYPTION_KEY is not set.'); + console.error('If this occurs after an update from a version that derived encryption from JWT_SECRET,'); + console.error('set ENCRYPTION_KEY to the value of your old JWT_SECRET to keep existing secrets readable.'); + console.error('For a fresh install, generate a random key: openssl rand -hex 32'); + process.exit(1); } export { ENCRYPTION_KEY }; From 19350fbc3ebddd0c1812ccbc9271922777ef90f6 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 08:43:10 +0200 Subject: [PATCH 17/25] fix: point upgraders to ./data/.jwt_secret in ENCRYPTION_KEY error and docs The startup error now tells operators exactly where to find the old key value (./data/.jwt_secret) rather than just saying "your old JWT_SECRET". docker-compose.yml and README updated to mark ENCRYPTION_KEY as required and remove the stale "auto-generated" comments. --- README.md | 4 ++-- docker-compose.yml | 2 +- server/src/config.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 003afa4..566a503 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ services: environment: - NODE_ENV=production - PORT=3000 - - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Auto-generated if not set. If upgrading, set to your old JWT_SECRET value to keep existing encrypted secrets readable. + - ENCRYPTION_KEY=${ENCRYPTION_KEY} # Required. Generate with: openssl rand -hex 32. Upgrading? Set this to the contents of ./data/.jwt_secret to keep existing encrypted secrets readable. - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details @@ -246,7 +246,7 @@ trek.yourdomain.com { | **Core** | | | | `PORT` | Server port | `3000` | | `NODE_ENV` | Environment (`production` / `development`) | `production` | -| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC); auto-generated and saved to `data/` if not set. **Upgrading:** set to your old `JWT_SECRET` value to keep existing encrypted data readable, then re-save credentials to migrate | Auto-generated | +| `ENCRYPTION_KEY` | **Required.** At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Generate with `openssl rand -hex 32`. **Upgrading:** set to the contents of `./data/.jwt_secret` to keep existing encrypted data readable. | — | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | diff --git a/docker-compose.yml b/docker-compose.yml index 42979a1..edcdca2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: environment: - NODE_ENV=production - PORT=3000 - - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Auto-generated if not set. If upgrading, set to your old JWT_SECRET value to keep existing encrypted secrets readable. + - ENCRYPTION_KEY=${ENCRYPTION_KEY} # Required. Generate with: openssl rand -hex 32. Upgrading? Set this to the contents of ./data/.jwt_secret to keep existing encrypted secrets readable. - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links diff --git a/server/src/config.ts b/server/src/config.ts index 1663478..a0fc620 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -48,8 +48,8 @@ const ENCRYPTION_KEY: string = process.env.ENCRYPTION_KEY || ''; if (!ENCRYPTION_KEY) { console.error('FATAL: ENCRYPTION_KEY is not set.'); - console.error('If this occurs after an update from a version that derived encryption from JWT_SECRET,'); - console.error('set ENCRYPTION_KEY to the value of your old JWT_SECRET to keep existing secrets readable.'); + console.error('If this occurs after an update, set ENCRYPTION_KEY to the value of your old JWT secret.'); + console.error('Your JWT secret is stored in data/.jwt_secret (host path: ./data/.jwt_secret).'); console.error('For a fresh install, generate a random key: openssl rand -hex 32'); process.exit(1); } From 4d596f2ff995a5a3f914ff2bac02fb75ddad936e Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 09:35:32 +0200 Subject: [PATCH 18/25] feat: add encryption key migration script and document it in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add server/scripts/migrate-encryption.ts — a standalone script that re-encrypts all at-rest secrets (OIDC client secret, SMTP password, Maps/OpenWeather/Immich API keys, MFA secrets) when rotating ENCRYPTION_KEY, without requiring the app to be running. - Prompts for old and new keys interactively; input is never echoed, handles copy-pasted keys correctly via a shared readline interface with a line queue to prevent race conditions on piped/pasted input - Creates a timestamped DB backup before any changes - Idempotent: detects already-migrated values by trying the new key - Exits non-zero and retains the backup if any field fails README updates: - Add .env setup step (openssl rand -hex 32) before the Docker Compose snippet so ENCRYPTION_KEY is set before first start - Add ENCRYPTION_KEY to the docker run one-liner - Add "Rotating the Encryption Key" section documenting the script, the docker exec command, and the upgrade path via ./data/.jwt_secret Co-Authored-By: Claude Sonnet 4.6 --- README.md | 25 ++- server/scripts/migrate-encryption.ts | 298 +++++++++++++++++++++++++++ 2 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 server/scripts/migrate-encryption.ts diff --git a/README.md b/README.md index 566a503..ebf7c6c 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ ## Quick Start ```bash -docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek +ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \ + -e ENCRYPTION_KEY=$ENCRYPTION_KEY \ + -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek ``` The app runs on port `3000`. The first user to register becomes the admin. @@ -115,6 +117,13 @@ TREK works as a Progressive Web App — no App Store needed:
Docker Compose (recommended for production) +First, create a `.env` file next to your `docker-compose.yml`: + +```bash +# Generate a random encryption key (required) +echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env +``` + ```yaml services: app: @@ -136,7 +145,7 @@ services: environment: - NODE_ENV=production - PORT=3000 - - ENCRYPTION_KEY=${ENCRYPTION_KEY} # Required. Generate with: openssl rand -hex 32. Upgrading? Set this to the contents of ./data/.jwt_secret to keep existing encrypted secrets readable. + - ENCRYPTION_KEY=${ENCRYPTION_KEY} # Required — see .env setup above - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details @@ -179,6 +188,18 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data. +### Rotating the Encryption Key + +If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app: + +```bash +docker exec -it trek node --import tsx scripts/migrate-encryption.ts +``` + +The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails. + +**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key". + ### Reverse Proxy (recommended) For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik). diff --git a/server/scripts/migrate-encryption.ts b/server/scripts/migrate-encryption.ts new file mode 100644 index 0000000..f5d788f --- /dev/null +++ b/server/scripts/migrate-encryption.ts @@ -0,0 +1,298 @@ +/** + * Encryption key migration script. + * + * Re-encrypts all at-rest secrets in the TREK database from one ENCRYPTION_KEY + * to another without requiring the application to be running. + * + * Usage (host): + * cd server + * node --import tsx scripts/migrate-encryption.ts + * + * Usage (Docker): + * docker exec -it trek node --import tsx scripts/migrate-encryption.ts + * + * The script will prompt for the old and new keys interactively so they never + * appear in shell history, process arguments, or log output. + */ + +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import readline from 'readline'; +import Database from 'better-sqlite3'; + +// --------------------------------------------------------------------------- +// Crypto helpers — mirrors apiKeyCrypto.ts and mfaCrypto.ts but with +// explicit key arguments so the script is independent of config.ts / env vars. +// --------------------------------------------------------------------------- + +const ENCRYPTED_PREFIX = 'enc:v1:'; + +function apiKey(encryptionKey: string): Buffer { + return crypto.createHash('sha256').update(`${encryptionKey}:api_keys:v1`).digest(); +} + +function mfaKey(encryptionKey: string): Buffer { + return crypto.createHash('sha256').update(`${encryptionKey}:mfa:v1`).digest(); +} + +function encryptApiKey(plain: string, encryptionKey: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', apiKey(encryptionKey), iv); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${ENCRYPTED_PREFIX}${Buffer.concat([iv, tag, enc]).toString('base64')}`; +} + +function decryptApiKey(value: string, encryptionKey: string): string | null { + if (!value.startsWith(ENCRYPTED_PREFIX)) return null; + try { + const buf = Buffer.from(value.slice(ENCRYPTED_PREFIX.length), 'base64'); + const decipher = crypto.createDecipheriv('aes-256-gcm', apiKey(encryptionKey), buf.subarray(0, 12)); + decipher.setAuthTag(buf.subarray(12, 28)); + return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString('utf8'); + } catch { + return null; + } +} + +function encryptMfa(plain: string, encryptionKey: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', mfaKey(encryptionKey), iv); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, enc]).toString('base64'); +} + +function decryptMfa(value: string, encryptionKey: string): string | null { + try { + const buf = Buffer.from(value, 'base64'); + if (buf.length < 28) return null; + const decipher = crypto.createDecipheriv('aes-256-gcm', mfaKey(encryptionKey), buf.subarray(0, 12)); + decipher.setAuthTag(buf.subarray(12, 28)); + return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString('utf8'); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Prompt helpers +// --------------------------------------------------------------------------- +// A single readline interface is shared for the entire script lifetime so +// stdin is never paused between prompts. +// +// Lines are collected into a queue as soon as readline emits them — this +// prevents the race where a line event fires before the next listener is +// registered (common with piped / pasted input that arrives all at once). + +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + +const lineQueue: string[] = []; +const lineWaiters: ((line: string) => void)[] = []; + +rl.on('line', (line) => { + if (lineWaiters.length > 0) { + lineWaiters.shift()!(line); + } else { + lineQueue.push(line); + } +}); + +function nextLine(): Promise { + return new Promise((resolve) => { + if (lineQueue.length > 0) { + resolve(lineQueue.shift()!); + } else { + lineWaiters.push(resolve); + } + }); +} + +// Muted prompt — typed/pasted characters are not echoed. +// _writeToOutput is suppressed only while waiting for this line. +async function promptSecret(question: string): Promise { + process.stdout.write(question); + (rl as any)._writeToOutput = () => {}; + const line = await nextLine(); + (rl as any)._writeToOutput = (s: string) => process.stdout.write(s); + process.stdout.write('\n'); + return line.trim(); +} + +async function prompt(question: string): Promise { + process.stdout.write(question); + const line = await nextLine(); + return line.trim(); +} + +// --------------------------------------------------------------------------- +// Migration +// --------------------------------------------------------------------------- + +interface MigrationResult { + migrated: number; + alreadyMigrated: number; + skipped: number; + errors: string[]; +} + +async function main() { + console.log('=== TREK Encryption Key Migration ===\n'); + console.log('This script re-encrypts all stored secrets under a new ENCRYPTION_KEY.'); + console.log('A backup of the database will be created before any changes are made.\n'); + + // Resolve DB path + const dbPath = path.resolve( + process.env.DB_PATH ?? path.join(__dirname, '../data/travel.db') + ); + + if (!fs.existsSync(dbPath)) { + console.error(`Database not found at: ${dbPath}`); + console.error('Set DB_PATH env var if your database is in a non-standard location.'); + process.exit(1); + } + + console.log(`Database: ${dbPath}\n`); + + // Collect keys interactively + const oldKey = await promptSecret('Old ENCRYPTION_KEY: '); + const newKey = await promptSecret('New ENCRYPTION_KEY: '); + + if (!oldKey || !newKey) { + rl.close(); + console.error('Both keys are required.'); + process.exit(1); + } + + if (oldKey === newKey) { + rl.close(); + console.error('Old and new keys are identical — nothing to do.'); + process.exit(0); + } + + // Confirm + const confirm = await prompt('\nProceed with migration? This will modify the database. Type "yes" to confirm: '); + if (confirm.trim().toLowerCase() !== 'yes') { + rl.close(); + console.log('Aborted.'); + process.exit(0); + } + + // Backup + const backupPath = `${dbPath}.backup-${Date.now()}`; + fs.copyFileSync(dbPath, backupPath); + console.log(`\nBackup created: ${backupPath}`); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + + const result: MigrationResult = { migrated: 0, alreadyMigrated: 0, skipped: 0, errors: [] }; + + // Helper: migrate a single api-key-style value (enc:v1: prefix) + function migrateApiKeyValue(raw: string, label: string): string | null { + if (!raw || !raw.startsWith(ENCRYPTED_PREFIX)) { + result.skipped++; + console.warn(` SKIP ${label}: not an encrypted value (missing enc:v1: prefix)`); + return null; + } + + const plain = decryptApiKey(raw, oldKey); + if (plain !== null) { + result.migrated++; + return encryptApiKey(plain, newKey); + } + + // Try new key — already migrated? + const check = decryptApiKey(raw, newKey); + if (check !== null) { + result.alreadyMigrated++; + return null; // no change needed + } + + result.errors.push(`${label}: decryption failed with both keys`); + console.error(` ERROR ${label}: could not decrypt with either key — skipping`); + return null; + } + + // Helper: migrate a single MFA value (no prefix, raw base64) + function migrateMfaValue(raw: string, label: string): string | null { + if (!raw) { result.skipped++; return null; } + + const plain = decryptMfa(raw, oldKey); + if (plain !== null) { + result.migrated++; + return encryptMfa(plain, newKey); + } + + const check = decryptMfa(raw, newKey); + if (check !== null) { + result.alreadyMigrated++; + return null; + } + + result.errors.push(`${label}: decryption failed with both keys`); + console.error(` ERROR ${label}: could not decrypt with either key — skipping`); + return null; + } + + db.transaction(() => { + // --- app_settings: oidc_client_secret, smtp_pass --- + for (const key of ['oidc_client_secret', 'smtp_pass']) { + const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined; + if (!row?.value) continue; + const newVal = migrateApiKeyValue(row.value, `app_settings.${key}`); + if (newVal !== null) { + db.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(newVal, key); + } + } + + // --- users: maps_api_key, openweather_api_key, immich_api_key --- + const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key']; + const users = db.prepare('SELECT id FROM users').all() as { id: number }[]; + + for (const user of users) { + const row = db.prepare(`SELECT ${apiKeyColumns.join(', ')} FROM users WHERE id = ?`).get(user.id) as Record; + + for (const col of apiKeyColumns) { + if (!row[col]) continue; + const newVal = migrateApiKeyValue(row[col], `users[${user.id}].${col}`); + if (newVal !== null) { + db.prepare(`UPDATE users SET ${col} = ? WHERE id = ?`).run(newVal, user.id); + } + } + + // mfa_secret (mfa crypto) + const mfaRow = db.prepare('SELECT mfa_secret FROM users WHERE id = ? AND mfa_secret IS NOT NULL').get(user.id) as { mfa_secret: string } | undefined; + if (mfaRow?.mfa_secret) { + const newVal = migrateMfaValue(mfaRow.mfa_secret, `users[${user.id}].mfa_secret`); + if (newVal !== null) { + db.prepare('UPDATE users SET mfa_secret = ? WHERE id = ?').run(newVal, user.id); + } + } + } + })(); + + db.close(); + rl.close(); + + console.log('\n=== Migration complete ==='); + console.log(` Migrated: ${result.migrated}`); + console.log(` Already on new key: ${result.alreadyMigrated}`); + console.log(` Skipped (empty): ${result.skipped}`); + if (result.errors.length > 0) { + console.warn(` Errors: ${result.errors.length}`); + result.errors.forEach(e => console.warn(` - ${e}`)); + console.warn('\nSome secrets could not be migrated. Check the errors above.'); + console.warn(`Your original database is backed up at: ${backupPath}`); + process.exit(1); + } else { + console.log('\nAll secrets successfully re-encrypted.'); + console.log(`Backup retained at: ${backupPath}`); + } +} + +main().catch((err) => { + console.error('Unexpected error:', err); + process.exit(1); +}); From 871bfd7dfd31a4c521c4f6687b151f9759a856ff Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 09:47:31 +0200 Subject: [PATCH 19/25] fix: make ENCRYPTION_KEY optional with backwards-compatible fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit process.exit(1) when ENCRYPTION_KEY is unset was a breaking change for existing installs — a plain git pull would prevent the server from starting. Replace with a three-step fallback: 1. ENCRYPTION_KEY env var (explicit, takes priority) 2. data/.jwt_secret (existing installs: encrypted data stays readable after upgrade with zero manual intervention) 3. data/.encryption_key auto-generated on first start (fresh installs) A warning is logged when falling back to the JWT secret so operators are nudged toward setting ENCRYPTION_KEY explicitly. Update README env table and Docker Compose comment to reflect that ENCRYPTION_KEY is recommended but no longer required. --- README.md | 11 ++-------- server/src/config.ts | 50 ++++++++++++++++++++++++++++++++------------ 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ebf7c6c..5644377 100644 --- a/README.md +++ b/README.md @@ -117,13 +117,6 @@ TREK works as a Progressive Web App — no App Store needed:
Docker Compose (recommended for production) -First, create a `.env` file next to your `docker-compose.yml`: - -```bash -# Generate a random encryption key (required) -echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env -``` - ```yaml services: app: @@ -145,7 +138,7 @@ services: environment: - NODE_ENV=production - PORT=3000 - - ENCRYPTION_KEY=${ENCRYPTION_KEY} # Required — see .env setup above + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs). - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details @@ -267,7 +260,7 @@ trek.yourdomain.com { | **Core** | | | | `PORT` | Server port | `3000` | | `NODE_ENV` | Environment (`production` / `development`) | `production` | -| `ENCRYPTION_KEY` | **Required.** At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Generate with `openssl rand -hex 32`. **Upgrading:** set to the contents of `./data/.jwt_secret` to keep existing encrypted data readable. | — | +| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | diff --git a/server/src/config.ts b/server/src/config.ts index a0fc620..d67d496 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -39,19 +39,43 @@ export function updateJwtSecret(newSecret: string): void { // Keeping it separate from JWT_SECRET means you can rotate session tokens without // invalidating all stored encrypted data, and vice-versa. // -// Upgrade note: if you already have encrypted data stored under a previous build -// that used JWT_SECRET for encryption, set ENCRYPTION_KEY to the value of your -// old JWT_SECRET so existing encrypted values continue to decrypt correctly. -// After re-saving all credentials via the admin panel you can switch to a new -// random ENCRYPTION_KEY. -const ENCRYPTION_KEY: string = process.env.ENCRYPTION_KEY || ''; +// Resolution order: +// 1. ENCRYPTION_KEY env var — explicit, always takes priority. +// 2. data/.jwt_secret — used automatically for existing installs that upgrade +// without setting ENCRYPTION_KEY; encrypted data stays readable with no +// manual intervention required. +// 3. data/.encryption_key — auto-generated and persisted on first start of a +// fresh install where neither of the above is available. +let _encryptionKey: string = process.env.ENCRYPTION_KEY || ''; -if (!ENCRYPTION_KEY) { - console.error('FATAL: ENCRYPTION_KEY is not set.'); - console.error('If this occurs after an update, set ENCRYPTION_KEY to the value of your old JWT secret.'); - console.error('Your JWT secret is stored in data/.jwt_secret (host path: ./data/.jwt_secret).'); - console.error('For a fresh install, generate a random key: openssl rand -hex 32'); - process.exit(1); +if (!_encryptionKey) { + // Fallback 1: existing install — reuse the JWT secret so previously encrypted + // values remain readable after an upgrade. + try { + _encryptionKey = fs.readFileSync(jwtSecretFile, 'utf8').trim(); + console.warn('WARNING: ENCRYPTION_KEY is not set. Falling back to JWT secret for at-rest encryption.'); + console.warn('Set ENCRYPTION_KEY explicitly to decouple encryption from JWT signing (recommended).'); + } catch { + // JWT secret not found — must be a fresh install, fall through. + } } -export { ENCRYPTION_KEY }; +if (!_encryptionKey) { + // Fallback 2: fresh install — auto-generate a dedicated key. + const encKeyFile = path.join(dataDir, '.encryption_key'); + try { + _encryptionKey = fs.readFileSync(encKeyFile, 'utf8').trim(); + } catch { + _encryptionKey = crypto.randomBytes(32).toString('hex'); + try { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 }); + console.log('Generated and saved encryption key to', encKeyFile); + } catch (writeErr: unknown) { + console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr); + console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.'); + } + } +} + +export const ENCRYPTION_KEY = _encryptionKey; From 862f59b77a774824bebb242bd393d5d43cb37329 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 09:48:14 +0200 Subject: [PATCH 20/25] chore: update docker-compose ENCRYPTION_KEY comment to match new behaviour --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index edcdca2..6c2ccba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: environment: - NODE_ENV=production - PORT=3000 - - ENCRYPTION_KEY=${ENCRYPTION_KEY} # Required. Generate with: openssl rand -hex 32. Upgrading? Set this to the contents of ./data/.jwt_secret to keep existing encrypted secrets readable. + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs). - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links From c9e61859cee48531de499bf7fbcfaafa74fcfedf Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 09:49:57 +0200 Subject: [PATCH 21/25] chore(helm): update ENCRYPTION_KEY docs to reflect automatic fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing installs no longer need to manually set ENCRYPTION_KEY to their old JWT secret on upgrade — the server falls back to data/.jwt_secret automatically. Update values.yaml, NOTES.txt, and chart README accordingly. --- chart/README.md | 2 +- chart/templates/NOTES.txt | 7 +++---- chart/values.yaml | 8 ++++---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/chart/README.md b/chart/README.md index 955e926..54e8b90 100644 --- a/chart/README.md +++ b/chart/README.md @@ -29,6 +29,6 @@ See `values.yaml` for more options. - Ingress is off by default. Enable and configure hosts for your domain. - PVCs require a default StorageClass or specify one as needed. - `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed. -- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Auto-generated and persisted to the data PVC if not provided. **Upgrading:** if a previous version used `JWT_SECRET`-derived encryption, set `secretEnv.ENCRYPTION_KEY` to your old `JWT_SECRET` value to keep existing encrypted data readable, then re-save credentials via the admin panel. +- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC. - If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically. - Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless. diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt index 3fae1fc..0e258f4 100644 --- a/chart/templates/NOTES.txt +++ b/chart/templates/NOTES.txt @@ -4,10 +4,9 @@ - To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`. - To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`). - - If left empty, the server auto-generates and persists the key to the data PVC — safe as long as - the PVC persists. - - Upgrading from a version that used JWT_SECRET for encryption: set `secretEnv.ENCRYPTION_KEY` to - your old JWT_SECRET value, then re-save credentials via the admin panel. + - If left empty, the server resolves the key automatically: existing installs fall back to + data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs + auto-generate a key persisted to the data PVC. 2. JWT_SECRET is managed entirely by the server: - Auto-generated on first start and persisted to the data PVC (data/.jwt_secret). diff --git a/chart/values.yaml b/chart/values.yaml index 8c5968f..07164e3 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -27,10 +27,10 @@ env: # rotatable via the admin panel) — it is not configured here. secretEnv: # At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC, etc.). - # Auto-generated and persisted to the data PVC if not set. - # Upgrading from a version that used JWT_SECRET for encryption: set this to your - # old JWT_SECRET value to keep existing encrypted data readable, then re-save - # credentials via the admin panel and rotate to a fresh random key. + # Recommended: set to a random 32-byte hex value (openssl rand -hex 32). + # If left empty the server resolves the key automatically: + # 1. data/.jwt_secret (existing installs — encrypted data stays readable after upgrade) + # 2. data/.encryption_key auto-generated on first start (fresh installs) ENCRYPTION_KEY: "" # If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades From 44e5f07f592118dd53bee16d512c427f19e8bab4 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 10:03:11 +0200 Subject: [PATCH 22/25] fix: persist encryption key to disk regardless of resolution source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, when the JWT secret was used as a fallback encryption key, nothing was written to data/.encryption_key. This meant that rotating the JWT secret via the admin panel would silently break decryption of all stored secrets on the next restart. Now, whatever key is resolved — env var, JWT secret fallback, or auto-generated — is immediately persisted to data/.encryption_key. On all subsequent starts, the file is read directly and the fallback chain is skipped entirely, making JWT rotation permanently safe. The env var path also writes to the file so the key survives container restarts if the env var is later removed. --- server/src/config.ts | 67 ++++++++++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/server/src/config.ts b/server/src/config.ts index d67d496..9a77477 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -41,41 +41,60 @@ export function updateJwtSecret(newSecret: string): void { // // Resolution order: // 1. ENCRYPTION_KEY env var — explicit, always takes priority. -// 2. data/.jwt_secret — used automatically for existing installs that upgrade -// without setting ENCRYPTION_KEY; encrypted data stays readable with no -// manual intervention required. -// 3. data/.encryption_key — auto-generated and persisted on first start of a -// fresh install where neither of the above is available. +// 2. data/.encryption_key file — present on any install that has started at +// least once (written automatically by cases 1b and 3 below). +// 3. data/.jwt_secret — one-time fallback for existing installs upgrading +// without a pre-set ENCRYPTION_KEY. The value is immediately persisted to +// data/.encryption_key so JWT rotation can never break decryption later. +// 4. Auto-generated — fresh install with none of the above; persisted to +// data/.encryption_key. +const encKeyFile = path.join(dataDir, '.encryption_key'); let _encryptionKey: string = process.env.ENCRYPTION_KEY || ''; -if (!_encryptionKey) { - // Fallback 1: existing install — reuse the JWT secret so previously encrypted - // values remain readable after an upgrade. +if (_encryptionKey) { + // Env var is set explicitly — persist it to file so the value survives + // container restarts even if the env var is later removed. try { - _encryptionKey = fs.readFileSync(jwtSecretFile, 'utf8').trim(); - console.warn('WARNING: ENCRYPTION_KEY is not set. Falling back to JWT secret for at-rest encryption.'); - console.warn('Set ENCRYPTION_KEY explicitly to decouple encryption from JWT signing (recommended).'); + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 }); } catch { - // JWT secret not found — must be a fresh install, fall through. + // Non-fatal: env var is the source of truth when set. } -} - -if (!_encryptionKey) { - // Fallback 2: fresh install — auto-generate a dedicated key. - const encKeyFile = path.join(dataDir, '.encryption_key'); +} else { + // Try the dedicated key file first (covers all installs after first start). try { _encryptionKey = fs.readFileSync(encKeyFile, 'utf8').trim(); } catch { - _encryptionKey = crypto.randomBytes(32).toString('hex'); + // File not found — first start on an existing or fresh install. + } + + if (!_encryptionKey) { + // One-time migration: existing install upgrading for the first time. + // Use the JWT secret as the encryption key and immediately write it to + // .encryption_key so future JWT rotations cannot break decryption. try { - if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); - fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 }); - console.log('Generated and saved encryption key to', encKeyFile); - } catch (writeErr: unknown) { - console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr); - console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.'); + _encryptionKey = fs.readFileSync(jwtSecretFile, 'utf8').trim(); + console.warn('WARNING: ENCRYPTION_KEY is not set. Falling back to JWT secret for at-rest encryption.'); + console.warn('The value has been persisted to data/.encryption_key — JWT rotation is now safe.'); + } catch { + // JWT secret not found — must be a fresh install. } } + + if (!_encryptionKey) { + // Fresh install — auto-generate a dedicated key. + _encryptionKey = crypto.randomBytes(32).toString('hex'); + } + + // Persist whatever key was resolved so subsequent starts skip the fallback chain. + try { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 }); + console.log('Encryption key persisted to', encKeyFile); + } catch (writeErr: unknown) { + console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr); + console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.'); + } } export const ENCRYPTION_KEY = _encryptionKey; From b88a8fcbb55c1e1bb93ecd48bfe174b7fbe5e841 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 10:14:35 +0200 Subject: [PATCH 23/25] fix: unify password validation error to show all requirements at once Co-Authored-By: Claude Sonnet 4.6 --- server/src/services/passwordPolicy.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/server/src/services/passwordPolicy.ts b/server/src/services/passwordPolicy.ts index ad817d1..9b20143 100644 --- a/server/src/services/passwordPolicy.ts +++ b/server/src/services/passwordPolicy.ts @@ -18,10 +18,11 @@ export function validatePassword(password: string): { ok: boolean; reason?: stri return { ok: false, reason: 'Password is too common. Please choose a unique password.' }; } - if (!/[A-Z]/.test(password)) return { ok: false, reason: 'Password must contain at least one uppercase letter' }; - if (!/[a-z]/.test(password)) return { ok: false, reason: 'Password must contain at least one lowercase letter' }; - if (!/[0-9]/.test(password)) return { ok: false, reason: 'Password must contain at least one number' }; - if (!/[^A-Za-z0-9]/.test(password)) return { ok: false, reason: 'Password must contain at least one special character' }; + const requirementsMessage = 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character'; + if (!/[A-Z]/.test(password)) return { ok: false, reason: requirementsMessage }; + if (!/[a-z]/.test(password)) return { ok: false, reason: requirementsMessage }; + if (!/[0-9]/.test(password)) return { ok: false, reason: requirementsMessage }; + if (!/[^A-Za-z0-9]/.test(password)) return { ok: false, reason: requirementsMessage }; return { ok: true }; } From add0b17e041efbad95c5f35dc480613dd239b8bc Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 11:02:45 +0200 Subject: [PATCH 24/25] feat(auth): migrate JWT storage from localStorage to httpOnly cookies Eliminates XSS token theft risk by storing session JWTs in an httpOnly cookie (trek_session) instead of localStorage, making them inaccessible to JavaScript entirely. - Add cookie-parser middleware and setAuthCookie/clearAuthCookie helpers - Set trek_session cookie on login, register, demo-login, MFA verify, OIDC exchange - Auth middleware reads cookie first, falls back to Authorization: Bearer (MCP unchanged) - Add POST /api/auth/logout to clear the cookie server-side - Remove all localStorage auth_token reads/writes from client - Axios uses withCredentials; raw fetch calls use credentials: include - WebSocket ws-token exchange uses credentials: include (no JWT param) - authStore initialises isLoading: true so ProtectedRoute waits for /api/auth/me Co-Authored-By: Claude Sonnet 4.6 --- client/src/App.tsx | 6 ++-- client/src/api/authUrl.ts | 9 ++--- client/src/api/client.ts | 11 ++---- client/src/api/websocket.ts | 31 ++++++++-------- .../src/components/Planner/DayPlanSidebar.tsx | 2 +- client/src/pages/LoginPage.tsx | 6 ++-- client/src/store/authStore.ts | 35 +++++-------------- server/package-lock.json | 31 ++++++++++++++++ server/package.json | 2 ++ server/src/index.ts | 2 ++ server/src/middleware/auth.ts | 14 +++++--- server/src/routes/auth.ts | 10 ++++++ server/src/routes/oidc.ts | 2 ++ server/src/services/cookie.ts | 22 ++++++++++++ 14 files changed, 115 insertions(+), 68 deletions(-) create mode 100644 server/src/services/cookie.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index fa31363..0235276 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -75,13 +75,11 @@ function RootRedirect() { } export default function App() { - const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() useEffect(() => { - if (token) { - loadUser() - } + loadUser() authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record }) => { if (config?.demo_mode) setDemoMode(true) if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) diff --git a/client/src/api/authUrl.ts b/client/src/api/authUrl.ts index 433f09d..203ceb3 100644 --- a/client/src/api/authUrl.ts +++ b/client/src/api/authUrl.ts @@ -1,13 +1,10 @@ export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise { - const jwt = localStorage.getItem('auth_token') - if (!jwt || !url) return url + if (!url) return url try { const resp = await fetch('/api/auth/resource-token', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${jwt}`, - }, + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ purpose }), }) if (!resp.ok) return url diff --git a/client/src/api/client.ts b/client/src/api/client.ts index f440469..7992005 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -3,18 +3,15 @@ import { getSocketId } from './websocket' const apiClient: AxiosInstance = axios.create({ baseURL: '/api', + withCredentials: true, headers: { 'Content-Type': 'application/json', }, }) -// Request interceptor - add auth token and socket ID +// Request interceptor - add socket ID apiClient.interceptors.request.use( (config) => { - const token = localStorage.getItem('auth_token') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } const sid = getSocketId() if (sid) { config.headers['X-Socket-Id'] = sid @@ -29,7 +26,6 @@ apiClient.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { - localStorage.removeItem('auth_token') if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) { window.location.href = '/login' } @@ -285,9 +281,8 @@ export const backupApi = { list: () => apiClient.get('/backup/list').then(r => r.data), create: () => apiClient.post('/backup/create').then(r => r.data), download: async (filename: string): Promise => { - const token = localStorage.getItem('auth_token') const res = await fetch(`/api/backup/download/${filename}`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) throw new Error('Download failed') const blob = await res.blob() diff --git a/client/src/api/websocket.ts b/client/src/api/websocket.ts index 757f953..2b4a520 100644 --- a/client/src/api/websocket.ts +++ b/client/src/api/websocket.ts @@ -9,7 +9,7 @@ let reconnectDelay = 1000 const MAX_RECONNECT_DELAY = 30000 const listeners = new Set() const activeTrips = new Set() -let currentToken: string | null = null +let shouldReconnect = false let refetchCallback: RefetchCallback | null = null let mySocketId: string | null = null let connecting = false @@ -27,15 +27,15 @@ function getWsUrl(wsToken: string): string { return `${protocol}://${location.host}/ws?token=${wsToken}` } -async function fetchWsToken(jwt: string): Promise { +async function fetchWsToken(): Promise { try { const resp = await fetch('/api/auth/ws-token', { method: 'POST', - headers: { 'Authorization': `Bearer ${jwt}` }, + credentials: 'include', }) if (resp.status === 401) { - // JWT expired — stop reconnecting - currentToken = null + // Session expired — stop reconnecting + shouldReconnect = false return null } if (!resp.ok) return null @@ -65,26 +65,25 @@ function scheduleReconnect(): void { if (reconnectTimer) return reconnectTimer = setTimeout(() => { reconnectTimer = null - if (currentToken) { - connectInternal(currentToken, true) + if (shouldReconnect) { + connectInternal(true) } }, reconnectDelay) reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY) } -async function connectInternal(token: string, _isReconnect = false): Promise { +async function connectInternal(_isReconnect = false): Promise { if (connecting) return if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { return } connecting = true - const wsToken = await fetchWsToken(token) + const wsToken = await fetchWsToken() connecting = false if (!wsToken) { - // currentToken may have been cleared on 401; only schedule reconnect if still active - if (currentToken) scheduleReconnect() + if (shouldReconnect) scheduleReconnect() return } @@ -113,7 +112,7 @@ async function connectInternal(token: string, _isReconnect = false): Promise { socket = null - if (currentToken) { + if (shouldReconnect) { scheduleReconnect() } } @@ -123,18 +122,18 @@ async function connectInternal(token: string, _isReconnect = false): Promise { try { const res = await fetch(`/api/trips/${tripId}/export.ics`, { - headers: { 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` }, + credentials: 'include', }) if (!res.ok) throw new Error() const blob = await res.blob() diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 1f8283b..1474838 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -55,11 +55,11 @@ export default function LoginPage(): React.ReactElement { if (oidcCode) { setIsLoading(true) window.history.replaceState({}, '', '/login') - fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode)) + fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' }) .then(r => r.json()) - .then(data => { + .then(async data => { if (data.token) { - localStorage.setItem('auth_token', data.token) + await loadUser() navigate('/dashboard', { replace: true }) } else { setError(data.error || 'OIDC login failed') diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 9fbad53..a98bd65 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -17,7 +17,6 @@ interface AvatarResponse { interface AuthState { user: User | null - token: string | null isAuthenticated: boolean isLoading: boolean error: string | null @@ -49,9 +48,8 @@ interface AuthState { export const useAuthStore = create((set, get) => ({ user: null, - token: localStorage.getItem('auth_token') || null, - isAuthenticated: !!localStorage.getItem('auth_token'), - isLoading: false, + isAuthenticated: false, + isLoading: true, error: null, demoMode: localStorage.getItem('demo_mode') === 'true', hasMapsKey: false, @@ -67,15 +65,13 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: false, error: null }) return { mfa_required: true as const, mfa_token: data.mfa_token } } - localStorage.setItem('auth_token', data.token) set({ user: data.user, - token: data.token, isAuthenticated: true, isLoading: false, error: null, }) - connect(data.token) + connect() return data as AuthResponse } catch (err: unknown) { const error = getApiErrorMessage(err, 'Login failed') @@ -88,15 +84,13 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: true, error: null }) try { const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') }) - localStorage.setItem('auth_token', data.token) set({ user: data.user, - token: data.token, isAuthenticated: true, isLoading: false, error: null, }) - connect(data.token) + connect() return data as AuthResponse } catch (err: unknown) { const error = getApiErrorMessage(err, 'Verification failed') @@ -109,15 +103,13 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: true, error: null }) try { const data = await authApi.register({ username, email, password, invite_token }) - localStorage.setItem('auth_token', data.token) set({ user: data.user, - token: data.token, isAuthenticated: true, isLoading: false, error: null, }) - connect(data.token) + connect() return data } catch (err: unknown) { const error = getApiErrorMessage(err, 'Registration failed') @@ -128,7 +120,8 @@ export const useAuthStore = create((set, get) => ({ logout: () => { disconnect() - localStorage.removeItem('auth_token') + // Tell server to clear the httpOnly cookie + fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) // Clear service worker caches containing sensitive data if ('caches' in window) { caches.delete('api-data').catch(() => {}) @@ -136,7 +129,6 @@ export const useAuthStore = create((set, get) => ({ } set({ user: null, - token: null, isAuthenticated: false, error: null, }) @@ -144,11 +136,6 @@ export const useAuthStore = create((set, get) => ({ loadUser: async (opts?: { silent?: boolean }) => { const silent = !!opts?.silent - const token = get().token - if (!token) { - if (!silent) set({ isLoading: false }) - return - } if (!silent) set({ isLoading: true }) try { const data = await authApi.me() @@ -157,16 +144,14 @@ export const useAuthStore = create((set, get) => ({ isAuthenticated: true, isLoading: false, }) - connect(token) + connect() } catch (err: unknown) { // Only clear auth state on 401 (invalid/expired token), not on network errors const isAuthError = err && typeof err === 'object' && 'response' in err && (err as { response?: { status?: number } }).response?.status === 401 if (isAuthError) { - localStorage.removeItem('auth_token') set({ user: null, - token: null, isAuthenticated: false, isLoading: false, }) @@ -233,16 +218,14 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: true, error: null }) try { const data = await authApi.demoLogin() - localStorage.setItem('auth_token', data.token) set({ user: data.user, - token: data.token, isAuthenticated: true, isLoading: false, demoMode: true, error: null, }) - connect(data.token) + connect() return data } catch (err: unknown) { const error = getApiErrorMessage(err, 'Demo login failed') diff --git a/server/package-lock.json b/server/package-lock.json index f4af0a6..fb46ba6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -12,6 +12,7 @@ "archiver": "^6.0.1", "bcryptjs": "^2.4.3", "better-sqlite3": "^12.8.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.1", "express": "^4.18.3", @@ -34,6 +35,7 @@ "@types/archiver": "^7.0.0", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", @@ -914,6 +916,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -1713,6 +1725,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", diff --git a/server/package.json b/server/package.json index 27b8210..20030bc 100644 --- a/server/package.json +++ b/server/package.json @@ -11,6 +11,7 @@ "archiver": "^6.0.1", "bcryptjs": "^2.4.3", "better-sqlite3": "^12.8.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.1", "express": "^4.18.3", @@ -33,6 +34,7 @@ "@types/archiver": "^7.0.0", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", diff --git a/server/src/index.ts b/server/src/index.ts index c79bcc8..6c81750 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,6 +3,7 @@ import express, { Request, Response, NextFunction } from 'express'; import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy'; import cors from 'cors'; import helmet from 'helmet'; +import cookieParser from 'cookie-parser'; import path from 'path'; import fs from 'fs'; @@ -86,6 +87,7 @@ if (shouldForceHttps) { } app.use(express.json({ limit: '100kb' })); app.use(express.urlencoded({ extended: true })); +app.use(cookieParser()); app.use(enforceGlobalMfaPolicy); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 9b0c040..b2a7807 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -4,9 +4,16 @@ import { db } from '../db/database'; import { JWT_SECRET } from '../config'; import { AuthRequest, OptionalAuthRequest, User } from '../types'; -const authenticate = (req: Request, res: Response, next: NextFunction): void => { +function extractToken(req: Request): string | null { + // Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients) + const cookieToken = (req as any).cookies?.trek_session; + if (cookieToken) return cookieToken; const authHeader = req.headers['authorization']; - const token = authHeader && authHeader.split(' ')[1]; + return (authHeader && authHeader.split(' ')[1]) || null; +} + +const authenticate = (req: Request, res: Response, next: NextFunction): void => { + const token = extractToken(req); if (!token) { res.status(401).json({ error: 'Access token required' }); @@ -30,8 +37,7 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void => }; const optionalAuth = (req: Request, res: Response, next: NextFunction): void => { - const authHeader = req.headers['authorization']; - const token = authHeader && authHeader.split(' ')[1]; + const token = extractToken(req); if (!token) { (req as OptionalAuthRequest).user = null; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index f86357e..eb96998 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -22,6 +22,7 @@ import { writeAudit, getClientIp } from '../services/auditLog'; import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../services/apiKeyCrypto'; import { startTripReminders } from '../scheduler'; import { createEphemeralToken } from '../services/ephemeralTokens'; +import { setAuthCookie, clearAuthCookie } from '../services/cookie'; authenticator.options = { window: 1 }; @@ -229,6 +230,7 @@ router.post('/demo-login', (_req: Request, res: Response) => { if (!user) return res.status(500).json({ error: 'Demo user not found' }); const token = generateToken(user); const safe = stripUserForClient(user) as Record; + setAuthCookie(res, token); res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } }); }); @@ -307,6 +309,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { } writeAudit({ userId: Number(result.lastInsertRowid), action: 'user.register', ip: getClientIp(req), details: { username, email, role } }); + setAuthCookie(res, token); res.status(201).json({ token, user: { ...user, avatar_url: null } }); } catch (err: unknown) { res.status(500).json({ error: 'Error creating user' }); @@ -350,6 +353,7 @@ router.post('/login', authLimiter, (req: Request, res: Response) => { const userSafe = stripUserForClient(user) as Record; writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { email } }); + setAuthCookie(res, token); res.json({ token, user: { ...userSafe, avatar_url: avatarUrl(user) } }); }); @@ -367,6 +371,11 @@ router.get('/me', authenticate, (req: Request, res: Response) => { res.json({ user: { ...base, avatar_url: avatarUrl(user) } }); }); +router.post('/logout', (req: Request, res: Response) => { + clearAuthCookie(res); + res.json({ success: true }); +}); + router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { const authReq = req as AuthRequest; if (isOidcOnlyMode()) { @@ -810,6 +819,7 @@ router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => { const sessionToken = generateToken(user); const userSafe = stripUserForClient(user) as Record; writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { mfa: true } }); + setAuthCookie(res, sessionToken); res.json({ token: sessionToken, user: { ...userSafe, avatar_url: avatarUrl(user) } }); } catch { return res.status(401).json({ error: 'Invalid or expired verification token' }); diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts index a62d306..8ba8c23 100644 --- a/server/src/routes/oidc.ts +++ b/server/src/routes/oidc.ts @@ -6,6 +6,7 @@ import { db } from '../db/database'; import { JWT_SECRET } from '../config'; import { User } from '../types'; import { decrypt_api_key } from '../services/apiKeyCrypto'; +import { setAuthCookie } from '../services/cookie'; interface OidcDiscoveryDoc { authorization_endpoint: string; @@ -289,6 +290,7 @@ router.get('/exchange', (req: Request, res: Response) => { if (!entry) return res.status(400).json({ error: 'Invalid or expired code' }); authCodes.delete(code); if (Date.now() - entry.created > AUTH_CODE_TTL) return res.status(400).json({ error: 'Code expired' }); + setAuthCookie(res, entry.token); res.json({ token: entry.token }); }); diff --git a/server/src/services/cookie.ts b/server/src/services/cookie.ts new file mode 100644 index 0000000..448e25c --- /dev/null +++ b/server/src/services/cookie.ts @@ -0,0 +1,22 @@ +import { Response } from 'express'; + +const COOKIE_NAME = 'trek_session'; + +function cookieOptions(clear = false) { + const secure = process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true'; + return { + httpOnly: true, + secure, + sameSite: 'strict' as const, + path: '/', + ...(clear ? {} : { maxAge: 24 * 60 * 60 * 1000 }), // 24h — matches JWT expiry + }; +} + +export function setAuthCookie(res: Response, token: string): void { + res.cookie(COOKIE_NAME, token, cookieOptions()); +} + +export function clearAuthCookie(res: Response): void { + res.clearCookie(COOKIE_NAME, cookieOptions(true)); +} From 0ebcff9504c71ab966f9dec44e466522216426b9 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 17:40:45 +0200 Subject: [PATCH 25/25] Conflict resolution --- server/src/routes/immich.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 8a3ef48..c39a7ec 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -92,7 +92,8 @@ router.get('/status', authenticate, async (req: Request, res: Response) => { router.post('/test', authenticate, async (req: Request, res: Response) => { const { immich_url, immich_api_key } = req.body; if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' }); - if (!isValidImmichUrl(immich_url)) return res.json({ connected: false, error: 'Invalid Immich URL' }); + const ssrf = await checkSsrf(immich_url); + if (!ssrf.allowed) return res.json({ connected: false, error: ssrf.error ?? 'Invalid Immich URL' }); try { const resp = await fetch(`${immich_url}/api/users/me`, { headers: { 'x-api-key': immich_api_key, 'Accept': 'application/json' },