From ac982a208e1cbe1141740c46d3650e22086a543f Mon Sep 17 00:00:00 2001 From: Timothy Miller Date: Wed, 18 Mar 2026 19:53:51 -0400 Subject: [PATCH] Replace ipnet dependency with inline CidrRange for CIDR matching Remove the ipnet crate and implement a lightweight CidrRange struct that handles IPv4/IPv6 CIDR parsing and containment checks using bitwise masking. Adds tests for invalid prefixes and cross-family non-matching. --- Cargo.lock | 1 - Cargo.toml | 1 - src/cf_ip_filter.rs | 72 ++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b6666b0..ccada5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,7 +114,6 @@ dependencies = [ "chrono", "idna", "if-addrs", - "ipnet", "regex", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index f94a90e..f128aca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ chrono = { version = "0.4", features = ["clock"] } url = "2" idna = "1" if-addrs = "0.13" -ipnet = "2" [profile.release] opt-level = "s" diff --git a/src/cf_ip_filter.rs b/src/cf_ip_filter.rs index 30d9e93..aef9aa2 100644 --- a/src/cf_ip_filter.rs +++ b/src/cf_ip_filter.rs @@ -1,5 +1,4 @@ use crate::pp::{self, PP}; -use ipnet::IpNet; use reqwest::Client; use std::net::IpAddr; use std::time::Duration; @@ -7,9 +6,52 @@ use std::time::Duration; const CF_IPV4_URL: &str = "https://www.cloudflare.com/ips-v4"; const CF_IPV6_URL: &str = "https://www.cloudflare.com/ips-v6"; +/// A CIDR range parsed from "address/prefix" notation. +struct CidrRange { + addr: IpAddr, + prefix_len: u8, +} + +impl CidrRange { + fn parse(s: &str) -> Option { + let (addr_str, prefix_str) = s.split_once('/')?; + let addr: IpAddr = addr_str.parse().ok()?; + let prefix_len: u8 = prefix_str.parse().ok()?; + match addr { + IpAddr::V4(_) if prefix_len > 32 => None, + IpAddr::V6(_) if prefix_len > 128 => None, + _ => Some(Self { addr, prefix_len }), + } + } + + fn contains(&self, ip: &IpAddr) -> bool { + match (self.addr, ip) { + (IpAddr::V4(net), IpAddr::V4(ip)) => { + let net_bits = u32::from(net); + let ip_bits = u32::from(*ip); + if self.prefix_len == 0 { + return true; + } + let mask = !0u32 << (32 - self.prefix_len); + (net_bits & mask) == (ip_bits & mask) + } + (IpAddr::V6(net), IpAddr::V6(ip)) => { + let net_bits = u128::from(net); + let ip_bits = u128::from(*ip); + if self.prefix_len == 0 { + return true; + } + let mask = !0u128 << (128 - self.prefix_len); + (net_bits & mask) == (ip_bits & mask) + } + _ => false, + } + } +} + /// Holds parsed Cloudflare CIDR ranges for IP filtering. pub struct CloudflareIpFilter { - ranges: Vec, + ranges: Vec, } impl CloudflareIpFilter { @@ -26,13 +68,13 @@ impl CloudflareIpFilter { if line.is_empty() { continue; } - match line.parse::() { - Ok(net) => ranges.push(net), - Err(e) => { + match CidrRange::parse(line) { + Some(range) => ranges.push(range), + None => { ppfmt.warningf( pp::EMOJI_WARNING, &format!( - "Failed to parse Cloudflare IP range '{line}': {e}" + "Failed to parse Cloudflare IP range '{line}'" ), ); } @@ -86,14 +128,14 @@ impl CloudflareIpFilter { /// Parse ranges from raw text lines (for testing). #[cfg(test)] pub fn from_lines(lines: &str) -> Option { - let ranges: Vec = lines + let ranges: Vec = lines .lines() .filter_map(|l| { let l = l.trim(); if l.is_empty() { None } else { - l.parse().ok() + CidrRange::parse(l) } }) .collect(); @@ -178,4 +220,18 @@ mod tests { // Just outside range (104.24.0.0) assert!(!filter.contains(&IpAddr::V4(Ipv4Addr::new(104, 24, 0, 0)))); } + + #[test] + fn test_invalid_prefix_rejected() { + assert!(CidrRange::parse("10.0.0.0/33").is_none()); + assert!(CidrRange::parse("::1/129").is_none()); + assert!(CidrRange::parse("not-an-ip/24").is_none()); + } + + #[test] + fn test_v4_does_not_match_v6() { + let filter = CloudflareIpFilter::from_lines("104.16.0.0/13").unwrap(); + let ip: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0, 0, 0, 0, 0, 1)); + assert!(!filter.contains(&ip)); + } }