feat: add per-IP rate limiting for global and auth endpoints#248
feat: add per-IP rate limiting for global and auth endpoints#248
Conversation
… 1.2) Add brute-force protection via token-bucket rate limiting with per-IP tracking and LRU eviction. Two rate limiter instances in generated apps: - Global (100 rps, burst 200): wraps the entire mux, prevents general abuse - Auth (0.1 rps, burst 5): wraps auth handlers, prevents credential stuffing Key implementation details: - pkg/ratelimit/: reusable library with functional options API, both single-mutex and sharded (16-way) implementations, configurable deny handler, sweep/stale thresholds, and proper Close() lifecycle - main.go.tmpl: inline simplified rate limiter using golang.org/x/time/rate directly (template can't import unreleased pkg/ratelimit, see #247) - handler.go.tmpl: auth handler factories accept authRL middleware param; withMiddleware() helper eliminates repeated nil-check boilerplate - Hybrid deny handler: redirects browsers to /auth?error=rate_limited, returns JSON 429 for API clients (content negotiation via Accept header) - All rates configurable via env vars (RATE_LIMIT_RPS, RATE_LIMIT_BURST, RATE_LIMIT_AUTH_RPS, RATE_LIMIT_AUTH_BURST, RATE_LIMIT_MAX_IPS) - getClientIP() trusts X-Forwarded-For/X-Real-IP only from loopback/private peers, normalizes proxy IPs to prevent duplicate map keys Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code ReviewOverall this is a solid implementation with well-structured code, security-conscious IP extraction, comprehensive test coverage, and good use of functional options. Bugs / CorrectnessDouble appCancel() call in main.go.tmpl appCancel is both deferred and called explicitly before graceful shutdown. This is safe (CancelFunc is idempotent) but slightly confusing. Consider relying on just the defer. Compile-time shard size assertion - one-sided guard The assertion fires if shard grows past 128 bytes, but passes silently if it shrinks below 128 bytes (padding becomes a no-op). Fine for the current layout; worth noting if the struct changes. SecuritygetClientIP returns raw string when net.ParseIP fails In both main.go.tmpl and pkg/ratelimit/ratelimit.go, when a proxy header contains a malformed value (hostname, ip:port, etc.), net.ParseIP returns nil and the raw trimmed string becomes the LRU map key. This does not create a security hole (a spoofed key just gives the attacker their own bucket), but arbitrary strings from untrusted headers can accumulate in the map. Falling back to the r.RemoteAddr host on parse failure would be safer. Design and MaintainabilityinjectAuthRateLimiter anchor is fragile The insertion point is found via strings.Index(mainContent, "authRL)"). Any prior occurrence of "authRL)" in the file - a comment, a future call - would inject code at the wrong location. A more specific anchor like the full "auth.Handler(queries, authRL)" string would reduce the blast radius. The idempotency check also matches "authRL=" without a space, which could collide with an unrelated identifier. Inline duplication between main.go.tmpl and pkg/ratelimit Acknowledged in the PR as blocked on issue 247 - fine to merge as-is. The inline version omits eviction logging and slog integration present in the package; if it stays around longer than expected those become observable gaps in generated apps. Nits
SummaryThe malformed XFF fallback and injection anchor fragility are worth addressing before merging. Core rate limiting logic, IP extraction security, sharding implementation, and pkg/ratelimit test coverage are all solid work. |
There was a problem hiding this comment.
Pull request overview
Adds per-IP token-bucket rate limiting to generated apps (global + auth endpoints) and introduces a reusable pkg/ratelimit middleware library to support brute-force protection and general abuse mitigation.
Changes:
- Added
pkg/ratelimitlibrary with LRU eviction, optional sharded implementation, configurable options, and comprehensive tests. - Updated the multi-kit
main.gotemplate to wrap the server with a global per-IP rate limiter and added inline helper functions (env parsing, IP extraction, LRU limiter). - Updated auth generation/templates to support an optional
authRLmiddleware and improved route injection to detect handler calls that take(queries, ...).
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/ratelimit/ratelimit.go | New rate limiting middleware implementation (single-mutex + sharded) with LRU eviction, cleanup goroutine, and client IP extraction. |
| pkg/ratelimit/ratelimit_test.go | Test suite covering limiting behavior, eviction, cleanup, concurrency, deny handler, and GetClientIP. |
| internal/kits/system/multi/templates/auth/handler.go.tmpl | Auth handlers accept optional middleware to apply auth-specific rate limiting. |
| internal/kits/system/multi/templates/app/main.go.tmpl | Adds global rate limiter + inline limiter implementation and env helpers to generated multi-kit apps. |
| internal/generator/route_injector.go | Treats handler calls containing (queries, as requiring DB queries initialization. |
| internal/generator/auth_test.go | Updates auth-related fixtures to match new handler signatures using authRL. |
| internal/generator/auth.go | Injects auth routes with authRL and adds logic to inject an auth rate limiter block into main.go. |
| commands/env.go | Documents RATE_LIMIT_* env vars in .env.example output and adds reason strings. |
internal/generator/auth.go
Outdated
| // Check if already injected (look for the variable declaration, not just any usage) | ||
| if strings.Contains(mainContent, "authRL :=") || strings.Contains(mainContent, "authRL=") { | ||
| return nil | ||
| } |
pkg/ratelimit/ratelimit.go
Outdated
| _ [64]byte // pad to 128 bytes (2 cache lines) | ||
| } | ||
|
|
||
| // Compile-time assertion: shard struct must be exactly 128 bytes. |
| // GetClientIP extracts the client IP from the request. | ||
| // It only trusts X-Forwarded-For / X-Real-IP when the immediate peer is a | ||
| // loopback or private address (i.e., behind a reverse proxy). | ||
| func GetClientIP(r *http.Request) string { | ||
| host, _, err := net.SplitHostPort(r.RemoteAddr) | ||
| if err != nil { | ||
| host = r.RemoteAddr | ||
| } | ||
|
|
||
| peerIP := net.ParseIP(host) | ||
| trustedProxy := peerIP != nil && (peerIP.IsLoopback() || peerIP.IsPrivate()) | ||
|
|
||
| if trustedProxy { | ||
| if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | ||
| clientIP := xff | ||
| if i := strings.IndexByte(xff, ','); i > 0 { | ||
| clientIP = xff[:i] | ||
| } | ||
| if ip := net.ParseIP(strings.TrimSpace(clientIP)); ip != nil { | ||
| return ip.String() | ||
| } | ||
| return strings.TrimSpace(clientIP) | ||
| } | ||
| if xri := r.Header.Get("X-Real-IP"); xri != "" { | ||
| if ip := net.ParseIP(strings.TrimSpace(xri)); ip != nil { | ||
| return ip.String() | ||
| } | ||
| return strings.TrimSpace(xri) | ||
| } | ||
| } |
| // New creates a RateLimiter that runs a background cleanup goroutine. | ||
| // The goroutine is stopped when Close is called or ctx is cancelled. | ||
| // | ||
| // For MaxIPs >= 16, a sharded implementation is used to reduce lock | ||
| // contention. For smaller values, a single-mutex implementation is used. | ||
| func New(ctx context.Context, opts ...Option) *RateLimiter { | ||
| cfg := defaultConfig() | ||
| for _, opt := range opts { | ||
| opt(&cfg) | ||
| } | ||
| if cfg.DenyHandler == nil { | ||
| cfg.DenyHandler = defaultDenyHandler | ||
| } | ||
| if cfg.MaxIPs <= 0 { | ||
| cfg.MaxIPs = 10000 | ||
| } | ||
|
|
||
| ctx, cancel := context.WithCancel(ctx) | ||
|
|
||
| rl := &RateLimiter{cancel: cancel} | ||
|
|
||
| if cfg.MaxIPs < defaultNumShards { | ||
| rl.mw, rl.done = newSingleMutexLimiter(ctx, &cfg) | ||
| } else { | ||
| rl.mw, rl.done = newShardedLimiter(ctx, &cfg, defaultNumShards) | ||
| } |
| w.Header().Set("Retry-After", "1") | ||
| http.Error(w, "Too Many Requests", http.StatusTooManyRequests) | ||
| } | ||
| } |
- Remove double appCancel() — rely on defer (idempotent but confusing) - Fall back to peer IP when proxy headers contain malformed values instead of using raw untrusted strings as map keys - Use regex for injectAuthRateLimiter idempotency check and anchor (matches `\bauthRL\s*:?=` and `auth.\w+Handler(queries, authRL)`) - Validate burst >= 1, rps >= 0, SweepInterval/StaleThreshold > 0 in both pkg/ratelimit and inline rate limiter to prevent panics/lockouts - Fix shard size assertion comment: "must not exceed" vs "must be exactly" - Fix gofmt formatting in commands/env.go Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review: Per-IP Rate LimitingOverall this is a solid implementation -- good security design, thoughtful IP detection, and strong test coverage in Bugs / Correctness1. Auth rate-limiter injection may silently no-op
2. If an operator sets 3.
Security4. XFF header split uses A value like if i := strings.IndexByte(xff, ','); i > 0 { // should be i >= 0 5. Duplication between Flagging subtle differences that may drift over time:
6. const _ = uint(128 - unsafe.Sizeof(shard{})) This produces a confusing "constant overflows uint" error if the struct grows beyond 128 bytes. A comment explaining the intent (two 64-byte cache lines on x86) and the architecture assumption would help future contributors. 7.
Missing Tests8. No unit tests for This is the most complex injection logic in the PR (regex matching, string splicing, idempotency check). A focused unit test with a known 9. No tests for Specifically: invalid strings, zero values, negative values, and Minor
What's Good
Generated with Claude Code |
- Fix XFF header split: i > 0 → i >= 0 to handle leading comma edge case - Add slog.Warn for invalid env var values in getEnvFloat/getEnvInt so misconfiguration is visible in production logs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review: Per-IP Rate Limiting (#248)This is well-structured work — functional options API is clean, IP extraction security (only trusting XFF from private/loopback peers) is correct, and the test suite is comprehensive with race-detector coverage. A few issues worth resolving before merging. Bugs / Correctness1. In 2. authRLDeclRe := regexp.MustCompile(`\bauthRL\s*:?=`)This would match 3. Missing
Security4. At 5. No RFC 6585 and common API conventions expect these headers so well-behaved clients can implement backoff without hammering the 429 wall. Low-effort win for API consumers. Design6. Global and auth limiters share Both 7. This is the riskiest code in the PR — regex-based string surgery with an idempotency check. The integration test catches gross failures but a unit test with a known Nits
What's solid
The blocking items are #3 (missing Generated with Claude Code |
- Add slog.Warn when burst < 1 or rps < 0 is clamped, so operators see feedback instead of silent default substitution - Tighten injectAuthRateLimiter idempotency regex to match the specific declaration `authRL\s*:=\s*newRateLimiter` to avoid false positives from identifiers like `authRLConfig` Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review: feat: add per-IP rate limiting for global and auth endpointsOverall this is a well-structured addition with solid test coverage. The design is sound — token bucket per IP, LRU eviction, trusted-proxy detection, configurable via env vars. A few things worth discussing. Design: Dual ImplementationThe PR ships two separate rate limiter implementations: a full-featured This is intentional per the PR description (follow-up in #247), but it creates a maintenance risk. At minimum, add a Fragile: Regex-based injection in
|
…ndler - Add TODO(#247) comment in main.go.tmpl marking inline rate limiter as debt to be replaced with pkg/ratelimit after next release - Document trusted-proxy assumption in getClientIP (both inline and lib): correct behind single reverse proxy, not for multi-tenant private nets - Exclude ConfirmEmailHandler from auth rate limiting — single-use tokens from a known inbox don't need brute-force protection, and shared NAT IPs could block legitimate users clicking confirmation links Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review: Per-IP Rate Limiting (#248)Overall this is a well-designed and thorough implementation. The dual-limiter architecture, LRU eviction, sharded locking for high-concurrency, and the proxy-aware IP extraction are all solid choices. Test coverage is comprehensive. A few things worth addressing: Security
When
There is no way to independently tune the IP pool for auth vs. global endpoints. Auth endpoints should likely track far fewer IPs (attackers tend to be a smaller set), so a separate Code Quality
This map is only consulted for required vars, and rate-limit vars are never in Regex injection in The approach works and the TODO tracking migration to PerformanceCleanup goroutine holds the single mutex while scanning all 10k entries ( Acceptable at the expected scale of generated apps, but worth a brief comment acknowledging the tradeoff vs. the sharded version. Cache-line padding targets 128 bytes (2 cache lines) per shard Cache lines on x86 are 64 bytes, so 128 bytes is 2x more memory than needed to prevent false sharing. A single 64-byte alignment is sufficient for the goal. The comment says "pad to 128 bytes (2 cache lines)" without explaining the choice. TestingTiming-dependent tests may be flaky under CI load ( 400ms sleep with 50ms sweep / 100ms stale threshold is a reasonable buffer, but heavily loaded CI runners could miss it. Consider Minor Nits
Summary
The Generated with Claude Code |
- Warn when RPS=0 (blocks all traffic after burst exhaustion) in both pkg/ratelimit and inline rate limiter — prevents silent self-DDOS - Acknowledge w.Write return value in defaultDenyHandler to silence linters - Remove dead rate limit entries from getVarReason map (only consulted for required vars; rate limit vars are optional) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review: Per-IP Rate Limiting (#248)Overall this is a solid, well-structured addition. The Bugs / Correctness1. Sharding threshold mismatch In // current — triggers sharding at MaxIPs=16, giving 1 IP per shard
if cfg.MaxIPs < defaultNumShards {
rl.mw, rl.done = newSingleMutexLimiter(ctx, &cfg)
} else {
rl.mw, rl.done = newShardedLimiter(ctx, &cfg, defaultNumShards)
}2. The function is called with a non-fatal error path: if err := injectAuthRateLimiter(mainGoPath); err != nil {
fmt.Printf("⚠️ Could not inject auth rate limiter: %v\n", err)
// continues...
}But the auth handlers already reference 3. A negative value in env (e.g. Security4. The proxy-trust logic ( 5. For the global 100 rps / burst 200 limiter, a legitimate client that exhausts its burst tokens will be told to retry after 1 second — accurate. But for the auth limiter (0.1 rps / burst 5), the Code Quality6. const _ = uint(128 - unsafe.Sizeof(shard{}))This produces a confusing compile error like 7. FNV-1 reimplemented manually in func (sl *shardedLimiter) shardFor(ip string) *shard {
h := uint32(2166136261)
for i := 0; i < len(ip); i++ { ... }
}
8. Implicit coupling between injected code and template
Tests9. These use
10. No test for The function has an idempotency guard ( 11. No IPv6 test for The XFF parsing code calls Minor
Summary: Issues 2 (silent failure leaving broken main.go) and 1 (sharding threshold) are the most important to fix before merge. The rest are improvements or low-severity observations. The core implementation — token bucket, LRU eviction, GetClientIP proxy logic — looks correct and the test suite is thorough. 🤖 Generated with Claude Code |
- Raise sharding threshold from MaxIPs >= 16 to >= 64 (4 IPs per shard minimum) to avoid single-IP shards that cause constant evictions - Make injectAuthRateLimiter a hard error when routes were injected (authRL references exist) but skip when no routes were injected (test fixtures without TODO markers) - Warn on RPS=0 in pkg/ratelimit (blocks all traffic after burst) - Fix w.Write return value in defaultDenyHandler Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review: Per-IP Rate Limiting (#248)Several reviews have already been posted with detailed findings. Rather than repeating those, this is a synthesis focused on the items I consider must-fix before merging and a few additional observations not yet raised. Blocking:
|
Detect whether main.go was generated with rate limiting support before injecting authRL references. Apps generated before this PR won't have newRateLimiter/appCtx — auth handler calls fall back to the old signature (queries only) so the generated code still compiles. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review: feat: add per-IP rate limiting for global and auth endpointsOverall this is a solid, well-thought-out feature. The design is clean, the test coverage is thorough, and the security decisions are well-documented. A few things worth discussing below. Security
XFF leftmost-IP extraction — Taking Double rate limiting — Auth endpoints go through both the global limiter (100 rps) and the per-handler auth limiter (0.1 rps). The auth limiter is the binding constraint, so this is fine and actually provides defense-in-depth. Worth a comment in the generated Potential BugsPartial injection + undefined
PerformanceSingle mutex in generated Cleanup goroutine sweeps stale entries back-to-front — For the sharded sweeper, shards are swept sequentially (lock one, sweep, release, next). This is fine for a 5-minute ticker. If Code Quality
Minor Nits
Test CoverageStrong overall — LRU eviction, stale cleanup, concurrency (race detector), custom deny handlers, Missing test: Summary: Ready to merge with optional follow-ups. The double-rate-limiting comment in generated code, the partial-injection edge case, and a test for 🤖 Generated with Claude Code |
Summary
pkg/ratelimit/library with functional options API, single-mutex + sharded implementations, and comprehensive testsmain.go(can't import unreleasedpkg/ratelimit, follow-up in Switch rate limiting template from inline to pkg/ratelimit/ #247)RATE_LIMIT_RPS,RATE_LIMIT_AUTH_RPS, etc.)Test plan
pkg/ratelimit/— 20 tests covering LRU eviction, stale cleanup, concurrency, custom deny handlers,GetClientIP(race detector enabled)internal/generator/— updated auth test fixtures for new handler signaturesTestGeneratedAppFullFlow) — generates full app with auth, builds, and runs tests-race🤖 Generated with Claude Code