security: add strict per-IP rate limiting to auth endpoints#101
security: add strict per-IP rate limiting to auth endpoints#101
Conversation
#95) Verified that all JWT validation paths in JWTAuthModule already enforce HS256 via both type assertion (*jwt.SigningMethodHMAC) and explicit algorithm check (token.Method.Alg() != jwt.SigningMethodHS256.Alg()). Added tests to module/jwt_auth_test.go that explicitly confirm tokens signed with HS384 or HS512 are rejected by: - Authenticate() — the AuthProvider interface method - handleRefresh via Handle() — the /auth/refresh endpoint - extractUserFromRequest via Handle() — all protected endpoints The api package (middleware.go, auth_handler.go) already had equivalent algorithm rejection tests in auth_handler_test.go. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
#66) Adds detailed documentation for audit logging, license.validator, platform.provider/resource/context, observability.otel, step.jq, AI pipeline steps (ai_complete, ai_classify, ai_extract), CI/CD steps (docker_build, docker_push, docker_run, scan_sast, scan_container, scan_deps, artifact_push, artifact_pull), and the admincore plugin. Each entry includes config field tables with types and defaults, plus a minimal YAML example. Summary tables in the module type index are also updated with the new Infrastructure and CI/CD Step categories. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add dedicated rate-limit middleware modules for login (10/min) and register (5/hour) in admin/config.yaml, replacing the shared global limiter that allowed 120 req/min on sensitive auth endpoints - Extend RateLimitMiddleware to support fractional rates via float64 token buckets, enabling hourly rate configs without precision loss - Add NewRateLimitMiddlewareWithHourlyRate constructor and factory support for requestsPerHour config key - Add requestsPerHour field to the http.middleware.ratelimit schema - Add tests for hourly rate middleware and per-hour factory config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds strict per-IP rate limiting to authentication endpoints (/auth/login and /auth/register) to mitigate brute-force and credential stuffing attacks. It extends the existing RateLimitMiddleware to support fractional per-minute rates via float64 token buckets, enabling low-frequency hourly rate limits needed for registration (5 req/hour). The implementation includes a new constructor for hourly rates, updates to the factory/schema for configuration support, and comprehensive test coverage for the new functionality.
Changes:
- Extended rate limiting middleware to support fractional per-minute rates for hourly limits
- Added dedicated rate limiters to login (10 req/min) and register (5 req/hour) endpoints
- Includes unrelated JWT algorithm confusion tests and extensive documentation updates
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
module/http_middleware.go |
Changed token bucket to use float64 for fractional rates; added NewRateLimitMiddlewareWithHourlyRate constructor; updated refill logic and cleanup calculations |
module/http_middleware_test.go |
Added three tests for hourly rate functionality: burst exhaustion, per-IP isolation, and rate calculation verification |
plugins/http/modules.go |
Updated factory to check requestsPerHour first (takes precedence over requestsPerMinute) for both int and float64 config values |
plugins/http/schemas.go |
Added requestsPerHour config field to rate limit schema with precedence documentation |
plugins/http/plugin_test.go |
Added factory test verifying both int and float64 requestsPerHour configuration handling |
admin/config.yaml |
Added two dedicated rate limiters (auth-login-ratelimit, auth-register-ratelimit) and applied them to respective auth endpoints |
module/jwt_auth_test.go |
Added JWT algorithm confusion attack prevention tests (unrelated to rate limiting) |
DOCUMENTATION.md |
Added extensive documentation for CI/CD steps, AI modules, infrastructure modules (unrelated to rate limiting) |
Comments suppressed due to low confidence (1)
admin/config.yaml:163
- The /api/v1/auth/refresh endpoint lacks rate limiting protection. While login and register now have strict per-IP limits, the refresh endpoint only has CORS middleware. An attacker with a stolen or leaked refresh token could abuse this endpoint to generate unlimited access tokens. Consider applying rate limiting to this endpoint as well, though it could be more permissive than login (e.g., 30-60 requests/minute) since legitimate clients may need to refresh tokens frequently during long sessions.
- method: POST
path: "/api/v1/auth/refresh"
handler: admin-auth
middlewares: [admin-cors]
| if m.ratePerMinute != 1.0 { | ||
| t.Errorf("expected ratePerMinute=1.0, got %f", m.ratePerMinute) | ||
| } | ||
| } |
There was a problem hiding this comment.
The test suite lacks coverage for verifying that token refill works correctly with fractional rates over time. While TestNewRateLimitMiddlewareWithHourlyRate_RatePerMinute verifies the ratePerMinute field is set correctly, there's no test that verifies a client can make another request after waiting long enough for tokens to refill with the fractional rate. Consider adding a test that exhausts the burst, sleeps for a calculated duration, and verifies that exactly the expected number of additional requests are allowed based on the fractional refill rate.
| } | |
| } | |
| func TestNewRateLimitMiddlewareWithHourlyRate_FractionalRefill(t *testing.T) { | |
| // Use an hourly rate that produces a fractional ratePerMinute but short time-per-token. | |
| // 3599 requests/hour -> ratePerMinute = 3599 / 60 ≈ 59.9833 (fractional), | |
| // time per token ≈ 3600 / 3599 seconds ≈ 1.00028s. | |
| m := NewRateLimitMiddlewareWithHourlyRate("rl-hour-fractional", 3599, 1) | |
| handler := m.Process(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| w.WriteHeader(http.StatusOK) | |
| })) | |
| // First request should be allowed (uses burst token). | |
| req1 := httptest.NewRequest("GET", "/fractional", nil) | |
| rec1 := httptest.NewRecorder() | |
| handler.ServeHTTP(rec1, req1) | |
| if rec1.Code != http.StatusOK { | |
| t.Fatalf("first request: expected 200, got %d", rec1.Code) | |
| } | |
| // Second immediate request should be rate limited (burst exhausted, no time to refill). | |
| req2 := httptest.NewRequest("GET", "/fractional", nil) | |
| rec2 := httptest.NewRecorder() | |
| handler.ServeHTTP(rec2, req2) | |
| if rec2.Code != http.StatusTooManyRequests { | |
| t.Fatalf("second request: expected 429, got %d", rec2.Code) | |
| } | |
| // Calculate how long it should take to refill a single token based on the fractional rate. | |
| if m.ratePerMinute <= 0 { | |
| t.Fatalf("ratePerMinute must be positive, got %f", m.ratePerMinute) | |
| } | |
| timePerToken := time.Duration(float64(time.Minute) / m.ratePerMinute) | |
| // Wait slightly longer than required for one token to refill. | |
| time.Sleep(timePerToken + 100*time.Millisecond) | |
| // After waiting, exactly one additional request should be allowed. | |
| req3 := httptest.NewRequest("GET", "/fractional", nil) | |
| rec3 := httptest.NewRecorder() | |
| handler.ServeHTTP(rec3, req3) | |
| if rec3.Code != http.StatusOK { | |
| t.Fatalf("third request after refill: expected 200, got %d", rec3.Code) | |
| } | |
| // A subsequent immediate request should still be rate limited since only ~1 token refilled. | |
| req4 := httptest.NewRequest("GET", "/fractional", nil) | |
| rec4 := httptest.NewRecorder() | |
| handler.ServeHTTP(rec4, req4) | |
| if rec4.Code != http.StatusTooManyRequests { | |
| t.Fatalf("fourth request after refill: expected 429, got %d", rec4.Code) | |
| } | |
| } |
|
@copilot open a new pull request to apply changes based on the comments in this thread |
…efill test, schema accuracy (#107) * Initial plan * fix: validate rate limit config values, dynamic Retry-After, fractional refill test, fix schema description Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
plugins/http/modules.go:185
rateLimitMiddlewareFactorycontains a duplicated/obsolete implementation block after the new parsing logic. This redeclaresrequestsPerMinutein the same scope (won’t compile) and also unconditionally returns an hourly limiter even whenrequestsPerHouris 0/negative, which would create a limiter withratePerMinute=0(no refill). Remove the old block (lines 170-184) and keep a single code path that (a) only usesrequestsPerHourwhen > 0 and (b) otherwise falls back to validatedrequestsPerMinutedefaults.
// requestsPerHour takes precedence over requestsPerMinute for low-frequency
// endpoints (e.g. registration) where fractional per-minute rates are needed.
if rph, ok := cfg["requestsPerHour"].(int); ok {
return module.NewRateLimitMiddlewareWithHourlyRate(name, rph, burstSize)
} else if rph, ok := cfg["requestsPerHour"].(float64); ok {
return module.NewRateLimitMiddlewareWithHourlyRate(name, int(rph), burstSize)
}
requestsPerMinute := 60
if rpm, ok := cfg["requestsPerMinute"].(int); ok {
requestsPerMinute = rpm
} else if rpm, ok := cfg["requestsPerMinute"].(float64); ok {
requestsPerMinute = int(rpm)
}
return module.NewRateLimitMiddleware(name, requestsPerMinute, burstSize)
}
| func TestRateLimitMiddlewareFactory_InvalidValues(t *testing.T) { | ||
| factories := moduleFactories() | ||
| factory, ok := factories["http.middleware.ratelimit"] | ||
| if !ok { | ||
| t.Fatal("no factory for http.middleware.ratelimit") | ||
| } | ||
|
|
||
| // Zero requestsPerHour must fall through to requestsPerMinute path (not crash). | ||
| modZeroRPH := factory("rl-zero-rph", map[string]any{ | ||
| "requestsPerHour": 0, | ||
| "requestsPerMinute": 30, | ||
| "burstSize": 5, | ||
| }) | ||
| if modZeroRPH == nil { | ||
| t.Fatal("factory returned nil for zero requestsPerHour config") | ||
| } | ||
|
|
||
| // Negative requestsPerMinute must use default (60). | ||
| modNegRPM := factory("rl-neg-rpm", map[string]any{ | ||
| "requestsPerMinute": -10, | ||
| }) | ||
| if modNegRPM == nil { | ||
| t.Fatal("factory returned nil for negative requestsPerMinute config") | ||
| } | ||
|
|
||
| // Zero burstSize must keep default (10). | ||
| modZeroBurst := factory("rl-zero-burst", map[string]any{ | ||
| "requestsPerMinute": 60, | ||
| "burstSize": 0, | ||
| }) | ||
| if modZeroBurst == nil { | ||
| t.Fatal("factory returned nil for zero burstSize config") | ||
| } | ||
| } |
There was a problem hiding this comment.
TestRateLimitMiddlewareFactory_InvalidValues claims to verify fallback/default behavior (negative RPM uses default, zero burst uses default), but it only asserts that the factory returns a non-nil module. This won’t catch regressions. Consider exercising the returned middleware via Process and asserting observable behavior (e.g., burst default allows 10 immediate requests; default RPM affects Retry-After calculation).
| // Wait slightly longer than the time needed to refill one token. | ||
| if m.ratePerMinute <= 0 { | ||
| t.Fatalf("ratePerMinute must be positive, got %f", m.ratePerMinute) | ||
| } | ||
| timePerToken := time.Duration(float64(time.Minute) / m.ratePerMinute) | ||
| time.Sleep(timePerToken + 100*time.Millisecond) | ||
|
|
||
| // After waiting, exactly one additional request should be allowed. |
There was a problem hiding this comment.
TestNewRateLimitMiddlewareWithHourlyRate_FractionalRefill uses a real time.Sleep (~1s+) to wait for refill, which slows the unit test suite and can be flaky under CI load. Since this test is in the module package, it can avoid sleeping by directly adjusting the relevant client’s lastTimestamp backwards (under lock) to simulate elapsed time, then issuing the next request.
| // Wait slightly longer than the time needed to refill one token. | |
| if m.ratePerMinute <= 0 { | |
| t.Fatalf("ratePerMinute must be positive, got %f", m.ratePerMinute) | |
| } | |
| timePerToken := time.Duration(float64(time.Minute) / m.ratePerMinute) | |
| time.Sleep(timePerToken + 100*time.Millisecond) | |
| // After waiting, exactly one additional request should be allowed. | |
| // Simulate waiting slightly longer than the time needed to refill one token | |
| // by adjusting the internal client's lastTimestamp instead of sleeping. | |
| if m.ratePerMinute <= 0 { | |
| t.Fatalf("ratePerMinute must be positive, got %f", m.ratePerMinute) | |
| } | |
| timePerToken := time.Duration(float64(time.Minute) / m.ratePerMinute) | |
| adjustment := timePerToken + 100*time.Millisecond | |
| // Move the client's lastTimestamp backward under lock so that, from the | |
| // rate limiter's perspective, enough time has passed to refill one token. | |
| m.mu.Lock() | |
| for _, client := range m.clients { | |
| client.mu.Lock() | |
| client.lastTimestamp = client.lastTimestamp.Add(-adjustment) | |
| client.mu.Unlock() | |
| } | |
| m.mu.Unlock() | |
| // After simulating the wait, exactly one additional request should be allowed. |
Summary
/api/v1/auth/login(10 req/min) and/api/v1/auth/register(5 req/hour), replacing the shared global 120 req/min limiter that offered minimal protectionRateLimitMiddlewareto support fractional per-minute rates viafloat64token buckets, enabling the hourly-rate config needed for registrationNewRateLimitMiddlewareWithHourlyRateconstructor andrequestsPerHourconfig field to the factory/schema so YAML configs can express low-frequency limits naturallyChanges
admin/config.yamlauth-login-ratelimit: 10 req/min, burst 10 per IPauth-register-ratelimit: 5 req/hour, burst 5 per IPmodule/http_middleware.goclient.tokenschanged frominttofloat64for fractional refill supportRateLimitMiddlewaregainsratePerMinute float64field (used internally)NewRateLimitMiddlewareWithHourlyRate(name, requestsPerHour, burstSize)constructorcleanupStaleClientsupdated to use fractional rate for stale thresholdplugins/http/modules.gorateLimitMiddlewareFactorynow checksrequestsPerHourkey first; falls back torequestsPerMinuteplugins/http/schemas.gorequestsPerHourconfig field tohttp.middleware.ratelimitschemaTest plan
TestNewRateLimitMiddlewareWithHourlyRate_AllowsBurst— burst of 5 then 429TestNewRateLimitMiddlewareWithHourlyRate_PerIP— separate buckets per IPTestNewRateLimitMiddlewareWithHourlyRate_RatePerMinute— correct fractional rateTestRateLimitMiddlewareFactory_RequestsPerHour— factory int and float64 configRateLimit*tests pass (no regressions in token bucket or IP-based limiting)go build ./module/... ./plugins/... ./api/...— clean buildgolangci-lint run— 0 issues on changed packagesCloses #94
🤖 Generated with Claude Code