Skip to content

sec(api): cap forgot-password requests per client IP - #1166

Merged
cristim merged 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-13
Oct 20, 2026
Merged

cristim merged 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-13

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Root cause

POST /api/auth/forgot-password only 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 in docs/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

  • New forgot_password_ip bucket: 10 attempts per 15 minutes per IP (internal/api/rate_limiter.go). That is the same budget as reset_password, the next step of the same flow. It is a separate key from the per-email forgot_password bucket so each can be tuned on its own.
  • forgotPassword calls h.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.
  • Client IP derivation: this reuses the existing checkRateLimit helper, so the key is req.RequestContext.HTTP.SourceIP, the same key used by login, reset_password and the other per-IP buckets. On Lambda Function URLs, AWS sets SourceIP. On the HTTP server, internal/server/http.go takes 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.
  • Fail-open on a limiter error, matching the per-email bucket's documented 02-M1 decision for this endpoint. The 429 depends only on how many requests this caller has made, never on whether an account exists, so it does not reveal which addresses have accounts.
  • forgot_password and forgot_password_ip are added to the assertRateLimitKeysKnown startup 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
    • Before the fix, every new test failed. With 11 distinct addresses from one IP, the 11th returned 200 instead of 429. A concurrent burst of 40 returned {200:40} instead of {200:10, 429:30}. The key was missing from the defaults. PerIPDeniedSkipsEmailBucket panicked on an unexpected AllowWithEmail call, and PerIPLimiterErrorFailsOpen failed AssertExpectations because AllowWithIP was never called.
    • After the fix, all pass.
  • What the tests cover:
    • The per-IP cap applies across distinct addresses, and a different IP keeps its own budget.
    • x-forwarded-for / x-real-ip headers on the request don't open a new bucket.
    • Malformed bodies count against the budget.
    • A request denied by the IP bucket skips AllowWithEmail and RequestPasswordReset.
    • A limiter error fails open.
    • A concurrent burst of 40 from one IP admits exactly 10.
    • Both bucket configs are pinned.
  • go test -race ./internal/api/ -run 'forgotPassword|ForgotPassword' -count=3: pass.
  • Mutation probes, each reverted afterwards:
    • Using checkRateLimitStrict instead: PerIPLimiterErrorFailsOpen fails.
    • Moving the IP check after body parsing: MalformedBodiesSpendIPBudget fails.
    • Keying the IP check on "forgot_password": PerIPDeniedSkipsEmailBucket and 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 -v
    • Twelve requests to distinct addresses alternate between the two replicas. Each sends X-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 the rate_limits row IP#<real ip>#ENDPOINT#forgot_password_ip has count 12.
    • 15 concurrent requests across both replicas give {200:10, 429:5} with count 15. The DB upsert is atomic, so there is no over-admission race.
    • Before the fix (IP check removed): fails, with attempts 11-12 getting 200, no forgot_password_ip row, and the concurrent burst at {200:15}. After the fix: passes, for both lambda=false and lambda=true.
    • Mutation probe: switching internal/server/http.go to 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/ and go vet -tags integration on both
  • go test ./internal/api/ ./internal/server/ ./internal/mocks/ -count=1
  • golangci-lint v2.10.1 run --build-tags integration ./internal/api/... ./internal/server/...: 0 issues
  • gocyclo -over 10 -ignore "_test\.go" internal/api/: silent
  • gosec v2.28.0 with the hook's excludes on ./internal/api/: clean
  • git-secrets scan of the changed files in a fresh repo with an isolated HOME: clean
  • scripts/test-git-secrets-allowlist.sh: 42 PASS

No real AWS endpoint was exercised. The Lambda Function URL path is covered only through the shared SourceIP field (the lambda=true subtest still goes through the HTTP adapter). No real email was sent: the integration test uses the no-op email sender and example.test addresses that don't exist.

Out of scope (noted, not changed)

  • The per-email key is the raw string, so Alice@x.com and alice@x.com are 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.go uses the rightmost X-Forwarded-For entry, which is only trustworthy behind exactly one trusted proxy. Without an XFF header it falls back to r.RemoteAddr, which includes the port. These are existing, shared properties of every per-IP bucket (see the audit note at docs/audits/codebase-audit-2026-09-02.md:428), and the new bucket inherits them as they are.
  • The frontend's requestPasswordReset already 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

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>
@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 996b2163-1251-4f01-b04a-25e8760699d2
📥 Commits

Reviewing files that changed from the base of the PR and between e304ebe and 090f732.

📒 Files selected for processing: 5
  • internal/api/handler.go
  • internal/api/handler_auth.go
  • internal/api/handler_forgot_password_ratelimit_test.go
  • internal/api/rate_limiter.go
  • internal/server/app_rate_limiter_integration_test.go
+-----------------------------------------------------------------------------------------------------------------------------------+
| Finish what you start. Where possible, the routine or object that allocates a resource should be responsible for deallocating it. |
+-----------------------------------------------------------------------------------------------------------------------------------+
  \
   \     /)/)
        ( '')
        /  |
      o(___)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@IAmJSD IAmJSD added triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline impact/few Limited audience effort/s Hours type/security Security finding labels Oct 20, 2026
