Skip to content

fix(ratelimit): canonicalise client keys — IPv6 /64 + mapped-v4 unification (FIX-RLKEY) - #132

Merged
vanlongme merged 4 commits into
mainfrom
v10/fix-rlkey
Oct 6, 2026
Merged

vanlongme merged 4 commits into
mainfrom
v10/fix-rlkey

Conversation

@vanlongme

@vanlongme vanlongme commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • internal/ratelimit.canon now Unmap()s every parseable address before rendering, so the IPv4-mapped spellings (::ffff:a.b.c.d, 0:0:0:0:0:ffff:a.b.c.d) land on the single native v4 key — a dual-stack client can no longer double its budget by switching family spelling.
  • IPv6 client keys fold to the /64 prefix (rendered as the masked base address): temporary/privacy-address rotation or deliberate /128-hopping inside the prefix one subscriber controls now draws one budget. IPv4 stays per-/32. /64 is the smallest prefix an end site is delegated, hence the finest granularity a single client can still rotate inside. Consequence: all hosts inside one shared /64 — a LAN segment, subscribers an ISP delegates a single prefix to — share one rate-limit budget.
  • The fold applies to rate-limit keys only. One selection feeds two renders: ClientKey (folded key) and the new ClientAddr (unmapped but unfolded real address). The gateway's forwarded-header rebuild (X-Forwarded-For/Forwarded/X-Real-IP toward the runtime, S18) uses ClientAddr, so the runtime still sees the client's exact address — TestProxy_IPv6ForwardsExactAddrKeyFolds pins the split.
  • The socket-peer paths canonicalise too, so a direct (unproxied) IPv6 client keys identically to an XFF-derived one. The same key string keys the local buckets and the shared Postgres window rows end to end — TestShared_IPv6WindowAndLocalShareKey pins that.
  • Docs: chart README + values.yaml and docs/runbooks/capacity.md describe the canonicalisation, the shared-/64 budget consequence, and the edge invariant (the right-most-untrusted XFF entry is only meaningful when every proxy hop in front is in -trusted-proxies — a pass-through trusted hop hands the key to client bytes; dual-stack edges must list their IPv6 ranges). docs/security/threat-model.md S14 residual updated.
  • One-time key-format change on upgrade, no migration: IPv6 window rows move from per-address to per-/64 keys (IPv4 keys unchanged). Rows under the old format expire inside their minute window; an IPv6 client mid-window at upgrade time gets at most one fresh window's budget.

Regression test (fails on v0.5.0, passes here)

go test ./internal/ratelimit/ -run 'TestClientKey_IPv6FoldedTo64|TestClientKey_MappedFormsUnified|TestShared_IPv6WindowAndLocalShareKey' -v

On v0.5.0 all three fail (per-/128 keys, ::ffff: split from native v4); with this change all pass. Verified: go build ./..., gofmt clean, go vet, go test -race ./internal/ratelimit/, plus the ./internal/gateway/ and ./internal/api/ suites and a 25 s FuzzClientKey run.

Test plan

  • v6 addresses in one /64 share a bucket; different /64s do not (peer + XFF paths)
  • ::ffff:v4 == native v4 (XFF + socket peer)
  • window + local bucket share the key string
  • gateway forwards the exact v6 address while the limiter key is the /64 base
  • regression fails on v0.5.0
  • CI

Diff stat vs origin/main

 deploy/helm/tinycdi/README.md        | 27 +++++++++++++-
 deploy/helm/tinycdi/values.yaml      |  7 ++--
 docs/runbooks/capacity.md            | 28 +++++++++++++--
 docs/security/threat-model.md        |  2 +-
 internal/gateway/forwarded_test.go   | 44 +++++++++++++++++++++++
 internal/gateway/proxy.go            | 10 +++---
 internal/ratelimit/fuzz_test.go      |  2 +-
 internal/ratelimit/ratelimit.go      | 63 ++++++++++++++++++++++++++++----
 internal/ratelimit/ratelimit_test.go | 69 ++++++++++++++++++++++++++++++++++++
 internal/ratelimit/shared_test.go    | 39 ++++++++++++++++++++
 10 files changed, 272 insertions(+), 19 deletions(-)

Generated with Devin

…fied (FIX-RLKEY)

The per-client budget multiplied with the client's address pool: an IPv6
client rotating temporary/privacy addresses inside its /64 got a fresh
local bucket and a fresh Postgres window row per /128, and the
::ffff:a.b.c.d spellings keyed separately from the native v4 address.
canon() now Unmap()s before rendering and folds IPv6 keys to the
masked /64 base; the socket-peer paths canonicalise too so direct v6
clients fold identically. The one canonical string still keys the local
buckets and the shared window rows end to end. The folded key remains a
parseable address, so the gateway's forwarded-header rebuild (S18) now
folds the runtime-bound client address at the same granularity.

IPv4 keys are unchanged; IPv6 window rows under the old per-address
format expire inside their minute window — no migration.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 6, 2026 05:04

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

vanlongme and others added 3 commits October 6, 2026 12:35
…IX-RLKEY)

Advisor gate: the /64 fold applies to rate-limit keys only. The shared
selection splits into two renders — ClientKey (folded key) and the new
ClientAddr (unmapped but unfolded real address) — and the gateway's
rewriteForwarded now derives headers from ClientAddr, so the runtime and
attribution keep the exact client address while billing stays per-/64.
Docs state the consequence plainly: hosts sharing one /64 share one
rate-limit budget, nothing else.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@vanlongme
vanlongme merged commit a09bea3 into main Oct 6, 2026
14 checks passed
@vanlongme
vanlongme deleted the v10/fix-rlkey branch October 6, 2026 06:26
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.

2 participants