Deprecate spoofable RealIP rate-limiting; add LimitBy + KeyFromContext and modernize the key API#61
Conversation
…ontext KeyByRealIP (and LimitByRealIP / WithKeyByRealIP built on it) derive the rate-limit key from client-supplied headers with no proxy-trust check, so a remote attacker can forge the key to either evade the limit or lock a victim out (HTTP 429). This is the same class of flaw fixed in chi's middleware.RealIP (GHSA-9g5q-2w5x-hmxf, GHSA-rjr7-jggh-pgcp, GHSA-3fxj-6jh8-hvhx). Deprecate the affected trio and add three router-agnostic building blocks: - LimitBy(n, t, keyFn, opts...) - canonical entry point, key is required. - KeyFromContext(extractor) - read the key from request context; pairs with chi's middleware.GetClientIP fed by a middleware.ClientIPFrom* (chi v5.3.0+). - ComposeKeys(fns...) - positional-arg equivalent of WithKeyFuncs for LimitBy. Also deprecate LimitByIP in favor of LimitBy(n, t, KeyByIP) (ergonomic, not a security change). Bodies are unchanged so existing code keeps compiling. Document the safe pattern and the ClientIPFrom* picker in README and _example. The main module stays chi-free: chi-free unit tests (KeyFromContext, ComposeKeys, KeyByRealIP regression) live in the root module, while the chi integration tests proving the spoof is closed live in the _example submodule (which already depends on chi). CI runs both via an added _example test step. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This comment was marked as outdated.
This comment was marked as outdated.
JoinKeys describes the behavior concretely (joins key parts with ":" separators, like strings.Join) rather than the vaguer "compose". The function is new and unreleased, so this is a safe, no-behavior-change rename. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This comment was marked as outdated.
This comment was marked as outdated.
LimitBy is now the canonical entry point, so deprecate Limit (use
LimitBy(n, t, keyFn, opts...), passing the key explicitly or Key("*") for a
single global bucket) and LimitAll (use LimitBy(n, t, Key("*"))). Bodies are
unchanged behavior-wise, so existing code keeps compiling.
Move the whole deprecated surface (Limit, LimitAll, LimitByIP, LimitByRealIP,
KeyByRealIP, WithKeyByRealIP) into deprecated.go to keep httprate.go focused on
the supported API. Each deprecated wrapper now delegates to LimitBy, mirroring
its documented migration. httprate has not reached a stable v1, so these may be
removed in a future major release.
Update README and _example to use LimitBy instead of the deprecated Limit.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This comment was marked as outdated.
This comment was marked as outdated.
KeyByIP keys off r.RemoteAddr. It is not spoofable, but behind a reverse proxy, load balancer, or CDN that is the proxy's address, so every client behind a proxy shares one bucket — usually the wrong key in production. There is no safe default IP source, so deprecate KeyByIP and WithKeyByIP (a footgun, NOT a security fix, unlike the KeyByRealIP family) and point users at an explicit chi middleware.ClientIPFrom* (v5.3.0+) plus KeyFromContext(GetClientIP). Move both into deprecated.go and repoint LimitByIP's migration note accordingly. Tidy the remaining helpers now that the IP keys are deprecated: - Collapse composedKeyFunc into JoinKeys (its only public equivalent) and have WithKeyFuncs call JoinKeys; drop the redundant private helper. - Move canonicalizeIP to deprecated.go next to its only callers (KeyByIP, KeyByRealIP); httprate.go no longer imports net. Update README and _example to the explicit chi ClientIPFromRemoteAddr + KeyFromContext(GetClientIP) pattern, and note the ClientIPFrom* middlewares are chi's. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…mples The LimitBy and JoinKeys godoc examples showcased JoinKeys(KeyByIP, KeyByEndpoint), but this PR deprecates KeyByIP. Copy-pasting the example produced a deprecation warning and contradicted the PR's own guidance. Switch the multi-dimensional example to the safe, recommended key (KeyFromContext(middleware.GetClientIP)), matching the README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Review — approve ✅
Traced the change end-to-end against the goals in the description; it's correct, behavior-preserving, and well-tested. Notes below.
Security fix is sound
I checked chi v5.3.0's ClientIPFromXFF: it walks X-Forwarded-For right-to-left, skips trusted-CIDR hops, and returns the rightmost non-trusted IP (fail-closed). So in TestLimitByClientIP_SpoofedXFF the trusted proxy appends the attacker's real 203.0.113.5 as the rightmost entry, and the key stays constant no matter what the attacker forges on the left. The test genuinely proves the spoof is closed, not just that the wiring runs.
No behavior change in the deprecated delegations
NewRateLimiter defaults keyFn to Key("*") when none is set, so the rewrites all reduce to the exact same keyFn as before:
Limit(n, t, opts...)→LimitBy(n, t, Key("*"), opts...)— identical, and aWithKeyFuncsinoptsstill overrides (LimitBy prepends its key option).LimitAll→Key("*"),LimitByIP→KeyByIP,LimitByRealIP→KeyByRealIP.JoinKeysis a verbatim rename ofcomposedKeyFunc(trailing-:join format preserved).
go build / go test / go vet / gofmt clean in both modules; CI now runs the _example integration tests too.
One doc fix pushed
The LimitBy and JoinKeys godoc examples showcased JoinKeys(KeyByIP, KeyByEndpoint) — but this PR deprecates KeyByIP, so copy-pasting them tripped a deprecation warning and contradicted the PR's own guidance. Switched the example to KeyFromContext(middleware.GetClientIP) to match the README.
Optional, non-blocking
deprecated.gocanonicalizeIPhas an ineffectivebreakinside aswitch(breaks the switch, not the loop). Harmless — the!isIPv6loop condition terminates correctly — and it's verbatim from the originalhttprate.go, now living in deprecated-only code. Fine to leave; trivial to drop for a clean staticcheck pass.LimitBy+ aWithKeyFuncsin the trailing options silently lets the option override the positional key. Reasonable (mirrorsLimit), just worth being aware of.
Nice, precise cleanup of the key API. 👍
Automated by Claude Opus 4.8.
Benchmark Results |
chi's middleware.GetClientIP returns the full client IP, so the recommended KeyFromContext(middleware.GetClientIP) path lost the IPv6 /64 normalization the deprecated KeyByIP/KeyByRealIP used to do. An IPv6 client owns a whole /64 (2^64 addresses via SLAAC) and could rotate within it to win a fresh rate-limit bucket per request, bypassing the per-IP limit. Rather than grow httprate's API or pull chi into the root module, demonstrate the fix where it belongs — at the rate-limiter layer — using chi's typed middleware.GetClientIPAddr (net/netip.Addr) and stdlib /64 masking: - _example: add clientIPKey helper (IPv4 as-is, IPv6 -> /64) and use it on both the directly-exposed and behind-a-proxy routes; add TestLimitByClientIP_IPv6Bucket. - README: show the clientIPKey helper in the client-IP section, explain the /64 rationale and how to widen the prefix; flag the caveat in the quickstart. chi keeps returning the exact client IP (right for logging/audit); the /64 bucketing is a rate-limit policy choice and stays explicit at the call site. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A KeyFunc already receives *http.Request and can read r.Context() directly, so the client-IP key is just an inline anonymous KeyFunc — no KeyFromContext wrapper and no package-level helper. Matches the existing inline key func on the /admin route. Tests share one KeyFunc mirroring main.go. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A KeyFunc is func(*http.Request) (string, error) — it already receives the
request, so it can read r.Context() directly. KeyFromContext was therefore
redundant sugar whose only terse use, KeyFromContext(middleware.GetClientIP),
was the IPv6-bypass footgun (full address, no /64 bucketing). Remove it.
Instead export CanonicalizeIP(ip string) string — the /64 canonicalization the
deprecated KeyByIP/KeyByRealIP did internally — as pure-stdlib supported API.
httprate stays router-agnostic and chi-free; the caller resolves the client IP
(e.g. chi's middleware.GetClientIP) and the KeyFunc reads it back:
r.Use(httprate.LimitBy(100, time.Minute, func(r *http.Request) (string, error) {
return httprate.CanonicalizeIP(middleware.GetClientIP(r.Context())), nil
}))
- httprate.go: add CanonicalizeIP, drop KeyFromContext + the context import;
update LimitBy/JoinKeys doc examples.
- deprecated.go: KeyByIP/KeyByRealIP call CanonicalizeIP; migration docs show
the inline KeyFunc + CanonicalizeIP pattern.
- tests: rename Test_canonicalizeIP -> TestCanonicalizeIP; rewrite the context
KeyFunc tests as plain KeyFuncs; example uses the one-liner.
- README/_example: inline KeyFunc + CanonicalizeIP throughout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Benchmark Results |
Summary
KeyByRealIP— andLimitByRealIP/WithKeyByRealIPbuilt on it — derive the rate-limit key from client-supplied headers (True-Client-IP,X-Real-IP,X-Forwarded-For) with no proxy-trust check. A remote attacker can forge the key to either:This is the same class of flaw fixed in chi's
middleware.RealIPin v5.3.0 (GHSA-9g5q-2w5x-hmxf, GHSA-rjr7-jggh-pgcp, GHSA-3fxj-6jh8-hvhx).This PR closes the spoofing hole and modernizes the key API around a single, explicit-trust pattern. All deprecated functions keep compiling (doc-only
// Deprecated:). httprate has not reached a stable v1, so the deprecated surface may be removed in a future release.New (supported) API
LimitBy(requestLimit, windowLength, keyFn, opts...)— canonical entry point; the key is a required positional argument, so every call site states what it limits by.KeyFromContext(func(context.Context) string)— reads the key from the request context. Pairs with chi'smiddleware.GetClientIP(chi v5.3.0+), fed by one of themiddleware.ClientIPFrom*middlewares. Generic over the extractor, so non-chi frameworks work too.JoinKeys(...KeyFunc)—:-joined multi-dimensional key forLimitBy(the positional-arg equivalent ofWithKeyFuncs).Recommended pattern (rate-limit by trusted client IP)
There is no safe default IP source — you state your trust model explicitly. If no
ClientIPFrom*runs upstream,GetClientIPreturns""and all requests share one global bucket (strictly more restrictive — a footgun, not a security regression).Deprecations
Security (spoofable — the GHSA):
KeyByRealIP,WithKeyByRealIP,LimitByRealIPFootguns / ergonomics (NOT security):
KeyByIP/WithKeyByIP— key offr.RemoteAddr. Not spoofable, but behind a reverse proxy / LB / CDN that's the proxy's address, so every client behind a proxy shares one bucket — usually the wrong key in production. Usemiddleware.ClientIPFromRemoteAddr+KeyFromContext(GetClientIP)for the directly-exposed case.LimitByIP— superseded by the above.Limit→LimitBy(n, t, keyFn, opts...);LimitAll→LimitBy(n, t, Key("*")).The whole deprecated surface is grouped in
deprecated.go.Limit/LimitAll/LimitByIP/LimitByRealIPnow delegate toLimitBy, mirroring their documented migration.Docs / examples / tests / CI
ClientIPFrom*picker; all examples moved toLimitBy._example: runnable demo of both the directly-exposed and behind-a-proxy patterns; chi bumpedv5.1.0→v5.3.0.KeyFromContext,JoinKeys, and aKeyByRealIPspoof-regression); the chi integration tests that prove the spoof is closed live in the_examplesubmodule. The main module'sgo.modstays chi-free; CI runs both modules' tests.Test plan
go test ./...passes in the root module.cd _example && go test ./...passes (chi integration: RemoteAddr happy path, spoofed-XFF fix-in-action, misconfig single-bucket).go vet+gofmtclean in both modules.Credits
Cross-references the chi advisories above; thanks to @convto, @rezmoss, and @Saku0512 for the original reports.
Fixes #53