Skip to content

sec(api): add "register" rate-limit bucket and fix CSRF prefix match (#1016, #1017) - #1038

Merged
cristim merged 8 commits into
feat/multicloud-web-frontendfrom
sec/api-auth-policy
Jun 7, 2026
Merged

cristim merged 8 commits into
feat/multicloud-web-frontendfrom
sec/api-auth-policy

Conversation

@cristim

@cristim cristim commented Jun 7, 2026 •

Copy link
Copy Markdown
Member

Summary

  • sec(api): public /api/register has no rate limit ("register" key missing → 300/min fallback) #1016 (rate limit): "register" was absent from getDefaultRateLimits(), so checkRateLimit(ctx, req, "register") silently fell through to api_general (300/min). At that rate an attacker can bulk-create pending-registration rows (each triggers a DB write + synchronous admin-notification email fan-out). Fixed by adding a dedicated "register" bucket at 5 req/15 min/IP (matching setup_admin severity). Added assertRateLimitKeysKnown(), called from NewHandler, that panics at startup if any key used in a checkRateLimit call is absent from the config map -- catches this class of bug before production traffic.
  • sec(api): CSRF exemption uses HasPrefix — /api/register exempts /api/registrations/*/approve|reject|delete #1017 (CSRF): csrfExemptAlways in requiresCSRFValidation matched "/api/register" with strings.HasPrefix, which also exempted POST /api/registrations/<id>/approve, /reject, and DELETE /api/registrations/<id> (all AuthAdmin, all state-changing). Approving a registration creates an enabled cloud account and stores credentials. Fixed by splitting the exempt list into a prefix slice for /api/auth/* paths and an exact path == "/api/register" check, mirroring the existing isPublicEndpoint logic.

Files changed

Test plan

  • go build ./... passes (confirmed)
  • go vet ./internal/api/... clean (confirmed)
  • go test ./internal/api/... 1496 tests pass (confirmed)
  • TestGetDefaultRateLimits_RegisterHasDedicatedBucket -- asserts "register" key exists and is stricter than api_general
  • TestAssertRateLimitKeysKnown_PanicsOnUnknownKey -- asserts startup assertion fires on missing key
  • TestAssertRateLimitKeysKnown_NoopForKnownKeys -- asserts assertion is silent for known keys
  • TestRequiresCSRFValidation_RegistrationsAdminRoutesAreNotExempt -- asserts all three admin /api/registrations/* routes require CSRF
  • TestRequiresCSRFValidation_AlwaysExemptPaths -- asserts /api/register exact path remains exempt

Closes #1016
Closes #1017

Scope expanded

Added 7 remaining findings from FOLD-1038 bucket (report 02 Medium/Low/Nit) as additional commits.

Commits added

ID Commit Summary
02-L1, 02-L4 c33654ad4 Fix stale forgot-password comment; hide raw DB error from public health response
02-N1, 02-N2 796dc7a51 Remove dead legacy RateLimiter type; type-safe toAPIPermissions (no silent drop)
02-M3 2d691b741 Cap in-memory rate limiter entries with oldest-reset eviction (500 entry cap)
02-M2 3aa4550e1 Add scheduled DB rate-limiter cleanup worker (StartCleanupWorker); wire in server/app.go
02-M1 130bd15cc Fail-closed rate limiting on credential endpoints (login, setup_admin, reset_password, change_password); alertable ALERT log for all fail-open paths

Additional test plan

  • go build ./... clean
  • go vet ./internal/api/... clean
  • go test ./internal/api/... 1494 tests pass
  • TestInMemoryRateLimiter_EntriesCapEnforced -- cap+2 IPs, map stays below cap after eviction
  • TestDBRateLimiter_StartCleanupWorker_StopsOnContextCancel -- worker exits on cancel
  • TestHandler_checkRateLimitStrict_FailsClosedOnError -- 503 on limiter error
  • TestHandler_checkRateLimit_FailsOpenOnError -- nil (allow) on limiter error
  • TestToAPIPermissions_ReturnsCopy -- mutation isolation after typed conversion

Also closes #1064

Issue #1016: "register" was missing from getDefaultRateLimits(), causing
checkRateLimit() to silently fall through to api_general (300/min). The
public /api/register endpoint writes to the DB and triggers a synchronous
admin-notification email per request, making 300/min a viable
table-flooding and inbox-spam vector. Add a dedicated "register" bucket
matching setup_admin severity (5 per 15 min per IP).

Add assertRateLimitKeysKnown(), called from NewHandler with every key used
in checkRateLimit call sites. Panics at startup if a key is missing from
the config map, catching this class of bug before production traffic hits.

Issue #1017: csrfExemptAlways used strings.HasPrefix on "/api/register",
which also exempted /api/registrations/<uuid>/approve, /reject, and DELETE
(all AuthAdmin, state-changing). Approving a registration creates an
enabled cloud account and stores credentials. Split the exempt list into a
prefix list for /api/auth/* paths and an exact match for /api/register,
mirroring the existing isPublicEndpoint() handling.

Regression tests:
- TestGetDefaultRateLimits_RegisterHasDedicatedBucket
- TestAssertRateLimitKeysKnown_PanicsOnUnknownKey
- TestAssertRateLimitKeysKnown_NoopForKnownKeys
- TestRequiresCSRFValidation_RegistrationsAdminRoutesAreNotExempt

Closes #1016, Closes #1017
@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm urgency/now Drop other things impact/many Affects most users effort/xs Trivial / one-liner type/security Security finding labels Jun 7, 2026
@coderabbitai

coderabbitai Bot commented Jun 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@cristim, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 32 minutes and 58 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 14b2c24e-58e7-45de-a9aa-819cecea6011

📥 Commits

Reviewing files that changed from the base of the PR and between f6d5caa and 3d170ab.

📒 Files selected for processing (17)
  • internal/api/db_rate_limiter.go
  • internal/api/db_rate_limiter_test.go
  • internal/api/handler_apikeys.go
  • internal/api/handler_auth.go
  • internal/api/handler_coverage_test.go
  • internal/api/health.go
  • internal/api/health_test.go
  • internal/api/inmemory_rate_limiter.go
  • internal/api/inmemory_rate_limiter_test.go
  • internal/api/middleware.go
  • internal/api/middleware_test.go
  • internal/api/rate_limiter.go
  • internal/api/rate_limiter_test.go
  • internal/api/types.go
  • internal/api/types_apikeys.go
  • internal/server/app.go
  • pkg/exchange/reshape_crossfamily_test.go
📝 Walkthrough

Walkthrough

Adds a dedicated 5/15min rate-limit bucket for /api/register, adds a startup assertion ensuring all rate-limit keys used by handlers are registered, tightens CSRF exemption matching to exact /api/register and narrower prefixes, and adds regression tests for both behaviors.

Changes

API Rate Limiting and CSRF Security Hardening

Layer / File(s) Summary
Rate limiter defaults & assertion
internal/api/rate_limiter.go, internal/api/inmemory_rate_limiter_test.go
Adds register default bucket (5 attempts per 15 minutes) and assertRateLimitKeysKnown(keys ...) which panics at startup if any provided key is missing. Includes tests validating the register bucket and the assertion's panic/no-op behavior.
Handler init-time check
internal/api/handler.go
NewHandler calls assertRateLimitKeysKnown(...) before returning, enumerating expected rate-limit keys so missing keys fail during startup/tests.
CSRF exemption matching & regression test
internal/api/middleware.go, internal/api/middleware_test.go
Replaces /api/register prefix exemption with exact path == "/api/register" and narrows always-exempt prefix list to specific auth/setup routes; adds a test asserting /api/registrations/* admin routes require CSRF when a session bearer token is present.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • LeanerCloud/CUDly#531: Modifies middleware.go's requiresCSRFValidation to condition CSRF exemptions based on session bearer token presence for approve/cancel routes.
  • LeanerCloud/CUDly#505: Adds rate-limit enforcement for public approve/cancel endpoints and updates getDefaultRateLimits, complementing the startup assertion added here.
  • LeanerCloud/CUDly#522: Adds endpoint-specific rate-limiter buckets (e.g., setup_admin) alongside the register bucket introduced here.

Poem

🐰 I hopped in code to mend the fence,
A register bucket, strict and dense,
Exact-match checks keep admin paths clean,
Startup will catch keys that once were unseen,
Now tests and mornings both rest easy and keen.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the two main changes: adding a 'register' rate-limit bucket and fixing CSRF prefix matching issues, with issue references.
Linked Issues check ✅ Passed Code changes fully implement all objectives from #1016 and #1017: dedicated register rate-limit bucket, startup assertion, exact-match CSRF exemption, and regression tests.
Out of Scope Changes check ✅ Passed All changes directly address the two linked issues; no unrelated modifications or scope creep detected in the files changed.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/api-auth-policy

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

@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/api/inmemory_rate_limiter_test.go (1)

269-288: ⚡ Quick win

Lock the register regression test to the exact security contract.

At Line 279-287, this test only checks “stricter than api_general” and non-zero window. A weak value like 299/min would still pass. Assert the exact 5 attempts / 15 minutes contract from #1016.

Proposed diff
 func TestGetDefaultRateLimits_RegisterHasDedicatedBucket(t *testing.T) {
 	limits := getDefaultRateLimits()

 	cfg, ok := limits["register"]
 	if !ok {
 		t.Fatal("register key missing from default rate limits (issue `#1016`): add it to getDefaultRateLimits()")
 	}

+	// Contract from `#1016`: 5 attempts per 15 minutes.
+	assert.Equal(t, 5, cfg.MaxAttempts, "register max attempts must be 5 per issue `#1016`")
+	assert.Equal(t, 15*time.Minute, cfg.Window, "register window must be 15 minutes per issue `#1016`")
+
 	// register must be far stricter than api_general (300/min).
 	apiGeneral := limits["api_general"]
 	if cfg.MaxAttempts >= apiGeneral.MaxAttempts {
 		t.Errorf("register limit (%d) is not stricter than api_general (%d); regression of `#1016`",
 			cfg.MaxAttempts, apiGeneral.MaxAttempts)
 	}

 	// Window must be non-zero.
 	if cfg.Window == 0 {
 		t.Error("register rate limit has zero window duration")
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/inmemory_rate_limiter_test.go` around lines 269 - 288, The test
TestGetDefaultRateLimits_RegisterHasDedicatedBucket is too loose; update it to
assert the exact security contract from `#1016` by checking the "register" entry
returned by getDefaultRateLimits() has MaxAttempts == 5 and Window ==
15*time.Minute (instead of only being stricter than api_general and non-zero).
Locate the "register" cfg in the test and replace/add assertions that compare
cfg.MaxAttempts to 5 and cfg.Window to 15*time.Minute so the test fails on any
regression in getDefaultRateLimits().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/api/inmemory_rate_limiter_test.go`:
- Around line 269-288: The test
TestGetDefaultRateLimits_RegisterHasDedicatedBucket is too loose; update it to
assert the exact security contract from `#1016` by checking the "register" entry
returned by getDefaultRateLimits() has MaxAttempts == 5 and Window ==
15*time.Minute (instead of only being stricter than api_general and non-zero).
Locate the "register" cfg in the test and replace/add assertions that compare
cfg.MaxAttempts to 5 and cfg.Window to 15*time.Minute so the test fails on any
regression in getDefaultRateLimits().

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d26d6e80-1a51-4e5e-92ea-8b2e415d3b79

📥 Commits

Reviewing files that changed from the base of the PR and between cbdc4be and e7ab667.

📒 Files selected for processing (5)
  • internal/api/handler.go
  • internal/api/inmemory_rate_limiter_test.go
  • internal/api/middleware.go
  • internal/api/middleware_test.go
  • internal/api/rate_limiter.go

Lock the register regression test to the precise security contract from
issue #1016 instead of only checking "stricter than api_general" and a
non-zero window. A weak value like 299/min would have passed the old
assertions while still allowing registration flooding. Assert the exact
5 attempts / 15 minutes contract so any future relaxation is caught.

Addresses CodeRabbit nitpick on PR #1038.
@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

Addressed CR review findings:

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026 •

Copy link
Copy Markdown
Contributor

@cristim Thanks for the follow-up! Pinning the exact contract (5 attempts / 15 minutes) is a much stronger regression guard than the relative "stricter than api_general" check — the stash-verification confirming failures under a weakened config is a nice touch. Triggering a fresh review pass now.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

cristim added 5 commits June 7, 2026 04:18
…L1, 02-L4)

02-L1: update the forgot_password handler comment from
"3 attempts per email per hour" to match the actual config
(10 attempts / 5 minutes in getDefaultRateLimits).

02-L4: health.go checkConfigStore exposed err.Error() in the public
/health response body (AuthPublic route). Log the detail server-side
only and return a static "config store unavailable" message to callers.
Update tests to assert the error string does not appear in the body.

Closes #1064
…sions (02-N1, 02-N2)

02-N1: remove legacy RateLimiter/newRateLimiter/rateLimitEntry from
types.go and rate_limiter.go. Production paths wire InMemoryRateLimiter
or DBRateLimiter (both implement RateLimiterInterface). The dead code
carried the same unbounded-GC bug as 02-M3 and was a maintenance trap.
Remove matching tests; redirect rate_limiter_test.go to a comment noting
where the config-function tests now live.

02-N2: change toAPIPermissions from []any (silent-drop type-assertion) to
[]Permission (typed, total). Replace the apologetic "in production this would
use proper conversion" comment with a clear copy-contract doc. Update tests:
replace the now-impossible NonPermissionItems test with a ReturnsCopy test
that asserts the mutation-isolation contract.

Closes #1064
…on (02-M3)

The previous GC only removed already-expired entries above 1000 keys,
allowing an attacker rotating source IPs to grow the map without bound
(expired entries are harmless, so an adversary is never pruned). The
fix lowers the hard cap to 500, evicts all expired entries first, then
evicts the oldest live entries (by resetTime) down to 80% of cap if the
map is still at or above the cap.

Add a regression test (TestInMemoryRateLimiter_EntriesCapEnforced) that
injects cap+2 unique IPs, confirms that one eviction cycle fires and the
final map is strictly below cap. Previously this test would reach cap+2.

Closes #1064
The existing opportunistic cleanup path (triggered when count==1) never
fires for perpetually-denied keys: under sustained abuse the abusing key's
count keeps climbing without ever resetting to 1, so its row escapes the
24-hour eviction window indefinitely.

Add StartCleanupWorker(ctx) which launches a background goroutine that
runs cleanup() on a fixed schedule (default 10 minutes), independent of
traffic. Wire it in server/app.go immediately after NewDBRateLimiter so
Lambda deployments benefit on startup.

The integer width of count is Go's default int (64-bit on all supported
platforms), so unbounded growth is cosmetically undesirable but not an
overflow risk.

Add TestDBRateLimiter_StartCleanupWorker_StopsOnContextCancel: overrides
the ticker interval to 5ms, confirms the worker exits cleanly on cancel.

Closes #1064
The global checkRateLimit swallows limiter errors and allows requests
through (fail-open). For data/general endpoints this is intentional, but
for the credential brute-force surface (login, setup_admin, reset_password,
change_password) it means any DB pressure that causes the rate limiter to
error silently disables protection. An attacker who can induce DB pressure
also unlocks unlimited password guessing.

Add checkRateLimitStrict (fail-closed): returns 503 on limiter error with
a high-severity ALERT log so monitoring can detect and alert on the
fail-closed window. Switch login, setup_admin, reset_password (both sites),
and change_password to checkRateLimitStrict.

For forgot_password (email-keyed, inline call): emit an ALERT log on error
but keep fail-open because failing closed here would return 503 vs 200,
leaking infrastructure state to an enumerating attacker; the enumeration
risk is the higher concern.

For createAPIKey (authenticated, user-keyed): upgrade the warn log to an
error ALERT so the fail-open window is observable, but keep fail-open
because the endpoint is behind requirePermission.

Add three regression tests:
- TestHandler_checkRateLimitStrict_FailsClosedOnError: 503 on db error
- TestHandler_checkRateLimit_FailsOpenOnError: nil (allow) on db error
- TestHandler_checkRateLimitStrict_Returns429WhenDenied: normal deny path

Closes #1064
@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

Added 5 commits for FOLD-1038 bucket (report 02, Med/Low/Nit, closes #1064):

02-L1 (c33654ad4): Fix stale comment in forgotPassword -- "3 attempts per email per hour" updated to match config (10/5 min).

02-L4 (c33654ad4): checkConfigStore in health.go returned raw err.Error() in the public /health response (AuthPublic route). Now logs server-side, returns static "config store unavailable" to callers.

02-N1, 02-N2 (796dc7a51): Removed dead legacy RateLimiter/newRateLimiter/rateLimitEntry types (maintenance trap with same GC bug as M3). Changed toAPIPermissions from []any (silent-drop type assertion) to []Permission (typed, total, returns copy).

02-M3 (2d691b741): In-memory rate limiter GC only removed already-expired entries above 1000 keys. Lowered cap to 500; added two-phase eviction: (1) expire already-stale entries, (2) if still at cap, evict oldest-reset-time live entries down to 80% of cap. Regression test: inject cap+2 unique IPs with long window, assert map stays below cap.

02-M2 (3aa4550e1): Added StartCleanupWorker(ctx) to DBRateLimiter -- a background goroutine that runs cleanup() on a 10-minute schedule, independent of the count==1 opportunistic trigger. Wired in server/app.go alongside NewDBRateLimiter. Regression test: context-cancel stops worker cleanly.

02-M1 (130bd15cc): Added checkRateLimitStrict (fail-closed variant): on limiter error, returns 503 and emits an ALERT-level error log instead of allowing through. Switched login, setup_admin, reset_password (both call sites), and change_password to checkRateLimitStrict. forgot_password stays fail-open (503 vs 200 leaks infrastructure state to email enumerators; upgraded to ALERT log). createAPIKey upgraded from warn to ALERT log. Three new middleware regression tests cover the fail-closed/open split.

Verification: go build ./... clean, go vet ./internal/api/... clean, go test ./internal/api/... 1494 tests pass.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026 •

Copy link
Copy Markdown
Contributor

@cristim This is a thorough and well-documented fold — the fail-closed/fail-open rationale for forgot_password (avoiding 503-vs-200 enumeration leakage to email enumerators) is particularly well-reasoned, and the two-phase eviction for the in-memory GC is a clean fix over the previous single-threshold approach. Triggering a fresh review now.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Allow in InMemoryRateLimiter had cyclomatic complexity 13, over the
pre-commit gocyclo threshold of 10. Extract two helpers:
- evictIfAtCap: cap-guard + expired-entry purge
- evictOldest: oldest-first eviction down to 80% of cap

Allow drops to CC=5; helpers are CC=5 and CC=4. No logic change.

Also fix gofmt on rate_limiter.go (trailing blank line) and
pkg/exchange/reshape_crossfamily_test.go (alignment in struct literals).
@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit c85bb9c into feat/multicloud-web-frontend Jun 7, 2026
4 checks passed
@cristim
cristim deleted the sec/api-auth-policy branch July 27, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/xs Trivial / one-liner impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/security Security finding urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant