Skip to content

security: add strict per-IP rate limiting to auth endpoints#101

Merged
intel352 merged 5 commits intomainfrom
fix/auth-rate-limiting
Feb 23, 2026
Merged

security: add strict per-IP rate limiting to auth endpoints#101
intel352 merged 5 commits intomainfrom
fix/auth-rate-limiting

Conversation

@intel352
Copy link
Contributor

Summary

  • Adds dedicated per-IP rate limiters to /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 protection
  • Extends RateLimitMiddleware to support fractional per-minute rates via float64 token buckets, enabling the hourly-rate config needed for registration
  • Adds NewRateLimitMiddlewareWithHourlyRate constructor and requestsPerHour config field to the factory/schema so YAML configs can express low-frequency limits naturally

Changes

admin/config.yaml

  • Add auth-login-ratelimit: 10 req/min, burst 10 per IP
  • Add auth-register-ratelimit: 5 req/hour, burst 5 per IP
  • Update login and register routes to use the dedicated limiters (not the shared 120/min one)

module/http_middleware.go

  • client.tokens changed from int to float64 for fractional refill support
  • RateLimitMiddleware gains ratePerMinute float64 field (used internally)
  • New NewRateLimitMiddlewareWithHourlyRate(name, requestsPerHour, burstSize) constructor
  • cleanupStaleClients updated to use fractional rate for stale threshold

plugins/http/modules.go

  • rateLimitMiddlewareFactory now checks requestsPerHour key first; falls back to requestsPerMinute

plugins/http/schemas.go

  • Adds requestsPerHour config field to http.middleware.ratelimit schema

Test plan

  • TestNewRateLimitMiddlewareWithHourlyRate_AllowsBurst — burst of 5 then 429
  • TestNewRateLimitMiddlewareWithHourlyRate_PerIP — separate buckets per IP
  • TestNewRateLimitMiddlewareWithHourlyRate_RatePerMinute — correct fractional rate
  • TestRateLimitMiddlewareFactory_RequestsPerHour — factory int and float64 config
  • All existing RateLimit* tests pass (no regressions in token bucket or IP-based limiting)
  • go build ./module/... ./plugins/... ./api/... — clean build
  • golangci-lint run — 0 issues on changed packages
  • Pre-push hook passes

Closes #94

🤖 Generated with Claude Code

intel352 and others added 3 commits February 22, 2026 23:23
#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>
Copilot AI review requested due to automatic review settings February 23, 2026 04:38
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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)
}
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
}
}
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 uses AI. Check for mistakes.
@intel352
Copy link
Contributor Author

@copilot open a new pull request to apply changes based on the comments in this thread

Copy link
Contributor

Copilot AI commented Feb 23, 2026

@intel352 I've opened a new pull request, #107, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI and others added 2 commits February 23, 2026 01:21
…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>
Copilot AI review requested due to automatic review settings February 23, 2026 07:08
@intel352 intel352 merged commit 910cffb into main Feb 23, 2026
9 of 14 checks passed
@intel352 intel352 deleted the fix/auth-rate-limiting branch February 23, 2026 07:08
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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

  • rateLimitMiddlewareFactory contains a duplicated/obsolete implementation block after the new parsing logic. This redeclares requestsPerMinute in the same scope (won’t compile) and also unconditionally returns an hourly limiter even when requestsPerHour is 0/negative, which would create a limiter with ratePerMinute=0 (no refill). Remove the old block (lines 170-184) and keep a single code path that (a) only uses requestsPerHour when > 0 and (b) otherwise falls back to validated requestsPerMinute defaults.
	// 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)
}

Comment on lines +359 to +392
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")
}
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.
Comment on lines +670 to +677
// 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.
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: Add rate limiting to auth endpoints (register/login)

3 participants