Skip to content

fix(clear-rate-limit): match RATE_LIMIT_DOMAIN literally in the LIKE pattern - #1272

Draft
IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-357
Draft

IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-357

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Issue #357 lists two findings. I re-checked both against current main (a14dc8f2):

# Finding Status
1a MEDIUM: cmd/cleanup-lambda DELETE has no status guard, so once expires_at is set it could hard-delete pending/scheduled/running rows Already fixed by #1173 (47bf1dc3, "fix(cleanup): only delete settled executions when their TTL lapses", the fix for #998). Both deleteExpired and dryRunCount now use expires_at < $1 AND status = ANY(TerminalExecutionStatuses) AND status <> ALL(HealthScoredExecutionStatuses). Real-Postgres tests in cmd/cleanup-lambda/main_integration_test.go cover every status and a concurrent writer.
1b MEDIUM: cmd/cleanup-lambda can never delete an execution because no writer sets expires_at, so it reports executionsDeleted: 0 forever Deferred (needs a maintainer decision, reasons below)
2 LOW: RATE_LIMIT_DOMAIN is put into a LIKE pattern without escaping % and _ Fixed in this PR

Finding 2: root cause and fix

cmd/lambda/clear-rate-limit/main.go built its DELETE pattern as "EMAIL#%@" + domain + "#ENDPOINT#forgot_password". The query was parameterized, so this was never SQL injection. But % and _ in the domain are LIKE wildcards, and so is the _ in forgot_password. As a result:

  • RATE_LIMIT_DOMAIN=% cleared the forgot_password rate limits of every domain, as the issue describes.
  • A domain containing _, such as example_test.com, also cleared exampleQtest.com.
  • The endpoint's _ also matched other endpoint names such as forgotXpassword.

Fix: forgotPasswordPattern escapes \, % and _ in the domain and the endpoint, and the DELETE now runs id LIKE $1 ESCAPE '\'. Only the email's local part is still a wildcard, which is the intended scope. I moved the query into clearForgotPasswordLimits(ctx, db, domain) so a real-Postgres test can call it. clearRateLimit still opens the connection and calls that function, so the Lambda's behavior and response are otherwise the same.

I chose escaping over rejecting the value. Escaping keeps exact-match semantics for any input, so a bad value deletes nothing instead of returning an error. A typo now clears too little instead of too much.

Finding 1b: why it is deferred

The status-guard half is already on main (see the table). The remaining problem is that the job's only execution predicate is the row's TTL, and no current writer sets one. Fixing that means choosing a retention rule for this binary. I don't think that should be decided in a cleanup PR:

