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:
parent
6dc7870a1b
commit
250646110f
2 changed files with 45 additions and 16 deletions
|
|
@ -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' }); }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in a new issue