Skip to content

fix(security): add rate limiting to auth and email endpoints - #2875

Draft
VirginiaWu11 wants to merge 3 commits into
developfrom
rate-limit-auth-endpoints
Draft

fix(security): add rate limiting to auth and email endpoints#2875
VirginiaWu11 wants to merge 3 commits into
developfrom
rate-limit-auth-endpoints

Conversation

@VirginiaWu11

Copy link
Copy Markdown
Contributor

Problem

No express-rate-limit or equivalent existed anywhere in server/. /api/accounts/login, /register, /forgotPassword, /resetPassword, /resendConfirmationEmail, /confirmRegister, and /api/email/contact accepted unlimited requests, enabling credential-stuffing/brute-force against login, mass account creation, and mail-relay abuse.

Fix

  • Added express-rate-limit with per-IP limiting:
    • Strict (5 req / 15 min): login, forgotPassword, resetPassword
    • Moderate (10 req / hour): register, resendConfirmationEmail, confirmRegister, email/contact
  • Each route gets its own limiter instance via a factory function (createStrictAuthLimiter() / createModerateAuthLimiter()) so unrelated actions don't share a rate-limit budget.
  • Added app.set("trust proxy", 1) so the limiter resolves the real client IP behind Heroku's proxy rather than the proxy's own address.
  • Client: login() now surfaces the server's actual rate-limit message on HTTP 429 instead of falling through to a misleading "incorrect password" message.
  • 429 response body shape aligned with the rest of the account API's {isSuccess, message} convention.

Explicitly out of scope (flagged, not silently decided)

  • CAPTCHA on register/contact — needs a provider decision (reCAPTCHA/hCaptcha/Turnstile) and client integration; separate follow-up.
  • Per-account (DB-tracked) lockout on login — IP-based limiting is the first line of defense here.
  • Automated test coverage for the new rate limiter — accepted as a Minor by review; recommend a short follow-up so a future refactor doesn't regress the per-route isolation this PR establishes.
  • Shared in-memory store (resets on dyno restart, not shared across dynos) — acceptable for the current deployment; would need Redis if horizontally scaled.

Checks

  • npm run typecheck (client + server): clean
  • npm run lint (server): clean
  • npx jest --ci (server): 10/27 failing — pre-existing stale-snapshot baseline in account.test.ts (unrelated to this change), no new failures
  • npm run build (client + server): clean
  • Manual verification: confirmed 429 fires at configured thresholds, confirmed per-route limiter isolation (exhausting /login's budget doesn't affect /forgotPassword), confirmed trust proxy resolves real client IP and resists spoofed X-Forwarded-For prefixes

Review

Independently reviewed via this repo's two-agent workflow (see docs/agent-workflow.md). Initial review found two Major issues (shared limiter buckets across unrelated routes; misleading 429 handling on login) — both fixed and re-verified. Final verdict: APPROVED.

🤖 Generated with Claude Code

VirginiaWu11 and others added 2 commits August 31, 2026 21:18
Adds express-rate-limit to slow down brute force / credential stuffing
and abuse against auth-adjacent endpoints, which previously had no
request limits:

- strictAuthLimiter (5 req / 15 min per IP): login, forgotPassword,
  resetPassword
- moderateAuthLimiter (10 req / hour per IP): register,
  resendConfirmationEmail, confirmRegister, email/contact

Also sets `trust proxy` in server.ts so the limiter keys off the real
client IP behind Heroku's proxy rather than the proxy's own address.

CAPTCHA on register/contact and per-account (DB-tracked) lockout are
left as separate follow-ups — out of scope for this pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes two Major findings from independent review of 96e711e:

- rate-limit.ts exported single shared limiter instances reused across
  multiple unrelated routes (login/forgotPassword/resetPassword sharing
  one instance, register/resendConfirmationEmail/confirmRegister/contact
  sharing another). Since express-rate-limit keys by IP only, this meant
  all routes on one shared instance drained the same counter/store --
  e.g. 5 failed logins would also 429 a subsequent forgotPassword
  request from the same IP. Fixed by converting to factory functions
  (createStrictAuthLimiter/createModerateAuthLimiter) called separately
  at each route registration, giving each route its own limiter/store.

- The client's login() silently swallowed a 429 response and returned
  undefined, causing the UI to fall through to "the password is
  incorrect... use Forgot Password" -- actively misleading once rate
  limited, especially compounded by the bug above. Fixed by having
  login() pass through the server's RATE_LIMITED response instead of
  discarding it, and Login.tsx now shows the actual rate-limit message.

Also aligned the 429 response body shape ({isSuccess, code, message})
with the rest of the account API's convention instead of an ad hoc
{error} shape (review Nit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@VirginiaWu11
VirginiaWu11 marked this pull request as draft September 1, 2026 02:07
Extends the login-page 429 fix (49697e4) to the other rate-limited
account endpoints, which had the same class of bug: their service
functions let axios throw on 429, so a rate-limited request fell into
each component's generic/hardcoded error handling instead of showing
the actual "too many attempts" message. Reported by the user after
manually testing forgotPassword and seeing a "Server error" toast
(axios's default message for a thrown 429).

- account-service.ts: extracted the login() 429-handling into a shared
  postAuthRequest() helper and applied it to register,
  resendConfirmationEmail, forgotPassword, resetPassword, and
  confirmRegister -- a 429 now returns the server's
  {isSuccess:false, code:"RATE_LIMITED", message} body instead of
  throwing. Any other error still throws, preserving each caller's
  existing behavior.
- ForgotPassword.tsx and ResetPassword.tsx already show
  response.message in their fallback branch, so no component change
  was needed there once the service layer stopped throwing.
- Register.tsx's fallback branch showed a hardcoded, unrelated message
  for any non-REG_DUPLICATE_EMAIL failure -- added an explicit
  RATE_LIMITED branch.
- ConfirmEmail.tsx's confirmRegister effect showed no toast at all on
  failure -- added a RATE_LIMITED branch with the real message.
- Login.tsx's AUTH_NOT_CONFIRMED branch called
  resendConfirmationEmail() without checking its result, always
  showing a "confirmation email sent" toast regardless of outcome.
  Since that call no longer throws on 429, it would have silently
  claimed success while actually rate-limited -- fixed to check the
  result and show the real message when rate-limited.

Verified: typecheck (client) clean, build (client) clean, manual
smoke test confirms the server returns the RATE_LIMITED body at the
configured threshold and the client no longer discards it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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