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 <m@bacarella.com>
This commit is contained in:
Michael Bacarella
2025-10-05 08:43:29 -07:00
committed by GitHub
parent 8d71534839
commit a953845ba7

View File

@@ -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());
}
}