Prevent brute force attacks on password reset#1190
Conversation
Summary of fixes ┌──────────────┬────────────────────────────────┐ │ Issue │ Fix │ ├──────────────┼────────────────────────────────┤ │ In-memory │ auth-rate-limiter.js now uses │ │ limiter │ Mongo-backed persistence (same │ │ doesn't │ as global limiter), with │ │ scale │ memory fallback │ ├──────────────┼────────────────────────────────┤ │ Race │ Failed attempts use atomic │ │ condition on │ $inc via findOneAndUpdate; │ │ attempt │ lockout uses updateOne with │ │ counter │ $set │ ├──────────────┼────────────────────────────────┤ │ Test │ Rewrote tests to await the │ │ flakiness │ middleware directly — no │ │ (setTimeout) │ timing dependencies │ ├──────────────┼────────────────────────────────┤ │ Stale │ Updated auth-send-reset.js │ │ comment │ line 19 comment │ ├──────────────┼────────────────────────────────┤ │ Limiter not │ Added │ │ initialized │ initializeAuthRateLimiter() │ │ at startup │ call in server.js startup │ │ │ sequence, after DB connect │ └──────────────┴────────────────────────────────┘
| // Check if too many failed attempts — invalidate secret atomically | ||
| if ((user.resetPasswordAttempts || 0) >= MAX_RESET_ATTEMPTS) { | ||
| console.warn(`[auth-reset-password][${os.hostname()}] Too many failed attempts, secret invalidated for: ${email}`); | ||
| await User.updateOne( |
There was a problem hiding this comment.
After 5 attempts do you invalidate the users password?
There was a problem hiding this comment.
No won't invalidate. Instead will block for 30 minutes with an error message displayed. Am working now on adding OL translations for those error messages.
| console.warn(`[auth-reset-password][${os.hostname()}] Invalid or expired TOTP code (attempt ${attempts}/${MAX_RESET_ATTEMPTS})`); | ||
|
|
||
| // If this increment just hit the limit, invalidate the secret now | ||
| if (updated && updated.resetPasswordAttempts >= MAX_RESET_ATTEMPTS) { |
There was a problem hiding this comment.
I guess it is quite secure that you invalidate the users password, but do you notify the user about it? There are password guesses all the time so this will make some users passwords invalid. You should at least send a message to the user letting them know
There was a problem hiding this comment.
We have very few admin users on sandbox - just our dev team. Will just display a message that they're locked out of password reset for 30 minutes. Working on that message now. Will summarize in new commit.
Here's what changed:
- auth-reset-password.js — API responses now include
a code field (RESET_LOCKED_OUT,
RESET_INVALID_CODE). Lockout message is now generic:
"too many failed attempts, please try again later"
(no mention of reset links or timing).
- AuthService.js — resetPassword attaches err.code
from the API response to the thrown Error.
- ResetCompletePage.js — Maps error codes to
translation keys, falls back to
t('reset.complete.error').
- en.json — Added reset.complete.lockedOut and
reset.complete.invalidCode.
- fr.json — Added matching French translations.
No emails, codes, reset links, attempt
counts, or lockout timestamps in logs now. Every log
message is purely operational — describes what
happened without exposing any sensitive data.
|
Rate limiting (middleware/auth-rate-limiter.js —
Timed lockout (api/auth/auth-reset-password.js)
User enumeration fix
Fresh secret per request
Bilingual error messages
Sensitive data scrubbed from logs
Schema (models/user.js)
|
api/auth/auth-reset-password.js
Outdated
| { _id: user._id }, | ||
| { $set: { resetPasswordSecret: null, resetPasswordAttempts: 0 } } | ||
| ); | ||
| return res.status(401).json({ success: false, message: 'invalid or expired code' }); |
There was a problem hiding this comment.
Instead of code, I would use password here. Unless users are logging in with a code, which I assume not? I think the "invalid or expired code" would be a confusing message to users.
api/auth/auth-reset-password.js
Outdated
| // Reject literal string "undefined" or "null" from client | ||
| if (code === 'undefined' || code === 'null') { | ||
| console.warn(`[auth-reset-password][${os.hostname()}] Received invalid code string: ${code}`); | ||
| console.warn(`[auth-reset-password][${os.hostname()}] Received invalid code string`); |
There was a problem hiding this comment.
I would replace code with password
There was a problem hiding this comment.
Actually this is for the TOTP code
- User enters their email on the reset request page
- auth-send-reset generates a 6-digit TOTP code and
emails a reset link containing it (e.g.
?email=...&code=123456) - User clicks the link, lands on ResetCompletePage
with code and email pre-filled from the URL - User enters their new password and submits
- auth-reset-password receives { email, code,
newPassword } and verifies the TOTP code
This guard catches the
case where the URL was malformed (missing ?code=
param), which would cause the frontend to send the
literal string "undefined".
| } | ||
|
|
||
| console.debug(`[auth-reset-password][${os.hostname()}] Validating TOTP code for: ${email}`); | ||
| console.debug(`[auth-reset-password][${os.hostname()}] Validating TOTP code`); |
There was a problem hiding this comment.
Ae you using a TOTP code for sign in? So you are enforcing MFA?
There was a problem hiding this comment.
MFA is built, and ready to switch over to enforce it via a settings toggle, which is defaulting to false right now. We just weren't ready to have it enabled.
|
Created docs/coding-agent-docs/authentication.md
|
🧪 AI Answers Review Environmenthttps://ehckgnbqcxc4nq6szotbdlmcmm0cshus.lambda-url.ca-central-1.on.aws/ |
Summary of fixes
┌──────────────┬────────────────────────────────┐
│ Issue │ Fix │
├──────────────┼────────────────────────────────┤
│ In-memory │ auth-rate-limiter.js now uses │
│ limiter │ Mongo-backed persistence (same │
│ doesn't │ as global limiter), with │
│ scale │ memory fallback │
├──────────────┼────────────────────────────────┤
│ Race │ Failed attempts use atomic │
│ condition on │ $inc via findOneAndUpdate; │
│ attempt │ lockout uses updateOne with │
│ counter │ $set │
├──────────────┼────────────────────────────────┤
│ Test │ Rewrote tests to await the │
│ flakiness │ middleware directly — no │
│ (setTimeout) │ timing dependencies │
├──────────────┼────────────────────────────────┤
│ Stale │ Updated auth-send-reset.js │
│ comment │ line 19 comment │
├──────────────┼────────────────────────────────┤
│ Limiter not │ Added │
│ initialized │ initializeAuthRateLimiter() │
│ at startup │ call in server.js startup │
│ │ sequence, after DB connect │
└──────────────┴────────────────────────────────┘
Summary | Résumé
Test instructions | Instructions pour tester la modification