Repository navigation
sec(auth): rate-limit MFA lifecycle endpoints per IP and per user - #443
Conversation
The four /api/auth/mfa/* routes (setup, enable, disable, regenerate-recovery-codes) verify a password, TOTP or recovery code but never called the rate limiter, so a stolen session could brute-force the second factor without limit. Add an "mfa" bucket (10 attempts / 15 min) and apply it through requireMFASession, which checks the bucket per IP and per session user, both fail-closed like the other credential endpoints. Closes #94
|
Warning Review limit reached
This review includes 5 billable files and costs up to $1.25.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Or wait 51 minutes for your next included review. View limit detailsLimit details: You’ve used the included review currently available. Your 63 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Review configuration: ⚙️ Run configurationConfiguration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml Review profile: CHILL Plan: Essentials Run ID: 📒 Files selected for processing (5)
Comment |
|
Independent adversarial review (Opus 5.5) at 5d1f5a8: MERGE. Every TOTP/recovery-code verifier is throttled: login under the existing strict 'login' bucket; setup, enable, disable and regenerate now via requireMFASession ('mfa' bucket, 10 per 15 min, per IP then per session user, fail-closed 503 only on a limiter error). Brute force is capped at about 960 guesses/day/user. Pre-fix: both new tests fail. go test -race -short api/auth 3212 pass, golangci-lint v2.10.1 clean; CI 25/25. Minor follow-ups added to #442. |
Summary
The MFA lifecycle endpoints (
/api/auth/mfa/setup,/enable,/disable,/regenerate-recovery-codes) now go through the existing rate limiter, keyed per IP and per session user, fail-closed.Root cause
None of the four MFA handlers called
checkRateLimit/checkRateLimitStrict, andgetDefaultRateLimitshad no MFA bucket. A holder of a stolen session could POST unlimited TOTP guesses toregenerate-recovery-codes(about 333k expected requests with the 3-window verifier) and receive fresh recovery codes.Fix
mfabucket ingetDefaultRateLimits: 10 attempts / 15 min (setup plus enable plus a few typos fit comfortably), registered inassertRateLimitKeysKnown.requireMFASessioninmiddleware.go:checkRateLimitStrict(..., "mfa")per IP (same helper as login/change-password), thenrequireSession, thenAllowWithUser(..., "mfa"). Limiter errors return 503 and 429 on exhaustion, matching the other credential endpoints. The per-user key stops a single session from resetting its budget by rotating source IPs.requireMFASessioninstead ofrequireSession.loginbucket (strict, per IP), unchanged here.No new mechanism: this reuses
RateLimiterInterface(AllowWithIP/AllowWithUser) andcheckRateLimitStrict.Interaction with #109
The in-memory limiter is per instance, so on a multi-instance deploy the effective budget is 10 x instance count per window. That is still a bounded, low rate (the brute-force estimate goes from minutes to years per account on a handful of instances), but it is not a global limit until #109 is resolved. Not fixed here.
Out of scope (filed as #442)
The TOTP replay window appended to #94 (audit A03-016: no last-accepted-counter tracking) and wiring MFA failures into the
FailedLoginAttemptslockout are separate mechanisms; tracked in #442.Regression test
internal/api/handler_auth_test.go:TestHandler_mfaEndpoints_RateLimited: for each of the 4 endpoints, with a realInMemoryRateLimiter, attempts 1..10 reach the service (400 invalid code) and attempt 11 gets 429. Run twice per endpoint: fixed IP, and a different source IP per attempt (proves the per-user key).TestHandler_mfaEnable_RateLimiterErrorFailsClosed: limiter error returns 503.Proof it fails pre-fix: stashed the four non-test files and ran both tests; both FAIL (
expected: 429on attempt 11, and the fail-closed test fails). With the fix, all 10 (sub)tests pass.Verification
GOTOOLCHAIN=go1.26.6 GOWORK=off go build ./...: okgo vet ./internal/api/: okgo test -race -short -count=1 ./internal/api/: okgolangci-lint v2.10.1 run ./internal/api/...(CI pin): 0 issuesgo mod tidy -diff: emptyCloses #94