From 250646110fa570926ac54e65081cda307eca7a2d Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 22 Apr 2026 01:00:37 +0200 Subject: [PATCH] fix(security): timing-safe forgot-password + redact log file writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent PHI-leak hardenings folded together: 1. forgot-password timing oracle The hit path previously did SELECT + token gen + UPDATE + SMTP send before responding; the miss path returned after the SELECT. An attacker could distinguish registered emails by response latency (SMTP RTT is hundreds of ms). Response is now sent immediately after Turnstile, with the DB and email work fired-and-forgotten in a background async block. Hit and miss take identical wall-clock time. Also hardened req.body.email to tolerate missing/non-string input instead of throwing 500. 2. logger.file redaction logger.info/warn/error wrote straight to /app/data/logs/YYYY-MM-DD.log without going through redact(). Current callers are metadata-only and safe, but any future caller writing logger.error('boom', req.body) would silently drop PHI to disk. Route both message and optional data through redact() — same helper the audit path already uses. Benign startup messages pass through unchanged; SSN/phone/email/DOB patterns are tokenised, long note-body-shaped text is truncated. --- src/routes/auth.js | 48 ++++++++++++++++++++++++++++++++------------- src/utils/logger.js | 13 ++++++++++-- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/routes/auth.js b/src/routes/auth.js index 4f38107..e8a185b 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -539,9 +539,16 @@ router.post('/disable-2fa', authMiddleware, async (req, res) => { }); // Password reset +// +// Timing-safe: response is sent after Turnstile verification, BEFORE the DB +// lookup and email send. Hit and miss therefore take the same wall-clock +// time (no SMTP RTT on hit, no extra latency on miss), closing the +// user-enumeration oracle that previously let an attacker distinguish +// registered emails by response time. router.post('/forgot-password', async (req, res) => { try { - // Cloudflare Turnstile verification + // Cloudflare Turnstile verification (runs for every request regardless + // of whether the account exists, so timing is equal) if (process.env.TURNSTILE_SECRET_KEY) { var turnstileToken = req.body.turnstileToken; if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification' }); @@ -553,20 +560,33 @@ router.post('/forgot-password', async (req, res) => { if (!tsData.success) return res.status(400).json({ error: 'Verification failed. Please try again.' }); } - var user = await db.get('SELECT id, name FROM users WHERE email = ?', [req.body.email.toLowerCase()]); - if (!user) return res.json({ success: true, message: 'If account exists, reset email sent' }); - var token = crypto.randomBytes(32).toString('hex'); - await db.run('UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?', [token, Date.now() + 3600000, user.id]); - var resetUrl = safeAppUrl() + '/reset-password?token=' + token; - var resetSubject = await db.getSetting('email.reset.subject') || 'Reset your password — Pediatric AI Scribe'; - var resetBody = await db.getSetting('email.reset.body') || 'Someone requested a password reset for your Pediatric AI Scribe account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request a password reset, no action is needed.'; - await sendEmail(req.body.email, resetSubject, emailWrapper( - `

Password reset request

-

${escHtml(resetBody).replace(/\n/g, '
')}

- ${btnHtml(resetUrl, 'Reset My Password')} - ${linkFallback(resetUrl)}` - )); + var email = String(req.body.email || '').toLowerCase(); + + // Return the identical response immediately — do not await anything + // account-specific below this point. res.json({ success: true, message: 'If account exists, reset email sent' }); + + // Fire-and-forget: DB lookup + token + email happen after the response + // is sent, so their latency cannot be measured by the caller. + (async function () { + try { + var user = await db.get('SELECT id FROM users WHERE email = ?', [email]); + if (!user) return; + var token = crypto.randomBytes(32).toString('hex'); + await db.run('UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?', [token, Date.now() + 3600000, user.id]); + var resetUrl = safeAppUrl() + '/reset-password?token=' + token; + var resetSubject = await db.getSetting('email.reset.subject') || 'Reset your password — Pediatric AI Scribe'; + var resetBody = await db.getSetting('email.reset.body') || 'Someone requested a password reset for your Pediatric AI Scribe account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request a password reset, no action is needed.'; + await sendEmail(email, resetSubject, emailWrapper( + `

Password reset request

+

${escHtml(resetBody).replace(/\n/g, '
')}

+ ${btnHtml(resetUrl, 'Reset My Password')} + ${linkFallback(resetUrl)}` + )); + } catch (bgErr) { + console.error('[Auth] forgot-password background send failed:', bgErr.message); + } + })(); } catch (err) { console.error('[Auth] Forgot password error:', err.message); res.status(500).json({ error: 'Password reset request failed' }); } }); diff --git a/src/utils/logger.js b/src/utils/logger.js index e02815b..f768fa1 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -129,8 +129,17 @@ var logger = { var now = new Date(); var date = now.toISOString().split('T')[0]; var logFile = path.join(LOG_DIR, date + '.log'); - var line = '[' + now.toISOString() + '] [' + level.toUpperCase() + '] ' + message; - if (data) line += ' | ' + (typeof data === 'string' ? data : JSON.stringify(data)); + // Defensive redaction: route both message and optional data through + // redact() so PHI patterns (SSN, phone, email, DoB) and note-body + // heuristics can't leak to the daily log file if a caller accidentally + // passes a request body, clinical string, or stack trace containing + // transcript text. + var safeMessage = redact(String(message == null ? '' : message)); + var line = '[' + now.toISOString() + '] [' + level.toUpperCase() + '] ' + safeMessage; + if (data != null) { + var raw = typeof data === 'string' ? data : JSON.stringify(data); + line += ' | ' + redact(raw); + } line += '\n'; fs.appendFileSync(logFile, line); if (level === 'error') console.error(line.trim());