Skip to content

Deprecate spoofable RealIP rate-limiting; add LimitBy + KeyFromContext and modernize the key API#61

Merged
VojtechVitek merged 8 commits into
masterfrom
deprecate-spoofable-realip-rate-limiting
Jun 29, 2026
Merged

Deprecate spoofable RealIP rate-limiting; add LimitBy + KeyFromContext and modernize the key API#61
VojtechVitek merged 8 commits into
masterfrom
deprecate-spoofable-realip-rate-limiting

Conversation

@VojtechVitek

@VojtechVitek VojtechVitek commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

KeyByRealIP — and LimitByRealIP / WithKeyByRealIP built 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:

  • Evade the limit — rotate the spoofed header → unbounded buckets, limit defeated; or
  • Lock out a victim — pin the header to the victim's IP → exhaust their bucket and 429 them out.

This is the same class of flaw fixed in chi's middleware.RealIP in 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's middleware.GetClientIP (chi v5.3.0+), fed by one of the middleware.ClientIPFrom* middlewares. Generic over the extractor, so non-chi frameworks work too.
  • JoinKeys(...KeyFunc):-joined multi-dimensional key for LimitBy (the positional-arg equivalent of WithKeyFuncs).

Recommended pattern (rate-limit by trusted client IP)

// Pick exactly ONE chi ClientIPFrom* matching your deployment:
r.Use(middleware.ClientIPFromXFF("10.0.0.0/8"))
// Then rate-limit by the trusted client IP:
r.Use(httprate.LimitBy(100, time.Minute, httprate.KeyFromContext(middleware.GetClientIP)))

There is no safe default IP source — you state your trust model explicitly. If no ClientIPFrom* runs upstream, GetClientIP returns "" and all requests share one global bucket (strictly more restrictive — a footgun, not a security regression).

Deprecations

Security (spoofable — the GHSA):

  • KeyByRealIP, WithKeyByRealIP, LimitByRealIP

Footguns / ergonomics (NOT security):

  • KeyByIP / WithKeyByIP — key off r.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. Use middleware.ClientIPFromRemoteAddr + KeyFromContext(GetClientIP) for the directly-exposed case.
  • LimitByIP — superseded by the above.
  • LimitLimitBy(n, t, keyFn, opts...); LimitAllLimitBy(n, t, Key("*")).

The whole deprecated surface is grouped in deprecated.go. Limit/LimitAll/LimitByIP/LimitByRealIP now delegate to LimitBy, mirroring their documented migration.

Docs / examples / tests / CI

  • README: security banner with GHSA links; a "Rate limit by client IP behind a proxy" section with the chi ClientIPFrom* picker; all examples moved to LimitBy.
  • _example: runnable demo of both the directly-exposed and behind-a-proxy patterns; chi bumped v5.1.0v5.3.0.
  • Tests: chi-free unit tests in the root module (KeyFromContext, JoinKeys, and a KeyByRealIP spoof-regression); the chi integration tests that prove the spoof is closed live in the _example submodule. The main module's go.mod stays 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 + gofmt clean in both modules.

Credits

Cross-references the chi advisories above; thanks to @convto, @rezmoss, and @Saku0512 for the original reports.

Fixes #53

…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>
@github-actions

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>
@github-actions

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>
@github-actions

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>
@VojtechVitek VojtechVitek changed the title Deprecate spoofable RealIP rate-limiting; add safe LimitBy + KeyFromContext Deprecate spoofable RealIP rate-limiting; add LimitBy + KeyFromContext and modernize the key API Jun 26, 2026
…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>

@VojtechVitek VojtechVitek left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 a WithKeyFuncs in opts still overrides (LimitBy prepends its key option).
  • LimitAllKey("*"), LimitByIPKeyByIP, LimitByRealIPKeyByRealIP.
  • JoinKeys is a verbatim rename of composedKeyFunc (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.go canonicalizeIP has an ineffective break inside a switch (breaks the switch, not the loop). Harmless — the !isIPv6 loop condition terminates correctly — and it's verbatim from the original httprate.go, now living in deprecated-only code. Fine to leave; trivial to drop for a clean staticcheck pass.
  • LimitBy + a WithKeyFuncs in the trailing options silently lets the option override the positional key. Reasonable (mirrors Limit), just worth being aware of.

Nice, precise cleanup of the key API. 👍


Automated by Claude Opus 4.8.

@github-actions

Copy link
Copy Markdown

Benchmark Results

goos: linux
goarch: amd64
pkg: github.com/go-chi/httprate
cpu: AMD EPYC 7763 64-Core Processor                
               │ master.txt  │            pr.txt             │
               │   sec/op    │   sec/op     vs base          │
LocalCounter-4   20.05m ± 2%   19.95m ± 1%  ~ (p=0.143 n=10)

               │  master.txt  │             pr.txt             │
               │     B/op     │     B/op      vs base          │
LocalCounter-4   2.850Mi ± 0%   2.847Mi ± 0%  ~ (p=0.529 n=10)

               │ master.txt  │            pr.txt             │
               │  allocs/op  │  allocs/op   vs base          │
LocalCounter-4   121.5k ± 0%   121.5k ± 0%  ~ (p=0.542 n=10)

@Max12345-ally Max12345-ally left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM
see some thoughts

Comment thread deprecated.go
Comment thread httprate.go Outdated
Comment thread httprate.go
VojtechVitek and others added 3 commits June 29, 2026 15:09
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>
@github-actions

Copy link
Copy Markdown

Benchmark Results

goos: linux
goarch: amd64
pkg: github.com/go-chi/httprate
cpu: AMD EPYC 9V74 80-Core Processor                
               │ master.txt  │            pr.txt             │
               │   sec/op    │   sec/op     vs base          │
LocalCounter-4   15.82m ± 1%   15.72m ± 2%  ~ (p=0.247 n=10)

               │  master.txt  │             pr.txt             │
               │     B/op     │     B/op      vs base          │
LocalCounter-4   2.842Mi ± 0%   2.842Mi ± 0%  ~ (p=0.684 n=10)

               │ master.txt  │            pr.txt             │
               │  allocs/op  │  allocs/op   vs base          │
LocalCounter-4   121.4k ± 0%   121.5k ± 0%  ~ (p=0.566 n=10)

@VojtechVitek VojtechVitek merged commit 741b4a5 into master Jun 29, 2026
3 checks passed
@VojtechVitek VojtechVitek deleted the deprecate-spoofable-realip-rate-limiting branch June 29, 2026 14:53
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.

KeyByRealIP is susceptible to spoofing

2 participants