Skip to content

Commit c85bb9c

Browse files
authored
sec(api): add "register" rate-limit bucket and fix CSRF prefix match (#1016, #1017) (#1038)
* sec(api): add "register" rate-limit bucket and fix CSRF prefix match 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 * test(api): assert exact register rate-limit contract (5/15min) 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. * fix(api): fix stale forgot-password comment and health info-leak (02-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 * refactor(api): remove dead RateLimiter type and type-safe toAPIPermissions (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 * fix(api): cap in-memory rate limiter entries with oldest-reset eviction (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 * fix(api): add scheduled DB rate-limiter cleanup worker (02-M2) 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 * sec(api): fail-closed rate limiting on credential endpoints (02-M1) 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 * refactor(api): extract eviction helpers to fix gocyclo on Allow (CI) 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).
1 parent 278a467 commit c85bb9c

17 files changed

Lines changed: 434 additions & 209 deletions

‎internal/api/db_rate_limiter.go‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,48 @@ type DBRateLimiter struct {
2828
// Verify that DBRateLimiter implements RateLimiterInterface
2929
var _ RateLimiterInterface = (*DBRateLimiter)(nil)
3030

31-
// NewDBRateLimiter creates a new database-backed rate limiter
31+
// dbRateLimiterScheduledCleanupInterval is the period between scheduled
32+
// cleanup runs started by StartCleanupWorker. Separate from cleanupInterval
33+
// (the minimum time between opportunistic cleanup runs) so that the scheduled
34+
// worker can tick independently. Exposed as a var so tests can override it.
35+
var dbRateLimiterScheduledCleanupInterval = 10 * time.Minute
36+
37+
// NewDBRateLimiter creates a new database-backed rate limiter.
38+
// Call StartCleanupWorker on the returned limiter to ensure periodic cleanup
39+
// of expired rows independent of traffic (addresses 02-M2).
3240
func NewDBRateLimiter(pool *pgxpool.Pool) *DBRateLimiter {
3341
return &DBRateLimiter{
3442
pool: pool,
3543
limits: getDefaultRateLimits(),
36-
cleanupInterval: 60 * time.Second, // Only cleanup at most once per minute
44+
cleanupInterval: 60 * time.Second, // minimum gap between opportunistic cleanup runs
3745
}
3846
}
3947

48+
// StartCleanupWorker launches a background goroutine that calls cleanup()
49+
// on a fixed schedule, independent of the count==1 opportunistic trigger.
50+
//
51+
// This ensures that perpetually-denied keys (whose count never resets to 1)
52+
// are also evicted, preventing unbounded row growth on the rate_limits table
53+
// under sustained abuse (02-M2). The goroutine stops when ctx is cancelled.
54+
//
55+
// Call this once at server startup after creating the DBRateLimiter. The
56+
// goroutine is lightweight (one blocked timer channel) and safe to call from
57+
// tests with a short-lived context.
58+
func (rl *DBRateLimiter) StartCleanupWorker(ctx context.Context) {
59+
go func() {
60+
ticker := time.NewTicker(dbRateLimiterScheduledCleanupInterval)
61+
defer ticker.Stop()
62+
for {
63+
select {
64+
case <-ctx.Done():
65+
return
66+
case <-ticker.C:
67+
rl.cleanup()
68+
}
69+
}
70+
}()
71+
}
72+
4073
// SetLimit allows customizing rate limits for specific endpoints
4174
func (rl *DBRateLimiter) SetLimit(endpoint string, config RateLimitConfig) {
4275
rl.limitsMu.Lock()

‎internal/api/db_rate_limiter_test.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,35 @@ func TestDBRateLimiter_maybeCleanup_ReadyToCleanup(t *testing.T) {
108108
// Brief wait for the goroutine to complete
109109
time.Sleep(20 * time.Millisecond)
110110
}
111+
112+
// Regression test for 02-M2: StartCleanupWorker must launch a goroutine that
113+
// stops cleanly on context cancellation. The goroutine is exercised with a
114+
// fast ticker so we can confirm it fires without sleeping in tests.
115+
func TestDBRateLimiter_StartCleanupWorker_StopsOnContextCancel(t *testing.T) {
116+
// Override the scheduled interval to a very short duration for testing.
117+
orig := dbRateLimiterScheduledCleanupInterval
118+
dbRateLimiterScheduledCleanupInterval = 5 * time.Millisecond
119+
t.Cleanup(func() { dbRateLimiterScheduledCleanupInterval = orig })
120+
121+
rl := NewDBRateLimiter(nil) // nil pool; cleanup() is a no-op
122+
ctx, cancel := context.WithCancel(context.Background())
123+
124+
done := make(chan struct{})
125+
// Wrap cleanup in a channel signal to confirm the worker ran.
126+
go func() {
127+
defer close(done)
128+
rl.StartCleanupWorker(ctx)
129+
}()
130+
131+
// Let the ticker fire at least twice (2 * 5ms = 10ms + margin).
132+
time.Sleep(30 * time.Millisecond)
133+
cancel()
134+
135+
// StartCleanupWorker itself returns immediately (goroutine is launched
136+
// inside it). The done channel signals the outer goroutine exited.
137+
select {
138+
case <-done:
139+
case <-time.After(100 * time.Millisecond):
140+
t.Error("StartCleanupWorker goroutine wrapper did not return after cancel")
141+
}
142+
}

‎internal/api/handler.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,20 @@ func NewHandler(cfg HandlerConfig) *Handler {
180180
// internal/server/app.go) and passed in via cfg.OIDCSigner. Leave
181181
// it nil to disable the /.well-known/* endpoints.
182182

183+
// Startup assertion: every key string used in checkRateLimit call sites must
184+
// have a dedicated entry in getDefaultRateLimits(). A missing key falls
185+
// silently through to api_general (300/min), as happened in issue #1016.
186+
// This panics at init time so the gap is caught in tests and on cold start,
187+
// not under production load.
188+
assertRateLimitKeysKnown(
189+
"login",
190+
"setup_admin",
191+
"reset_password",
192+
"change_password",
193+
"register",
194+
"approve_cancel_public",
195+
)
196+
183197
return h
184198
}
185199

‎internal/api/handler_apikeys.go‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,15 @@ func (h *Handler) createAPIKey(ctx context.Context, req *events.LambdaFunctionUR
4242
return nil, err
4343
}
4444

45-
// Rate limiting: 30 admin operations per user per minute
45+
// Rate limiting: 30 admin operations per user per minute.
46+
// Fail-open: this endpoint is already behind requirePermission so the auth
47+
// surface is lower than the unauthenticated credential endpoints. Emit a
48+
// high-severity alert so the fail-open window is observable (02-M1).
4649
if h.rateLimiter != nil {
4750
allowed, err := h.rateLimiter.AllowWithUser(ctx, session.UserID, "admin")
4851
if err != nil {
49-
logging.Warnf("rate limiter error for user %s on admin operation: %v", session.UserID, err)
52+
logging.Errorf("ALERT: rate limiter error on admin operation for user %s; proceeding fail-open (02-M1): %v",
53+
session.UserID, err)
5054
} else if !allowed {
5155
return nil, NewClientError(429, "too many requests, please slow down")
5256
}

‎internal/api/handler_auth.go‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func (h *Handler) login(ctx context.Context, req *events.LambdaFunctionURLReques
2121
}
2222

2323
// Rate limiting: 5 attempts per IP per 15 minutes
24-
if err := h.checkRateLimit(ctx, req, "login"); err != nil {
24+
if err := h.checkRateLimitStrict(ctx, req, "login"); err != nil {
2525
return nil, err
2626
}
2727

@@ -224,7 +224,7 @@ func (h *Handler) setupAdmin(ctx context.Context, req *events.LambdaFunctionURLR
224224
}
225225

226226
// Rate limiting: 5 attempts per IP per 15 minutes for admin setup
227-
if err := h.checkRateLimit(ctx, req, "setup_admin"); err != nil {
227+
if err := h.checkRateLimitStrict(ctx, req, "setup_admin"); err != nil {
228228
return nil, err
229229
}
230230

@@ -253,12 +253,15 @@ func (h *Handler) forgotPassword(ctx context.Context, req *events.LambdaFunction
253253
return nil, NewClientError(400, "invalid request body")
254254
}
255255

256-
// Rate limiting: 3 attempts per email per hour to prevent enumeration attacks
256+
// Rate limiting: 10 attempts per email per 5 minutes to prevent enumeration attacks.
257+
// On rate-limiter error: emit a high-severity alert (02-M1) but continue with the
258+
// request. Failing closed here would reveal infrastructure state (503 vs 200) to
259+
// an enumerating attacker and harm legitimate users more than the enumeration risk.
257260
if h.rateLimiter != nil {
258261
allowed, err := h.rateLimiter.AllowWithEmail(ctx, pwdReq.Email, "forgot_password")
259262
if err != nil {
260-
logging.Warnf("Rate limiter error for email %s: %v", redactEmail(pwdReq.Email), err)
261-
// Continue on rate limiter errors to avoid blocking legitimate requests
263+
logging.Errorf("ALERT: rate limiter error on forgot_password for %s; proceeding fail-open (02-M1): %v",
264+
redactEmail(pwdReq.Email), err)
262265
} else if !allowed {
263266
logging.Warnf("Rate limit exceeded for forgot password: %s", redactEmail(pwdReq.Email))
264267
// Always return success message to prevent email enumeration
@@ -303,7 +306,7 @@ func (h *Handler) resetPasswordStatus(ctx context.Context, req *events.LambdaFun
303306

304307
// Same rate-limit bucket as the submit endpoint: a token-probing
305308
// attacker would otherwise get a free oracle to test tokens.
306-
if err := h.checkRateLimit(ctx, req, "reset_password"); err != nil {
309+
if err := h.checkRateLimitStrict(ctx, req, "reset_password"); err != nil {
307310
return nil, err
308311
}
309312

@@ -332,7 +335,7 @@ func (h *Handler) resetPassword(ctx context.Context, req *events.LambdaFunctionU
332335
}
333336

334337
// Rate limiting: 10 attempts per IP per 15 minutes
335-
if err := h.checkRateLimit(ctx, req, "reset_password"); err != nil {
338+
if err := h.checkRateLimitStrict(ctx, req, "reset_password"); err != nil {
336339
return nil, err
337340
}
338341

@@ -465,7 +468,7 @@ func (h *Handler) changePassword(ctx context.Context, req *events.LambdaFunction
465468
}
466469

467470
// Rate limiting: 10 attempts per IP per 15 minutes
468-
if err := h.checkRateLimit(ctx, req, "change_password"); err != nil {
471+
if err := h.checkRateLimitStrict(ctx, req, "change_password"); err != nil {
469472
return nil, err
470473
}
471474

‎internal/api/handler_coverage_test.go‎

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -776,20 +776,17 @@ func TestDecodeBase64Password_ValidBase64(t *testing.T) {
776776
assert.Equal(t, "test123", result)
777777
}
778778

779-
// Test for types_apikeys toAPIPermissions
779+
// Tests for types_apikeys toAPIPermissions (02-N2: typed input, no silent drop).
780780

781781
func TestToAPIPermissions_EmptySlice(t *testing.T) {
782-
perms := []interface{}{}
783-
784-
result := toAPIPermissions(perms)
785-
782+
result := toAPIPermissions([]Permission{})
786783
assert.Len(t, result, 0)
787784
}
788785

789786
func TestToAPIPermissions_WithPermissions(t *testing.T) {
790-
perms := []interface{}{
791-
Permission{Action: "read", Resource: "config"},
792-
Permission{Action: "write", Resource: "plans"},
787+
perms := []Permission{
788+
{Action: "read", Resource: "config"},
789+
{Action: "write", Resource: "plans"},
793790
}
794791

795792
result := toAPIPermissions(perms)
@@ -799,18 +796,12 @@ func TestToAPIPermissions_WithPermissions(t *testing.T) {
799796
assert.Equal(t, "read", result[0].Action)
800797
}
801798

802-
func TestToAPIPermissions_NonPermissionItems(t *testing.T) {
803-
perms := []interface{}{
804-
"not a permission",
805-
123,
806-
Permission{Action: "read", Resource: "config"},
807-
}
808-
809-
result := toAPIPermissions(perms)
810-
811-
// Only the valid Permission should be included
812-
assert.Len(t, result, 1)
813-
assert.Equal(t, "config", result[0].Resource)
799+
func TestToAPIPermissions_ReturnsCopy(t *testing.T) {
800+
// Mutation of the returned slice must not affect the original (copy contract).
801+
src := []Permission{{Action: "read", Resource: "config"}}
802+
result := toAPIPermissions(src)
803+
result[0].Action = "write"
804+
assert.Equal(t, "read", src[0].Action, "original slice must not be mutated")
814805
}
815806

816807
// Test NewHandler with API key loaded

‎internal/api/health.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"time"
66

77
"github.com/LeanerCloud/CUDly/internal/credentials"
8+
"github.com/LeanerCloud/CUDly/pkg/logging"
89
)
910

1011
// HealthResponse represents the health check response
@@ -64,9 +65,12 @@ func (h *Handler) checkConfigStore(ctx context.Context) HealthCheck {
6465
// Try to access config to verify database connectivity
6566
_, err := h.config.GetGlobalConfig(ctx)
6667
if err != nil {
68+
// Log the detailed error server-side; return a generic message to avoid
69+
// leaking connection details (host, db name, SQLSTATE) on this public route.
70+
logging.Errorf("health: config store check failed: %v", err)
6771
return HealthCheck{
6872
Status: "unhealthy",
69-
Message: "Failed to access config store: " + err.Error(),
73+
Message: "config store unavailable",
7074
}
7175
}
7276

‎internal/api/health_test.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ func TestHandler_GetHealth_ConfigStoreUnhealthy(t *testing.T) {
8585

8686
assert.Equal(t, "degraded", response.Status)
8787
assert.Equal(t, "unhealthy", response.Checks["config_store"].Status)
88-
assert.Contains(t, response.Checks["config_store"].Message, "Failed to access config store")
88+
// Error detail must NOT appear in the public body (02-L4: info-leak fix).
89+
assert.Equal(t, "config store unavailable", response.Checks["config_store"].Message)
90+
assert.NotContains(t, response.Checks["config_store"].Message, "database connection failed")
8991
assert.Equal(t, "healthy", response.Checks["auth_service"].Status)
9092
}
9193

@@ -179,8 +181,9 @@ func TestHandler_checkConfigStore_AccessError(t *testing.T) {
179181
check := handler.checkConfigStore(ctx)
180182

181183
assert.Equal(t, "unhealthy", check.Status)
182-
assert.Contains(t, check.Message, "Failed to access config store")
183-
assert.Contains(t, check.Message, "connection timeout")
184+
// Error detail must NOT appear in the public body (02-L4: info-leak fix).
185+
assert.Equal(t, "config store unavailable", check.Message)
186+
assert.NotContains(t, check.Message, "connection timeout")
184187
}
185188

186189
func TestHandler_checkAuthService_Healthy(t *testing.T) {

0 commit comments

Comments
 (0)