Skip to content

fix(auth): lock out password-reset OTP after 5 failed attempts#238

Open
sebastiondev wants to merge 1 commit into
vernu:mainfrom
sebastiondev:fix/cwe640-auth-service-password-ed0b
Open

fix(auth): lock out password-reset OTP after 5 failed attempts#238
sebastiondev wants to merge 1 commit into
vernu:mainfrom
sebastiondev:fix/cwe640-auth-service-password-ed0b

Conversation

@sebastiondev

Copy link
Copy Markdown

Summary

POST /auth/reset-password verifies a 6-digit numeric OTP without any per-record attempt limit, which makes online brute-force of the OTP feasible within its 20-minute validity window. This PR adds a durable attempts counter to the PasswordReset document and locks out the reset record after 5 failed submissions.

  • Weakness: CWE-307 (Improper Restriction of Excessive Authentication Attempts) / CWE-640 (Weak Password Recovery).
  • Affected file/function: api/src/auth/auth.service.tsAuthService.resetPassword.
  • Endpoint: POST /auth/reset-password (api/src/auth/auth.controller.ts:170) — unauthenticated, body-controlled email + otp + newPassword.
  • Impact: account takeover of any user whose email address is known to the attacker.

Why the existing mitigations don't cover this

The password-reset flow already had some hardening from earlier commits, but none of it stops OTP guessing within a single reset window:

  • The global ThrottlerByIpGuard allows 500 requests/minute/IP → up to 10,000 attempts per IP over the 20-minute OTP lifetime. Against a 6-digit code (~9.0×10⁵ effective keyspace) that's roughly a 1.1% success chance per IP per window, and effectively certain from a small proxy pool.
  • Turnstile is only enforced on POST /auth/request-password-reset. POST /auth/reset-password — the submit path — never calls turnstileService.verify, so a single Turnstile solve unlocks unlimited guessing attempts against the resulting OTP.
  • The "5 reset requests per day per user" cap (commit 6252e4f) limits how many fresh OTPs the attacker can trigger, but doesn't limit guesses within one OTP record.
  • bcrypt.compare protects the stored OTP hash from offline attacks, not online guessing.

So pre-fix the practical guessing budget is ≈10k attempts/IP/window × N IPs × 5 records/day, which is more than enough to take over a targeted account.

The fix

Three small changes, kept as narrow as possible:

  1. api/src/auth/schemas/password-reset.schema.ts — add attempts: number (default 0) to the PasswordReset schema so the counter is persisted alongside the OTP record.
  2. api/src/auth/auth.service.ts — in resetPassword:
    • Reject immediately (and expire the record) if attempts >= MAX_PASSWORD_RESET_ATTEMPTS (5).
    • On a failed bcrypt.compare, increment attempts and save() the record.
    • On the failed attempt that hits the limit, set expiresAt = new Date(Date.now()) so a subsequent correct-guess submission against the same record cannot succeed.
  3. api/src/auth/auth.service.spec.ts — new regression tests (see below).

Rationale for the details:

  • The counter lives on the record, not in memory, so guesses across separate HTTP requests and separate API instances all count against the same budget.
  • Invalidating the record on the lock-out attempt is deliberate: without it, an attacker who gets a lucky 5th guess on the same record after the app restarts (or against a lag replica) could still win. Expiring the record removes that edge case.
  • The check runs before bcrypt.compare, so a locked-out record costs constant time regardless of the guess.
  • Combined with the existing 5-reset-requests-per-day cap this reduces the attacker budget from ~10,000 attempts per IP per window to 5 attempts per record × 5 records/24h = 25 guesses/day, i.e. ≈0.0028% success/day — practically infeasible.

Proof of concept (pre-fix)

Run the API locally with the current main:

git clone https://github.com/vernu/textbee && cd textbee
docker compose up -d # or: cd api && pnpm install && pnpm start:dev

Register a victim account via the UI or:

curl -sX POST http://localhost:3001/api/v1/auth/register \
  -H 'content-type: application/json' \
  -d '{"name":"Victim","email":"victim@example.com","password":"OriginalP@ss1"}'

