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());