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