From 922076f9fcced7862190748bb3e71358ade57e91 Mon Sep 17 00:00:00 2001 From: DaX Date: Thu, 30 Jul 2026 20:24:54 +0200 Subject: [PATCH] fix(auth): bucket IPv6 connection limits by /64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- crates/buzz-auth/src/rate_limit.rs | 90 ++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/crates/buzz-auth/src/rate_limit.rs b/crates/buzz-auth/src/rate_limit.rs index 8fd42c50fb..273fe30f4e 100644 --- a/crates/buzz-auth/src/rate_limit.rs +++ b/crates/buzz-auth/src/rate_limit.rs @@ -6,7 +6,7 @@ //! ⚠️ Fixed windows allow up to 2× burst at boundaries. Upgrade to sliding //! window or token bucket for strict limiting. -use std::net::IpAddr; +use std::net::{IpAddr, Ipv6Addr}; use buzz_core::TenantContext; use nostr::PublicKey; @@ -207,11 +207,42 @@ pub fn rate_limit_key(ctx: &TenantContext, pubkey: &PublicKey, limit_type: &Limi ) } -/// Redis key for IP-based rate limit: `buzz:ratelimit:ip:{ip}:conn`. +/// Redis key for IP-based rate limit: `buzz:ratelimit:ip:{bucket}:conn`. /// -/// Operator-global by design — see [`RateLimiter`] docs. +/// Operator-global by design — see [`RateLimiter`] docs. The address is +/// bucketed by [`ip_rate_limit_bucket`] so an IPv6 client cannot mint a fresh +/// 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)) +} + +/// The counter identity an address is charged against. +/// +/// IPv4 is keyed on the full address: v4 space is scarce, addresses are +/// commonly shared behind carrier NAT, and widening the bucket would let one +/// abusive client deny service to everyone behind the same egress. +/// +/// IPv6 is keyed on the `/64` prefix. A single end site is routinely delegated +/// a `/64` or shorter (RFC 4291 §2.5.4 fixes the interface identifier at 64 +/// bits), so keying on the full `/128` lets one host rotate through 2^64 +/// source addresses and get a fresh quota for every connection — the fence +/// would count addresses, not clients. +/// +/// IPv4-mapped addresses (`::ffff:a.b.c.d`) are unmapped to their IPv4 form +/// first. Masking them to `/64` would collapse every IPv4 client that arrives +/// over a dual-stack socket into the single `::/64` bucket, turning the fence +/// into a global limit on all IPv4 traffic. +pub fn ip_rate_limit_bucket(ip: &IpAddr) -> String { + match ip { + IpAddr::V4(v4) => v4.to_string(), + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => v4.to_string(), + None => { + let s = v6.segments(); + format!("{}/64", Ipv6Addr::new(s[0], s[1], s[2], s[3], 0, 0, 0, 0)) + } + }, + } } /// Always-allow rate limiter for unit tests. @@ -312,6 +343,57 @@ mod tests { assert_eq!(ip_rate_limit_key(&ip), "buzz:ratelimit:ip:192.168.1.1:conn"); } + #[test] + fn ipv6_addresses_in_one_slash_64_share_a_counter() { + // The fence counts clients, not addresses. An end site holding a /64 + // must not mint a fresh quota by rotating its interface identifier — + // it has 2^64 of them. + let a = IpAddr::V6("2001:db8:1:2::1".parse::().unwrap()); + let b = IpAddr::V6("2001:db8:1:2:dead:beef:cafe:1".parse::().unwrap()); + assert_eq!(ip_rate_limit_key(&a), ip_rate_limit_key(&b)); + assert_eq!( + ip_rate_limit_key(&a), + "buzz:ratelimit:ip:2001:db8:1:2::/64:conn" + ); + } + + #[test] + fn distinct_ipv6_prefixes_keep_separate_counters() { + // Bucketing must not over-block: two different end sites stay independent. + let a = IpAddr::V6("2001:db8:1:2::1".parse::().unwrap()); + let b = IpAddr::V6("2001:db8:1:3::1".parse::().unwrap()); + assert_ne!(ip_rate_limit_key(&a), ip_rate_limit_key(&b)); + } + + #[test] + fn ipv4_mapped_addresses_are_not_collapsed() { + // ::ffff:a.b.c.d arrives on dual-stack sockets. Masking these to /64 + // would put every IPv4 client in one bucket — a global IPv4 limit, and + // a self-inflicted outage rather than a fence. + let mapped_a = IpAddr::V6("::ffff:192.168.1.1".parse::().unwrap()); + let mapped_b = IpAddr::V6("::ffff:203.0.113.9".parse::().unwrap()); + assert_ne!(ip_rate_limit_key(&mapped_a), ip_rate_limit_key(&mapped_b)); + + // A mapped address charges the same counter as its native v4 form, so a + // client cannot double its quota by switching socket family. + let native = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)); + assert_eq!(ip_rate_limit_key(&mapped_a), ip_rate_limit_key(&native)); + } + + #[test] + fn ipv6_bucket_key_is_lowercase() { + // Same idempotence invariant the pubkey key holds: uppercase hex would + // split one logical client across two Redis rows → double quota. + let ip = IpAddr::V6("2001:DB8:AB:CD::1".parse::().unwrap()); + let key = ip_rate_limit_key(&ip); + for c in key.chars() { + assert!( + !c.is_ascii_uppercase(), + "ip rate-limit key {key} must be all-lowercase ASCII" + ); + } + } + #[tokio::test] async fn always_allow_limiter() { let limiter = AlwaysAllowRateLimiter;