feat(auth): rate-limit /forgot-password per (IP, email) (PMS-680) #459

Merged
nrupard merged 1 commit from feat/PMS-680-forgot-password-rate-limit into main 2026-07-22 18:33:41 +02:00
Owner

What

Rate-limits POST /api/v1/auth/forgot-password per (source IP, lowercased email), closing the PMS-680 gap where the endpoint was unthrottled and a known address could be reset-email bombed.

How

  • Generalized the login limiter: LoginLimiter::new() (hardcoded 20/5) becomes AuthRateLimiter::new(ip_per_min, email_per_min). Each unauthenticated auth endpoint constructs its own instance with its own quotas and buckets, matching the portal's separate-limiter pattern (one endpoint's traffic never consumes another's quota).
    • Login: 20/min per IP, 5/min per email (unchanged).
    • Forgot-password: 10/min per IP, 3/min per email (rarer than login; enough for a fumbling user, caps bombing).
  • Extracted the 429 response (rate_limited JSON + Retry-After + no-store) into a shared rate_limited_response helper used by both login and forgot_password.
  • forgot_password now takes ConnectInfo<SocketAddr> and checks the limiter inline (the email is in the request body, the same reason login checks inline). Over quota returns 429; under quota the silent-success / no-enumeration behaviour is unchanged.

Tests

tests/auth.rs::forgot_password_rate_limit_triggers_429: the 4th request for one email within a minute returns 429 with Retry-After and the rate_limited body, mirroring login_rate_limit_triggers_429. Full cargo test --test auth passes against Postgres; cargo check --all-targets and clippy are clean.

Notes

The 10/3 quotas are the values proposed in the issue; tune if load testing suggests. The limiter is in-memory (same as the existing login limiter), so it is single-process only; horizontal scale needs a shared store, the same caveat login already carries.

#PMS-680

## What Rate-limits `POST /api/v1/auth/forgot-password` per (source IP, lowercased email), closing the PMS-680 gap where the endpoint was unthrottled and a known address could be reset-email bombed. ## How - Generalized the login limiter: `LoginLimiter::new()` (hardcoded 20/5) becomes `AuthRateLimiter::new(ip_per_min, email_per_min)`. Each unauthenticated auth endpoint constructs its own instance with its own quotas and buckets, matching the portal's separate-limiter pattern (one endpoint's traffic never consumes another's quota). - Login: 20/min per IP, 5/min per email (unchanged). - Forgot-password: 10/min per IP, 3/min per email (rarer than login; enough for a fumbling user, caps bombing). - Extracted the 429 response (`rate_limited` JSON + `Retry-After` + `no-store`) into a shared `rate_limited_response` helper used by both `login` and `forgot_password`. - `forgot_password` now takes `ConnectInfo<SocketAddr>` and checks the limiter inline (the email is in the request body, the same reason login checks inline). Over quota returns 429; under quota the silent-success / no-enumeration behaviour is unchanged. ## Tests `tests/auth.rs::forgot_password_rate_limit_triggers_429`: the 4th request for one email within a minute returns 429 with `Retry-After` and the `rate_limited` body, mirroring `login_rate_limit_triggers_429`. Full `cargo test --test auth` passes against Postgres; `cargo check --all-targets` and clippy are clean. ## Notes The 10/3 quotas are the values proposed in the issue; tune if load testing suggests. The limiter is in-memory (same as the existing login limiter), so it is single-process only; horizontal scale needs a shared store, the same caveat login already carries. #PMS-680
feat(auth): rate-limit /forgot-password per (IP, email) (PMS-680)
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 39s
Check / fmt + clippy + build + tests (pull_request) Successful in 1m31s
Integration / integration tests (pull_request) Successful in 5m51s
Create release / Gate (release-branch merges only) (pull_request) Successful in 1s
Create release / Create release from merged PR (pull_request) Has been skipped
f6b9a77567
The forgot-password endpoint had no rate limiting, so a known address could be reset-email bombed (each under-quota call sends an email). Login was already limited; this extends the same per-(IP, email) governor throttle to /forgot-password.

Generalize the login limiter into AuthRateLimiter::new(ip_per_min, email_per_min) (was LoginLimiter::new() hardcoded at 20/5) and give each unauthenticated auth endpoint its own instance with its own quotas and buckets, mirroring the portal's separate-limiter pattern: login stays 20/min IP + 5/min email; forgot-password gets a tighter 10/min IP + 3/min email. The 429 response (rate_limited JSON + Retry-After + no-store) is extracted into a shared rate_limited_response helper used by both handlers. Over quota returns 429; under quota the silent-success (no enumeration) semantics are unchanged.

tests/auth.rs gains forgot_password_rate_limit_triggers_429 (the 4th request for one email in a minute returns 429 + Retry-After), mirroring the login test. Full auth suite green.

#PMS-680

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard deleted branch feat/PMS-680-forgot-password-rate-limit 2026-07-22 18:33:41 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!459
No description provided.