fix(ratelimiter): atomic fixed-window counting and explicit client-IP trust - #155
Open
devin-ai-integration[bot] wants to merge 2 commits into
Open
fix(ratelimiter): atomic fixed-window counting and explicit client-IP trust#155devin-ai-integration[bot] wants to merge 2 commits into
devin-ai-integration[bot] wants to merge 2 commits into
Conversation
chi v1 is deprecated and unmaintained; v5.3.2 ships the ClientIPFrom* middlewares that replace the spoofable RealIP.
… trust Allow now checks and increments under one lock, so concurrent requests cannot overshoot the limit. Windows reset lazily and expired keys are swept once per window instead of spawning a goroutine per key. middleware.RealIP trusted X-Real-IP / X-Forwarded-For from any client, letting anonymous callers mint a fresh IP bucket per request. The client address now comes from CLIENT_IP_HEADER (default CF-Connecting-IP) or CLIENT_IP_TRUSTED_PROXIES hops into X-Forwarded-For, falling back to the TCP peer. Retry-After is sent in whole seconds.
Contributor
Author
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two rate-limiter weaknesses, both cheap to close:
1.
FixedWindowLimiter.Allowwas not atomic. It read the count underRLock, released, then incremented underLockwithout re-checking, so N concurrent requests at the boundary all passed. It also spawned ago resetCount(key)sleeping goroutine per new key.func (rl *FixedWindowLimiter) Allow(key string) (bool, time.Duration) { - rl.RLock(); count, exists := rl.clients[key]; rl.RUnlock() - if !exists || count < rl.limit { - rl.Lock(); if !exists { go rl.resetCount(key) }; rl.clients[key]++; rl.Unlock() - return true, 0 - } - return false, rl.window + rl.mu.Lock(); defer rl.mu.Unlock() + if !now.Before(rl.nextSweep) { rl.sweep(now) } // O(n), at most once per window + b := rl.clients[key]; if b == nil || expired(b) { b = &bucket{resetAt: now.Add(rl.window)}; rl.clients[key] = b } + if b.count >= rl.limit { return false, b.resetAt.Sub(now) } + b.count++; return true, 0 }Check-and-increment now happen under one lock; windows reset lazily; expired keys are swept in one pass per window instead of one goroutine per key.
Retry-Afteris now the time actually left in the window, sent as whole seconds (Retry-After: 3instead of the non-standard5s).2.
middleware.RealIPtrusted forwarded headers from anyone. It rewroteRemoteAddrfromX-Real-IP/ firstX-Forwarded-Forentry with no notion of a trusted proxy, so an unauthenticated client could send a differentX-Real-IPper request and get a fresh 200-req bucket every time (plus a goroutine per request, see #1). chi has since deprecatedRealIPfor exactly this reason (GHSA-3fxj-6jh8-hvhx et al.).Replaced with chi v5.3.2's
ClientIPFrom*middlewares, selected by config inclientIPMiddleware():CLIENT_IP_HEADERCF-Connecting-IPClientIPFromHeader— single-IP header the edge proxy overwrites on every request (Cloudflare)CLIENT_IP_TRUSTED_PROXIES0ClientIPFromXFFTrustedProxies(n)— the XFF entrynhops from the rightClientIPFromRemoteAddr— TCP peerclientIP()readsmiddleware.GetClientIP(ctx)and falls back to theRemoteAddrhost, so a request with no usable header still gets a bucket rather than an empty key. No other forwarded header is ever consulted.The default matches harp's own Cloudflare → Cloud Run deployment. This assumes the origin is only reachable through Cloudflare (finding #3 from the audit — Cloud Run ingress should be restricted, or Cloudflare's IP list enforced); a client that can hit the
run.appURL directly can setCF-Connecting-IPitself, same as before withX-Real-IP. Adopters without Cloudflare setCLIENT_IP_HEADER=andCLIENT_IP_TRUSTED_PROXIES=1(or leave both default-off).Also in this PR
github.com/go-chi/chiv1.5.5 →github.com/go-chi/chi/v5v5.3.2 (separate commit; import-path change only). v1 is deprecated/unmaintained and lacks theClientIPFrom*middlewares. This also clears the onegovulncheckfinding left after chore(go): upgrade toolchain to Go 1.27 and bump vulnerable dependencies #154 (GO-2026-4316,RedirectSlashes, unused by harp).RealIPmutatedr.RemoteAddr, so chi's request logger printed the forwarded IP. It now prints the TCP peer (the proxy). Usemiddleware.GetClientIP(r.Context())where the client address is needed..env.exampleandclaude.mddocument the new knobs.Not in scope
Cross-instance (shared) rate limiting — each Cloud Run instance still has its own counters. Not worth it for a hackathon; Cloudflare handles volumetric traffic.
Verification
gofmt -l .,go vet ./...,staticcheck ./...cleango test -race ./cmd/api/ ./internal/...passes;internal/ratelimiterrun with-count=20 -raceX-Real-IP/X-Forwarded-For/True-Client-IP/CF-Connecting-IPnever mint a new bucket by default, that the configured header is honoured, and that XFF is only read past the trusted hop count.Related: #153 (push endpoint), #154 (Go 1.27 + deps).
Link to Devin session: https://app.devin.ai/sessions/7730c9bccc574233a0c2650ba204bec2
Open in Devin Desktop: https://app.devin.ai/desktop/session/7730c9bccc574233a0c2650ba204bec2?variant=devin
Requested by: @balebbae