From a953845ba7c6c32bd04bca9d54a0a13cf587d355 Mon Sep 17 00:00:00 2001 From: Michael Bacarella Date: Sun, 5 Oct 2025 08:43:29 -0700 Subject: [PATCH] feat: Add IPv6 prefix-based rate limiting on login failures (#13070) Enhance security by implementing rate limiting on IPv6 prefixes (/64, /56, /48) to prevent brute force attacks that exploit cheap IPv6 address generation. * Add private get_ipv6_prefixes() to calculate network prefixes * Implement private check_failure_ipv6_prefix() for prefix-specific limits on IPv6 addresses * Refactor check_failure() and update_failure() to support both IPs and prefixes * Add ExceedIPv6PrefixAttempts to AlarmAuditType enum Signed-off-by: Michael Bacarella --- src/server/connection.rs | 133 +++++++++++++++++++++++++++++++++++---- 1 file changed, 121 insertions(+), 12 deletions(-) diff --git a/src/server/connection.rs b/src/server/connection.rs index 3d6c6a72f..6f584a7af 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -50,8 +50,10 @@ use serde_json::{json, value::Value}; #[cfg(not(any(target_os = "android", target_os = "ios")))] use std::sync::atomic::Ordering; use std::{ + net::Ipv6Addr, num::NonZeroI64, path::PathBuf, + str::FromStr, sync::{atomic::AtomicI64, mpsc as std_mpsc}, }; #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -3173,35 +3175,134 @@ impl Connection { } } - fn update_failure(&self, (mut failure, time): ((i32, i32, i32), i32), remove: bool, i: usize) { + // Try to parse connection IP as IPv6 address, returning /64, /56, and /48 prefixes. + // Parsing an IPv4 address just returns None. + // note: we specifically don't use hbb_common::is_ipv6_str to avoid divergence issues + // between its regex and the system std::net::Ipv6Addr implementation. + fn get_ipv6_prefixes(&self) -> Option<(String, String, String)> { + fn mask_u128(addr: u128, prefix: u8) -> u128 { + let mask = if prefix == 0 || prefix > 128 { + 0 + } else { + (!0u128) << (128 - prefix) + }; + addr & mask + } + // eliminate zone-ids like "fe80::1%eth0" + let ip_only = self.ip.split('%').next().unwrap_or(&self.ip).trim(); + let ip = Ipv6Addr::from_str(ip_only).ok()?; + + let as_u128 = u128::from_be_bytes(ip.octets()); + + let p64 = Ipv6Addr::from(mask_u128(as_u128, 64).to_be_bytes()).to_string() + "/64"; + let p56 = Ipv6Addr::from(mask_u128(as_u128, 56).to_be_bytes()).to_string() + "/56"; + let p48 = Ipv6Addr::from(mask_u128(as_u128, 48).to_be_bytes()).to_string() + "/48"; + + Some((p64, p56, p48)) + } + + fn update_failure(&self, (failure, time): ((i32, i32, i32), i32), remove: bool, i: usize) { + fn bump(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) { + if cur.0 == time { + cur.1 += 1; + cur.2 += 1; + } else { + cur.0 = time; + cur.1 = 1; + cur.2 += 1; + } + cur + } + let map_mutex = &LOGIN_FAILURES[i]; if remove { if failure.0 != 0 { - LOGIN_FAILURES[i].lock().unwrap().remove(&self.ip); + if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() { + let mut m = map_mutex.lock().unwrap(); + m.remove(&p64); + m.remove(&p56); + m.remove(&p48); + m.remove(&self.ip); + } else { + map_mutex.lock().unwrap().remove(&self.ip); + } } return; } - if failure.0 == time { - failure.1 += 1; - failure.2 += 1; + // Bump the prefixes, fetching existing values + if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() { + let mut m = map_mutex.lock().unwrap(); + for key in [p64, p56, p48] { + let cur = m.get(&key).copied().unwrap_or((0, 0, 0)); + m.insert(key, bump(cur, time)); + } + // Update full IP: bump from the *original* passed-in failure + m.insert(self.ip.clone(), bump(failure, time)); } else { - failure.0 = time; - failure.1 = 1; - failure.2 += 1; + // Update full IP: bump from the *original* passed-in failure + let mut m = map_mutex.lock().unwrap(); + m.insert(self.ip.clone(), bump(failure, time)); } - LOGIN_FAILURES[i] + } + + async fn check_failure_ipv6_prefix( + &mut self, + i: usize, + time: i32, + prefix: &str, + prefix_num: i8, + thresh: i32, + ) -> Option<(((i32, i32, i32), i32), bool)> { + let failure_prefix = LOGIN_FAILURES[i] .lock() .unwrap() - .insert(self.ip.clone(), failure); + .get(prefix) + .copied() + .unwrap_or((0, 0, 0)); + + if failure_prefix.2 > thresh { + self.send_login_error(format!( + "Too many wrong attempts for IPv6 prefix /{}", + prefix_num + )) + .await; + Self::post_alarm_audit( + AlarmAuditType::ExceedIPv6PrefixAttempts, + json!({ + "ip": self.ip, + "id": self.lr.my_id.clone(), + "name": self.lr.my_name.clone(), + }), + ); + Some(((failure_prefix, time), false)) + } else { + None + } } async fn check_failure(&mut self, i: usize) -> (((i32, i32, i32), i32), bool) { + let time = (get_time() / 60_000) as i32; + + // IPv6 addresses are cheap to make so we check prefix/netblock as well + if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() { + if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p64, 64, 60).await { + return res; + } + if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p56, 56, 80).await { + return res; + } + if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p48, 48, 100).await { + return res; + } + } + + // checks IPv6 and IPv4 direct addresses let failure = LOGIN_FAILURES[i] .lock() .unwrap() .get(&self.ip) - .map(|x| x.clone()) + .copied() .unwrap_or((0, 0, 0)); - let time = (get_time() / 60_000) as i32; + let res = if failure.2 > 30 { self.send_login_error("Too many wrong attempts").await; Self::post_alarm_audit( @@ -4377,6 +4478,7 @@ pub enum AlarmAuditType { IpWhitelist = 0, ExceedThirtyAttempts = 1, SixAttemptsWithinOneMinute = 2, + ExceedIPv6PrefixAttempts = 3, } pub enum FileAuditType { @@ -4942,4 +5044,11 @@ mod test { assert_eq!(pos.x, 510); assert_eq!(pos.y, 510); } + + #[test] + fn ipv6() { + assert!(Ipv6Addr::from_str("::1").is_ok()); + assert!(Ipv6Addr::from_str("127.0.0.1").is_err()); + assert!(Ipv6Addr::from_str("0").is_err()); + } }