fix(security): timing-safe forgot-password + redact log file writer

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.
This commit is contained in:
Daniel 2026-04-22 01:00:37 +02:00
parent 6dc7870a1b
commit 250646110f
2 changed files with 45 additions and 16 deletions

View file

@ -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(
`<p style="margin:0 0 8px;font-size:20px;font-weight:600;">Password reset request</p>
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">${escHtml(resetBody).replace(/\n/g, '<br>')}</p>
${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(
`<p style="margin:0 0 8px;font-size:20px;font-weight:600;">Password reset request</p>
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">${escHtml(resetBody).replace(/\n/g, '<br>')}</p>
${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' }); }
});

View file

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