Repository navigation
sec(api): cap forgot-password requests per client IP - #1166
Conversation
POST /api/auth/forgot-password only had the per-email bucket, so one client could walk any number of addresses, one request each, with no total volume cap. Add a forgot_password_ip bucket (10 per 15 minutes per IP, the same budget as reset_password) checked before the body is parsed. It keys on the request's SourceIP like every other per-IP bucket (rightmost, proxy-appended X-Forwarded-For entry on the HTTP server) and fails open like the per-email bucket (02-M1). The comment/config mismatch from the issue was already reconciled to 10 per 5 minutes in LeanerCloud#1038; the handler comment now names both buckets. Closes LeanerCloud#13 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 5
✨ Finishing Touches
Comment |
|
Independent review 2 (claude-fleet, cold): APPROVE 090f732 Findings (none blocking). Related non-blocking notes, all pre-existing and shared with other rate-limited endpoints, were reported privately to the maintainers per SECURITY.md.
Against #13: point 2 is fixed at the root, and point 1 (the comment mismatch) is already fixed on main.
Attacked:
Verified locally, on a
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
VERDICT: APPROVE 090f732 PR #1166 (closes #13), head 090f732, one commit on merge base 26 commits behind origin/main f4387e4. Issue #13 had two points:
Findings (none blocking):
Enumeration: the 429 comes only from this caller's own count of requests (middleware.go:483-485). The email is not parsed until after that check, and a request that passes it still gets the identical 200 body, whether the account exists or the per-email bucket is exhausted. No oracle. Memory and multi-instance: in production the sensitive routes run behind ensureDB (server/lambda.go:29, http.go:275), and ensureDB swaps the temporary in-memory limiter for DBRateLimiter (app.go:834). That limiter keeps shared counters and runs periodic cleanup. The in-memory fallback already has a hard cap with eviction (inmemory_rate_limiter.go:60-79). The integration test drives two replicas against one Postgres and admits exactly 10 of 15 concurrent requests. The body's out-of-scope notes are accurate and pre-existing: the HTTP server takes the rightmost X-Forwarded-For entry, and falls back to RemoteAddr with its port. Attacked:
|
|
Local verification at
|
|
Marked ready and merged by the owner's agent after 2 independent reviews and local verification (see the review and verification comments above, all at |
Root cause
POST /api/auth/forgot-passwordonly checked the per-email bucket (AllowWithEmail(..., "forgot_password")). The key is the address, so one client could send one request per address to any number of addresses and every request was allowed. There was no total volume cap per client (also logged as A02-018 indocs/audits/codebase-audit-2026-09-02.md).The issue's first point, the comment/config mismatch (comment said 3 per hour, config said 10 per 5 minutes), was already fixed on main by #1038, which changed the handler comment to 10 per 5 minutes. This PR leaves the per-email limit as it is. The handler comment now describes both buckets, and a test pins both configs.
Fix
forgot_password_ipbucket: 10 attempts per 15 minutes per IP (internal/api/rate_limiter.go). That is the same budget asreset_password, the next step of the same flow. It is a separate key from the per-emailforgot_passwordbucket so each can be tuned on its own.forgotPasswordcallsh.checkRateLimit(ctx, req, "forgot_password_ip")before it parses the body and before the per-email check (internal/api/handler_auth.go). Malformed bodies count against the budget too. A request refused here does not use up the target address's per-email budget.checkRateLimithelper, so the key isreq.RequestContext.HTTP.SourceIP, the same key used bylogin,reset_passwordand the other per-IP buckets. On Lambda Function URLs, AWS sets SourceIP. On the HTTP server,internal/server/http.gotakes the rightmost X-Forwarded-For entry, which is the one the nearest trusted proxy appends. Entries the client adds further left are ignored. Request headers that reach the handler are never read for the key.forgot_passwordandforgot_password_ipare added to theassertRateLimitKeysKnownstartup list (ci: verify golangci-lint config against a vendored schema #1016 guard).Verification
All commands were run from the repo root on macOS with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default AWS profile is invalid.Unit / mock / in-memory limiter (
internal/api/handler_forgot_password_ratelimit_test.go):go test ./internal/api/ -run 'forgotPassword|ForgotPassword' -count=1{200:40}instead of{200:10, 429:30}. The key was missing from the defaults.PerIPDeniedSkipsEmailBucketpanicked on an unexpectedAllowWithEmailcall, andPerIPLimiterErrorFailsOpenfailedAssertExpectationsbecauseAllowWithIPwas never called.x-forwarded-for/x-real-ipheaders on the request don't open a new bucket.AllowWithEmailandRequestPasswordReset.go test -race ./internal/api/ -run 'forgotPassword|ForgotPassword' -count=3: pass.checkRateLimitStrictinstead:PerIPLimiterErrorFailsOpenfails.MalformedBodiesSpendIPBudgetfails."forgot_password":PerIPDeniedSkipsEmailBucketand the config test fail.Local integration: real Postgres (testcontainers), the real HTTP server, two replicas, real X-Forwarded-For parsing (
internal/server/app_rate_limiter_integration_test.go, extended):go test -tags integration ./internal/server/ -run '^TestApplicationRateLimiter$' -count=1 -vX-Forwarded-For: 198.51.100.<n>, <real ip>, with a different spoofed leftmost entry every time. Requests 1-10 get 200, requests 11-12 get 429, and therate_limitsrowIP#<real ip>#ENDPOINT#forgot_password_iphas count 12.{200:10, 429:5}with count 15. The DB upsert is atomic, so there is no over-admission race.forgot_password_iprow, and the concurrent burst at{200:15}. After the fix: passes, for bothlambda=falseandlambda=true.internal/server/http.goto the leftmost XFF entry makes attempts 11 and 12 fail. This shows the test would catch a spoofed-XFF bypass.CI-equivalent checks:
go build ./...go vet ./internal/api/ ./internal/server/andgo vet -tags integrationon bothgo test ./internal/api/ ./internal/server/ ./internal/mocks/ -count=1golangci-lintv2.10.1run --build-tags integration ./internal/api/... ./internal/server/...: 0 issuesgocyclo -over 10 -ignore "_test\.go" internal/api/: silent./internal/api/: cleanscripts/test-git-secrets-allowlist.sh: 42 PASSNo real AWS endpoint was exercised. The Lambda Function URL path is covered only through the shared
SourceIPfield (thelambda=truesubtest still goes through the HTTP adapter). No real email was sent: the integration test uses the no-op email sender andexample.testaddresses that don't exist.Out of scope (noted, not changed)
Alice@x.comandalice@x.comare separate buckets. A02-018 suggests normalizing it. That is not part of sec(auth): forgot-password rate limit comment mismatch and no per-IP cap #13.internal/server/http.gouses the rightmost X-Forwarded-For entry, which is only trustworthy behind exactly one trusted proxy. Without an XFF header it falls back tor.RemoteAddr, which includes the port. These are existing, shared properties of every per-IP bucket (see the audit note atdocs/audits/codebase-audit-2026-09-02.md:428), and the new bucket inherits them as they are.requestPasswordResetalready shows the server's error text on any non-OK response, so a 429 appears as "too many requests, please try again later". The OpenAPI spec already lists 429 for this route.Maintainer: please mirror the issue's triage labels on this PR:
triaged,priority/p3,severity/low,urgency/eventually,impact/few,effort/s,type/security.Closes #13
🤖 Generated with Claude Code