@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

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.

  • [non-blocking] The PR body's description of how the client IP is derived is incomplete for some deployment topologies. Suggest narrowing it to the topologies it actually holds for.

Against #13: point 2 is fixed at the root, and point 1 (the comment mismatch) is already fixed on main.

  • internal/api/handler_auth.go:342 adds a per-IP forgot_password_ip check. It runs before the body is parsed and before the per-email AllowWithEmail check (:356).
  • internal/api/rate_limiter.go adds the config at 10 requests per 15 minutes.
  • Both the DB and in-memory limiters load getDefaultRateLimits() (db_rate_limiter.go:43, inmemory_rate_limiter.go:39), so the key is not silently routed to api_general.
  • No migration. It reuses checkRateLimit rather than adding a helper.

Attacked:

  • Storage: production uses DBRateLimiter, an atomic INSERT ... ON CONFLICT upsert (db_rate_limiter.go:115-133) shared across replicas. The integration test shows exact admission across 2 replicas and under concurrent bursts.
  • Window: fixed. Denied requests count but do not extend reset_time.
  • Enumeration: the 429 comes before parsing and is independent of the account. The success body and timing are unchanged.
  • Normal flow: 10 requests per 15 minutes per IP is ample for one user.
  • Blast radius: forgot_password was grepped across go/ts/tf/yaml/json. The only other users are the startup key assertion (handler.go:229-231) and the limiter defaults.
  • Mutation probes on a scratch copy, each restored and confirmed identical with cmp:
    1. The IP check disabled: 3 tests FAIL (PerIPCapAcrossDistinctEmails, PerIPCapIgnoresForwardedHeaders, MalformedBodiesSpendIPBudget).
    2. The limit set to 11: the config pin test FAILS.
    3. The window set to 5 minutes: the config pin test FAILS.

Verified locally, on a git archive export of HEAD with no cloud credentials:

  • go build ./...: ok.
  • go vet ./internal/api/ ./internal/server/ and go vet -tags integration ./internal/server/: ok.
  • gofmt -l on the 5 changed files: clean.
  • go test -count=1 ./internal/api/ ./internal/server/, under the heavy lock: ok.
  • go test -tags integration -count=1 ./internal/server/ -run '^TestApplicationRateLimiter$': PASS for both lambda=false and lambda=true.
  • Fail-before, on the base export: the 4 unit tests FAIL, and the integration test FAILS for both subtests.

Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged.

@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

VERDICT: APPROVE 090f732

PR #1166 (closes #13), head 090f732, one commit on merge base 26 commits behind origin/main f4387e4. git merge-tree --write-tree origin/main 090f7321 is clean.

Issue #13 had two points:

  1. The comment/config mismatch was already fixed on main. handler_auth.go now documents both buckets, and TestGetDefaultRateLimits_ForgotPasswordBuckets pins both.
  2. The PR adds the missing per-IP cap: checkRateLimit(ctx, req, "forgot_password_ip") at handler_auth.go:342, before body parsing and before the per-email bucket. The bucket is 10 per 15 min (rate_limiter.go:43) and is registered in assertRateLimitKeysKnown (handler.go:230-231).

Findings (none blocking):

  • nit PR body, mutation-probe list - "Keying the IP check on forgot_password: ... and the config test fail". Reproduced: only PerIPDeniedSkipsEmailBucket fails. TestGetDefaultRateLimits_ForgotPasswordBuckets reads the config map and cannot see which key the handler passes. Correct the claim; no code change needed.
  • nit handler_auth.go:342 - fail-open matches the per-email bucket's documented 02-M1 decision. The cost: if the limiter errors, the per-IP cap stops applying. That is acceptable for a non-credential endpoint and consistent with the design.

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 runs on macOS at the PR head:
    • go test -race -run 'forgotPassword|ForgotPassword' ./internal/api/: 13 passed.
    • go vet -tags integration ./...: clean.
    • go test -tags integration -run '^TestApplicationRateLimiter$' ./internal/server/ (real Postgres via testcontainers, 2 replicas, lambda=false and lambda=true): PASS.
  • Mutation probes, each reverted afterwards:
    • IP check removed: 3 tests fail. The run stops at the mock panic in PerIPDeniedSkipsEmailBucket.
    • checkRateLimitStrict used instead: PerIPLimiterErrorFailsOpen fails.
    • IP check moved after body parsing: MalformedBodiesSpendIPBudget fails.
    • IP check keyed "forgot_password": PerIPDeniedSkipsEmailBucket fails.
    • http.go switched to the leftmost XFF entry: TestApplicationRateLimiter fails at attempts 11 and 12 in both modes.

@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

Local verification at 090f73210cf572b52720d98e9d799c600b42c261 merged onto current main (ca1e8a82; merge-tree f4b34ef9; macOS):

  • go build ./..., go vet -tags integration ./... and go test -race -short ./... pass (full suite).
  • Nothing was applied to any cloud.

@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

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 090f73210cf572b52720d98e9d799c600b42c261).

@cristim
cristim marked this pull request as ready for review October 20, 2026 23:46
@cristim
cristim merged commit 9fceda3 into LeanerCloud:main Oct 20, 2026
24 of 25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/security Security finding urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(auth): forgot-password rate limit comment mismatch and no per-IP cap

2 participants