Skip to content

fix(auth): bucket IPv6 connection limits by /64 - #3784

Open
daxdax89 wants to merge 1 commit into
block:mainfrom
daxdax89:fix/ipv6-connection-rate-limit-bucket
Open

fix(auth): bucket IPv6 connection limits by /64#3784
daxdax89 wants to merge 1 commit into
block:mainfrom
daxdax89:fix/ipv6-connection-rate-limit-bucket

Conversation

@daxdax89

Copy link
Copy Markdown

Problem

ip_rate_limit_key keyed the per-IP connection fence on the full address:

format!("buzz:ratelimit:ip:{}:conn", ip)

For IPv6 that is a /128. RFC 4291 §2.5.4 fixes the interface identifier at 64 bits, and end sites are routinely delegated a /64 or shorter, so one host can rotate through 2^64 source addresses and charge a different Redis counter on every connection. The fence counts addresses, not clients.

This is the pre-auth edge control — check_ip_connection runs before host to community resolution completes (and instead of it when resolution fails), so unlike the pubkey-keyed limits there is no authenticated identity downstream that re-checks it. It is the only thing standing between an unauthenticated peer and connection-slot exhaustion.

Trace: ip_rate_limit_key (crates/buzz-auth/src/rate_limit.rs:213) → check_ip_connection (crates/buzz-pubsub/src/rate_limiter.rs:118) → run_rate_limit fixed-window. No prefix normalization anywhere in the chain.

Fix

Bucket IPv6 on the /64 prefix via a new ip_rate_limit_bucket.

IPv4 deliberately keeps the full address. v4 space is scarce and commonly shared behind carrier NAT, so widening that bucket would let one abusive client deny service to everyone behind the same egress — the opposite failure.

The IPv4-mapped trap

::ffff:a.b.c.d arrives on dual-stack sockets. Masking those to /64 like any other v6 address would place every IPv4 client into the single ::/64 bucket, silently converting a per-client fence into a global cap on all IPv4 traffic — a self-inflicted outage strictly worse than the bug being fixed. They are unmapped to their v4 form first, which also means a client cannot double its quota by switching socket family. There is a test pinning both directions.

Tests

Five cases in rate_limit.rs, all infra-free:

  • two addresses in one /64 share a counter (the regression)
  • distinct /64s stay independent (guards against over-blocking)
  • v4-mapped addresses do not collapse together, and match their native v4 key
  • the existing IPv4 key format is unchanged
  • the key stays lowercase, matching the invariant rate_limit_key already holds

Verified the suite catches the bug: reverting ip_rate_limit_bucket to ip.to_string() fails exactly the /64 and v4-mapped tests.

Operational note

IPv6 keys change shape, so counters in flight at deploy are orphaned. They expire with the rate-limit window (seconds) and no migration is needed.

Checks

just fmt-check, just clippy (clean), and just test-unit (all six suites) pass locally. buzz-pubsub is unaffected — the public signature is unchanged.

Scope

Found while auditing buzz-auth. Two other candidate findings from that pass are deliberately not included, since both turned out to be working as designed:

  • the inert scope grant in verify_auth_eventSECURITY.md states channel membership is the only access-control mechanism and there are no capability taxonomies, so the scope layer is intentionally not a gate;
  • NIP-98 body binding — api/bridge.rs already enforces a require_payload check before verification, and api/git/transport.rs documents its body: None as a considered trade-off.

Happy to open an issue on either if maintainers see it differently.

Related

Searched open PRs and issues; nothing touches IP rate limiting. #3633 (feat(auth): add provider-neutral authorization contract) also lives in buzz-auth but only in lib.rs and a new provider/ module, so there is no overlap with rate_limit.rs.

`ip_rate_limit_key` keyed the connection fence on the full address, so an
IPv6 client charged a different Redis counter for every source address it
used. RFC 4291 §2.5.4 fixes the interface identifier at 64 bits and end
sites are routinely delegated a /64 or shorter, so a single host can rotate
through 2^64 addresses and receive a fresh quota each time. The fence
counted addresses rather than clients, and `check_ip_connection` is the
pre-auth edge control — it runs before host to community resolution, so
nothing downstream re-checks it.

Bucket IPv6 on the /64 prefix. IPv4 keeps the full address: v4 space is
scarce and commonly shared behind carrier NAT, where widening the bucket
would let one abusive client deny service to everyone behind the same
egress.

IPv4-mapped addresses (::ffff:a.b.c.d) are unmapped before bucketing.
Masking them as ordinary v6 would place every IPv4 client arriving on a
dual-stack socket into the single ::/64 bucket, converting the per-client
fence into a global cap on all IPv4 traffic.

Keys change shape for IPv6, so existing counters are orphaned; they expire
with the rate-limit window and no migration is needed.

Signed-off-by: DaX <daxdax89@gmail.com>
@daxdax89
daxdax89 requested a review from a team as a code owner July 30, 2026 18:25

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 922076f9fc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/// counter per connection.
pub fn ip_rate_limit_key(ip: &IpAddr) -> String {
format!("buzz:ratelimit:ip:{}:conn", ip)
format!("buzz:ratelimit:ip:{}:conn", ip_rate_limit_bucket(ip))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wire the bucket into connection admission

For the relay path inspected, this normalization never runs: a repo-wide search finds check_ip_connection only in trait/implementation/test definitions, while router.rs upgrades directly into handle_connection and connection.rs checks only the process-wide semaphore. Consequently, an unauthenticated IPv6 peer rotating addresses still reaches connection admission without incrementing this Redis key, so the connection-slot exhaustion vulnerability described by this fix remains unchanged.

Useful? React with 👍 / 👎.

@Chessing234

Copy link
Copy Markdown
Contributor

looks good. if theres a user-visible string change, a tiny screenshot helps a lot

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