From 1b28bd96d40d775a267972cdb6d2921a03565f03 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 04:33:17 +0200 Subject: [PATCH] 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 };