Trigger a reset (this needs one Turnstile solve in production, none locally if Turnstile is unconfigured):

curl -sX POST http://localhost:3001/api/v1/auth/request-password-reset \
  -H 'content-type: application/json' \
  -d '{"email":"victim@example.com","turnstileToken":"dev"}'

Now brute-force the OTP against the same record — on the pre-fix code every wrong guess just returns 400 Invalid OTP and the record stays valid:

for i in $(seq 0 999999); do
  otp=$(printf '%06d' $i)
  resp=$(curl -sX POST http://localhost:3001/api/v1/auth/reset-password \
    -H 'content-type: application/json' \
    -d "{\"email\":\"victim@example.com\",\"otp\":\"$otp\",\"newPassword\":\"AttackerP@ss1\"}")
  echo "$otp -> $resp"
  case "$resp" in *"Password reset successful"*) echo "PWNED with $otp"; break;; esac
done

On the pre-fix code this loop eventually succeeds within the OTP's 20-minute window (or across a handful of request-password-reset cycles) and the attacker's AttackerP@ss1 becomes the account password. On the post-fix code the 6th request against any single reset record returns 400 Invalid OTP and further guesses (including the correct one) are rejected because the record's expiresAt has been set to now.

Tests

api/src/auth/auth.service.spec.ts adds three focused regression tests around resetPassword:

  1. Brute-force lockout — five wrong OTPs are rejected, and the correct OTP submitted afterwards is also rejected (proving the record is invalidated, not just rate-limited). Asserts user.save was never called and reset.attempts >= 5 was persisted.
  2. Happy path still works — a correct OTP with attempts: 2 succeeds and the user's password is written.
  3. Already-locked record — a record with attempts: 5 rejects the correct OTP with HttpException and does not modify the user.

Run just this file:

cd api
pnpm install
pnpm test -- auth.service.spec.ts

All three cases pass on the fix branch.

Adversarial review

Before submitting, we tried to disprove the finding:

  • "There's a router-level AuthGuard we missed." No — api/src/auth/auth.controller.ts:168-173 shows @Post('/reset-password') with no @UseGuards. The AuthGuard decorator is used further down the same controller (e.g. /send-email-verification-email), so its absence here is intentional and the endpoint is genuinely unauthenticated.
  • "Turnstile blocks the submit path." Grepping turnstileService.verify shows it's only invoked from requestResetPassword. The submit path (resetPassword) never calls it, so a bot that solved Turnstile once can reuse the resulting OTP window freely.
  • "The IP throttler is tight enough." ThrottlerByIpGuard is set to 500 rpm/IP globally, which is ~10,000 attempts per IP over the OTP's 20-minute lifetime — orders of magnitude too loose to defend a 6-digit OTP.
  • "bcrypt.compare timing already limits throughput." bcrypt slows offline cracking of the stored hash, but online guessing throughput is bounded by network + throttler, not by hash cost.
  • "Preconditions already give the attacker equivalent access." The preconditions are: know the victim's email, reach the public API, and (in production) one Turnstile solve. None of those grant any account access; the vulnerability turns them into full takeover of an arbitrary user account. Not redundant.

Scope

The change is limited to the password-reset OTP path. Email-verification OTPs (email-verification.schema.ts) use a similar shape but have lower stakes and are out of scope for this PR; happy to open a follow-up if you'd like the same treatment applied there.


Discovered by the Sebastion AI GitHub App.

The password-reset flow issues a 6-digit numeric OTP that lives for
20 minutes. The verification path (auth.service.resetPassword) had no
per-record failed-attempt counter and did not invalidate the reset
record on failure, so an attacker who triggered a reset email could
brute-force the OTP in-window (1e6 guesses).

Track a durable 'attempts' counter on the PasswordReset document and
invalidate the record after 5 failed submissions. A correct guess
after lockout is rejected as well.
@vercel

vercel Bot commented Jul 4, 2026

Copy link
Copy Markdown

@sebastiondev is attempting to deploy a commit to the vernu's projects Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant