Repository navigation
fix(account): limit OTP verification attempts to prevent brute force - #241
ArtyomSavchenko wants to merge 2 commits into
Conversation
Signed-off-by: Artyom Savchenko <armisav@gmail.com>
|
Review summary Limiting OTP attempts is a good idea, and the design holds up: the attempt is counted before the code is checked, Two issues to address before merge (details in the inline comments):
Minor: 🤖 Generated with Claude Code |
Signed-off-by: Artyom Savchenko <armisav@gmail.com>
MichaelUray
left a comment
There was a problem hiding this comment.
Thanks for tackling OTP brute force. I reviewed the two commits up to db9929d. The double counting from the earlier bot comment is fixed, $inc is atomic on Mongo and Postgres, existing rows are handled and all 14 locales have the new message. A few things still need to change before this is safe to merge, though.
Blocking
-
The window lock lets anyone lock a user out of OTP login, and the correct code is rejected too (
server/account/src/utils.ts:642,:767). I know thesendOtppart is planned for a separate PR, but with this PR merged as is, the lockout already exists: someone who knows only the email address requests a code and sends ~16 wrong guesses, after whichsendOtprefuses new codes andverifyOtpAttemptrejects even the correct code for the whole window. Repeating that once an hour keeps the user out indefinitely, and the password lock tells the user to unlock via OTP. Removing the check only fromsendOtpwould not be enough, becauseverifyOtpAttemptchecks the window before comparing the code. Suggestion: ship only the per-code cap in this PR (window limit off by default or removed) and add a throttle that cannot be triggered against a victim (e.g. keyed by client IP plus social id, or exponential backoff) in the follow-up. -
Guesses that overlap code creation are not counted (
utils.ts:757-763). When the first query finds no active OTP, verification still runs the second query and accepts a matching code found there.sendOtpinserts the code only after the email is sent (utils.ts:668-669), so concurrent verifications can do their first read before the insert and their second read after it: they skip the increment but are still checked against the new code. In an in-memory reproduction of this interleaving with the production function, 19 guesses returnedinvalid, a 20th matching guess returnedvalid, and the stored attempt count stayed at 0. Suggestion: returninvalidimmediately when the first query finds no active code, and only accept a code whose budget this request actually consumed (conditional increment or transaction), with a deterministic test that inserts a code between the two reads. -
After a resend, older codes are outside the per-code cap (
utils.ts:757-772). The increment now targets the newest active code, but the exhaustion check uses the code that matched. With a code lifetime longer than the resend delay (e.g. the in-code fallbacks of 60 s / 30 s), five wrong guesses on code A, then a resend creating B, then the correct A on the sixth try succeeds (counters A=5, B=1), and further wrong guesses only charge B. With the window limit disabled, whichOtpLimitsexplicitly supports, nothing bounds guesses against A. Suggestion: invalidate previous codes on resend, or accept and charge only the newest active code.
Should fix
- The tests cannot show that attempts accumulate.
mockDb.otp.updateis a no-opjest.fn(), so the second read never sees the increment. The "last allowed attempt" test starts at 5 stored attempts and expects success, but with a working increment production returnslocked(6 attempts).utils.test.ts:1052also pins the behaviour from point 1 (correct code rejected under the window lock). A stateful collection stub that applies$inc, plus tests for overlapping codes and the interleaving from point 2, would cover this. - The migration comment refers to an upstream key that does not exist (
server/account/src/collections/postgres/migrations.ts:890). There is no "v33" for this onupstream/develop; the open upstream PR hcengineering/platform#11143 adds the same column underaccount_db_v28_add_otp_attemptswith different counting. The SQL is idempotent, so the database is fine, but the comment is misleading and the next upstream sync will need to reconcile the two.
Nits
- An env var set to an empty string becomes
NaN, which disables the limits and makes the expired-code cleanup throw on every run; parse with a fallback. - The window is measured from code creation, not from the attempts.
- The lock message gives no time or way to recover.
- Every failed guess logs the user's email at warn level.
- The three new env vars are not documented.
No description provided.