Options for the maintainer: (a) make cmd/cleanup-lambda call a shared, live-guarded version of CleanupOldExecutions after fix/issue-47 lands; (b) delete cmd/cleanup-lambda and its two unused modules if cleanup stays in the server task (#712); or (c) set PurchaseExecution.TTL when executions are created. Option (c) conflicts with the ramp-retention trigger from migration 000124 and with the health-score windows, so I'd avoid it.

Refs #357

Verification

All checks ran locally on the branch head. The DB evidence comes from a real Postgres testcontainer with every migration applied. No Lambda was deployed or invoked, and no AWS API was called. All commands ran with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true.

New tests:

  • cmd/lambda/clear-rate-limit/main_test.go, unit test (runs in plain go test): TestForgotPasswordPatternEscapesLikeMetacharacters pins the escaped pattern for example.com, %, example_test.com and a backslash domain.
  • cmd/lambda/clear-rate-limit/main_integration_test.go (//go:build integration): TestClearForgotPasswordLimitsDeletesOnlyTheNamedDomain seeds forgot_password rows for several domains, including a literal % domain, example_test.com and exampleQtest.com, plus a forgotXpassword row, a login row and an IP row. It runs the real DELETE for example.com, %, example_test.com and an unused domain, then checks the exact surviving rows and both response counts.

Both test files are force-added: the .gitignore entry clear-rate-limit, meant for the binary, also matches the package directory. main.go was already tracked the same way.

Commands:

go test -count=1 ./cmd/lambda/clear-rate-limit/
go test -tags integration -count=1 -timeout 20m -run 'TestClearForgotPasswordLimitsDeletesOnlyTheNamedDomain' -v ./cmd/lambda/clear-rate-limit/
  • After the fix: both pass. All 4 integration subtests pass.
  • Before the fix, with main.go's old pattern and query swapped back in:
    • The unit test fails on all 4 domains (for example, expected "EMAIL#%@\%#ENDPOINT#forgot\_password", actual "EMAIL#%@%#ENDPOINT#forgot_password").
    • Integration subtest % fails: the DELETE also removed the example.com (alice and bob), other.org, example_test.com and exampleQtest.com rows, plus the forgotXpassword row.
    • Subtest example_test.com fails: it also removed exampleQtest.com.
    • Subtest example.com fails: it also removed forgotXpassword.
    • nobody.invalid passes, as expected.
  • Mutation note: dropping only the ESCAPE '\' clause is not caught. Postgres's default LIKE escape character is already \, so the clause only makes the behavior explicit and the result is the same. Escaping only the domain but not the endpoint is caught by both tests.

Other checks:

  • go build ./..., go vet ./..., go vet -tags integration ./...: clean
  • go test -count=1 ./internal/mocks/: ok
  • golangci-lint v2.10.1 run --allow-parallel-runners ./cmd/lambda/clear-rate-limit/..., with and without --build-tags integration: 0 issues
  • gocyclo -over 10 -ignore "_test\.go" cmd/lambda/clear-rate-limit/: silent
  • gofmt -l: clean

Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree: queue/platform @ 936996ad = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-337 ff3420c
fix/issue-169 7d75eb5
fix/issue-342 66b221f
fix/issue-310 5e11c21
fix/issue-326 5f0ea59
fix/issue-243 365e959
fix/issue-343 6c94036
fix/issue-312 d2848a8
fix/issue-316 acd60dd
fix/issue-339 75ea9b9
fix/issue-339-commitment-type 73bdb19
fix/issue-353 5d25fcd
fix/issue-313 3156bc4
fix/issue-357 53d9e58
fix/issue-368 0ba727d
fix/issue-419 61648c9
fix/issue-111 b04c372
fix/issue-686 0ad90b7
fix/issue-674 793095d
fix/issue-1055 4ec96f1
fix/issue-559 dfa9525
fix/issue-558 034eed4
fix/issue-874 1720539
fix/issue-666 1e120da
fix/issue-668 fb26345
fix/issue-568 ce04173
fix/issue-311 e54f250
fix/issue-177 0cbb88a
fix/issue-151 c21377c
fix/issue-166 41fb77b
fix/issue-27 2d67622
fix/issue-323 69288ae
fix/issue-59 36463e8
fix/issue-43 35e4069
fix/issue-158 6f76f8a
fix/issue-104 bb00491
fix/issue-55 971ef1b
fix/issue-361 89ac220
fix/issue-120 b458719
fix/issue-36 42bac6e
fix/issue-525 57c97dd
fix/issue-712 e5bcda0
fix/issue-72 6cc63ed

fix/issue-72 and fix/issue-312 conflict on one line in internal/api/handler_dashboard.go (both change the calculateCommitmentMetrics call). The tree used #72's side with #312's validated provider variable; whichever PR merges second takes that same resolution.

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:

  • Passed at 936996ad: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks at 936996ad: golangci-lint v2.10.1 found 0 issues on every changed package, with and without --build-tags integration. tests/e2e is a separate module, so it was vetted under its own e2e tag. gocyclo -over 10 is silent on the changed non-test files.
  • Integration tests, every changed package, one package at a time (-tags integration -count=1, 60-minute local timeout because the VM was shared):
    • At 936996ad, these pass: internal/server, internal/credentials, internal/scheduler, internal/database/postgres/migrations, internal/purchase and internal/api.
    • internal/config passes except TestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).
    • These were run on the first build of this tree (26795376, same branches at earlier heads): internal/analytics, internal/auth, internal/commitmentopts, internal/database and cmd/lambda/clear-rate-limit all pass. The later fixes don't touch those packages.
  • What the combined run caught, each fixed on its own branch before this PR opened:
    • a compile break between fix/issue-104's test and fix/issue-326's httpToLambdaRequest signature change;
    • fix/issue-316 rejecting legacy-keyed plans;
    • fix/issue-120 versus main's global-filter test.
  • Secrets: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

Out of scope / notes

  • Finding 1b is deferred, as explained above.
  • clear-rate-limit still has no dry-run mode, and its defaultDomain is still hard-coded. The 2026-09-02 audit (A10-022) mentions both, but this issue doesn't ask for them.
  • CleanupOldExecutions branch 3's missing live-status guard (raised in fix(cleanup): only delete settled executions when their TTL lapses #1173) is still open. It touches the same code as fix/issue-47.
  • No migration, frontend or Terraform changes.

Maintainer: please mirror the issue's triage labels onto this PR: triaged, priority/p3, severity/low, urgency/eventually, impact/internal, effort/l, type/chore.

🤖 Generated with Claude Code

…pattern

clear-rate-limit built its DELETE pattern by concatenating
RATE_LIMIT_DOMAIN into "EMAIL#%@<domain>#ENDPOINT#forgot_password". The
query was parameterized, but % and _ inside the domain (and the _ in
"forgot_password") are LIKE wildcards, so RATE_LIMIT_DOMAIN=% cleared the
forgot_password rate limits of every domain, and a domain with an _
matched any character in that position.

The domain and endpoint are now escaped and the DELETE uses
ESCAPE '\', so only the email's local part is a wildcard. The query moved
into clearForgotPasswordLimits so a real-Postgres test can call it.

The cleanup-lambda half of the same review finding is not changed here:
its status guard already landed in LeanerCloud#1173, and its retention rule is left
for the maintainer to decide (see the PR body).

Refs LeanerCloud#357

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added impact/internal Team-internal only effort/l Weeks type/chore Maintenance / non-user-visible triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/l Weeks impact/internal Team-internal only priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant