Skip to content

fix(ratelimiter): atomic fixed-window counting and explicit client-IP trust - #155

Open
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1788456683-rate-limiter-hardening
Open

fix(ratelimiter): atomic fixed-window counting and explicit client-IP trust#155
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1788456683-rate-limiter-hardening

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Summary

Two rate-limiter weaknesses, both cheap to close:

1. FixedWindowLimiter.Allow was not atomic. It read the count under RLock, released, then incremented under Lock without re-checking, so N concurrent requests at the boundary all passed. It also spawned a go 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-After is now the time actually left in the window, sent as whole seconds (Retry-After: 3 instead of the non-standard 5s).

2. middleware.RealIP trusted forwarded headers from anyone. It rewrote RemoteAddr from X-Real-IP / first X-Forwarded-For entry with no notion of a trusted proxy, so an unauthenticated client could send a different X-Real-IP per request and get a fresh 200-req bucket every time (plus a goroutine per request, see #1). chi has since deprecated RealIP for exactly this reason (GHSA-3fxj-6jh8-hvhx et al.).

Replaced with chi v5.3.2's ClientIPFrom* middlewares, selected by config in clientIPMiddleware():

env default behaviour
CLIENT_IP_HEADER CF-Connecting-IP ClientIPFromHeader — single-IP header the edge proxy overwrites on every request (Cloudflare)
CLIENT_IP_TRUSTED_PROXIES 0 if header is empty and this is > 0: ClientIPFromXFFTrustedProxies(n) — the XFF entry n hops from the right
neither ClientIPFromRemoteAddr — TCP peer

clientIP() reads middleware.GetClientIP(ctx) and falls back to the RemoteAddr host, 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.app URL directly can set CF-Connecting-IP itself, same as before with X-Real-IP. Adopters without Cloudflare set CLIENT_IP_HEADER= and CLIENT_IP_TRUSTED_PROXIES=1 (or leave both default-off).

Also in this PR

  • github.com/go-chi/chi v1.5.5 → github.com/go-chi/chi/v5 v5.3.2 (separate commit; import-path change only). v1 is deprecated/unmaintained and lacks the ClientIPFrom* middlewares. This also clears the one govulncheck finding left after chore(go): upgrade toolchain to Go 1.27 and bump vulnerable dependencies #154 (GO-2026-4316, RedirectSlashes, unused by harp).
  • Behavioural note: RealIP mutated r.RemoteAddr, so chi's request logger printed the forwarded IP. It now prints the TCP peer (the proxy). Use middleware.GetClientIP(r.Context()) where the client address is needed.
  • .env.example and claude.md document 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 ./... clean
  • go test -race ./cmd/api/ ./internal/... passes; internal/ratelimiter run with -count=20 -race
  • New tests: exact-limit under 32 concurrent goroutines, retry-after = remaining window, lazy reset, sweep; middleware tests that forged X-Real-IP / X-Forwarded-For / True-Client-IP / CF-Connecting-IP never 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

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.
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

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.

1 participant