Skip to main content

krabka_authz/
cidr.rs

1//! CIDR-based ACL host patterns (KIP-1276).
2//!
3//! Kafka 4.4 lets an ACL host be a CIDR range such as `10.0.0.0/8` or
4//! `2001:db8::/32`, not only a single address or the `*` wildcard. This module
5//! is the parser [`CreateAcls`] validation uses to reject a malformed CIDR up
6//! front, and the range test the authorizer uses to match a peer address
7//! against a stored CIDR ACL. Two call sites, one implementation, so they
8//! cannot drift.
9//!
10//! No CIDR crate is a dependency of this workspace, so the range test is
11//! hand-rolled from [`std::net`] address bytes rather than pulling one in for
12//! a single prefix-mask comparison.
13//!
14//! [`CreateAcls`]: https://kafka.apache.org/protocol.html#The_Messages_CreateAcls
15
16use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
17
18/// A parsed `<ip>/<prefix-len>` CIDR host pattern.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct Cidr {
21    ip: IpAddr,
22    prefix_len: u8,
23}
24
25impl Cidr {
26    /// Parses `host` as a CIDR pattern the way Kafka's `CidrUtils.validate`
27    /// does: a pattern containing `:` is IPv6 and goes to commons-net
28    /// `SubnetUtils6`, anything else is IPv4 and goes to `SubnetUtils`
29    /// (commons-net 3.13.0, the version Kafka trunk pins).
30    ///
31    /// # Errors
32    ///
33    /// Returns the commons-net `IllegalArgumentException` message; the caller
34    /// prefixes it with `Invalid CIDR notation '<host>': ` to match Kafka's
35    /// `CreateAcls` message. An IPv4-mapped IPv6 range (`::ffff:10.0.0.0/104`)
36    /// is refused, as Java resolves that literal to an `Inet4Address` and
37    /// `SubnetUtils6` rejects it; the operator writes the plain IPv4 form.
38    pub fn parse(host: &str) -> Result<Self, String> {
39        if host.contains(':') {
40            parse_v6(host)
41        } else {
42            parse_v4(host)
43        }
44    }
45
46    /// True when `ip` falls within this range.
47    ///
48    /// `ip` is canonicalized first ([`IpAddr::to_canonical`]), so an
49    /// IPv4-mapped IPv6 peer -- what a dual-stack listener hands the request
50    /// path -- unwraps to its IPv4 form before comparison and matches a plain
51    /// IPv4 CIDR. The CIDR itself is not canonicalized: `CreateAcls` only
52    /// ever stores the form an operator typed (`10.0.0.0/8`,
53    /// `2001:db8::/32`), never an IPv4-mapped IPv6 literal, so a mismatched
54    /// pair here is a genuine address-family mismatch and answers `false`,
55    /// the same as a literal host comparison would.
56    ///
57    /// An IPv4 range never contains `0.0.0.0`, even `0.0.0.0/0`: commons-net
58    /// `SubnetUtils.SubnetInfo.isInRange(int)` refuses the zero address before
59    /// it compares. `SubnetUtils6` has no such rule, so `::/0` contains `::`.
60    #[must_use]
61    pub fn contains(&self, ip: IpAddr) -> bool {
62        match (self.ip, ip.to_canonical()) {
63            (IpAddr::V4(_), IpAddr::V4(candidate)) if candidate.is_unspecified() => false,
64            (IpAddr::V4(range), IpAddr::V4(candidate)) => {
65                let mask = v4_mask(self.prefix_len);
66                u32::from(range) & mask == u32::from(candidate) & mask
67            }
68            (IpAddr::V6(range), IpAddr::V6(candidate)) => {
69                let mask = v6_mask(self.prefix_len);
70                u128::from(range) & mask == u128::from(candidate) & mask
71            }
72            (IpAddr::V4(_), IpAddr::V6(_)) | (IpAddr::V6(_), IpAddr::V4(_)) => false,
73        }
74    }
75}
76
77/// `SubnetUtils(String)`: the whole pattern must match
78/// `(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})/(\d{1,2})`, then each octet is
79/// range-checked to `[0,255]` and the prefix to `[0,32]`, in that order.
80fn parse_v4(cidr: &str) -> Result<Cidr, String> {
81    let parse_fail = || format!("Could not parse [{cidr}]");
82    let (address, prefix) = cidr.split_once('/').ok_or_else(parse_fail)?;
83    let octets: Vec<&str> = address.split('.').collect();
84    if octets.len() != 4
85        || !octets.iter().all(|octet| ascii_digits(octet, 3))
86        || !ascii_digits(prefix, 2)
87    {
88        return Err(parse_fail());
89    }
90    let mut bytes = [0_u8; 4];
91    for (byte, octet) in bytes.iter_mut().zip(&octets) {
92        let value: u32 = octet.parse().map_err(|_| parse_fail())?;
93        *byte = u8::try_from(value).map_err(|_| format!("Value [{value}] not in range [0,255]"))?;
94    }
95    let prefix_len: u8 = prefix.parse().map_err(|_| parse_fail())?;
96    if prefix_len > 32 {
97        return Err(format!("Value [{prefix_len}] not in range [0,32]"));
98    }
99    Ok(Cidr {
100        ip: IpAddr::V4(Ipv4Addr::from(bytes)),
101        prefix_len,
102    })
103}
104
105/// `SubnetUtils6(String)`: split at the first `/`, parse the prefix as a Java
106/// `int` and range-check it to `[0,128]`, then resolve the address with
107/// `InetAddress.getByName`, which must give an `Inet6Address`.
108fn parse_v6(cidr: &str) -> Result<Cidr, String> {
109    let Some((address, prefix)) = cidr.split_once('/') else {
110        return Err(format!("Could not parse [{cidr}] - missing prefix length"));
111    };
112    let prefix_len = java_parse_int(prefix)
113        .ok_or_else(|| format!("Could not parse [{cidr}] - invalid prefix length"))?;
114    let prefix_len = u8::try_from(prefix_len)
115        .ok()
116        .filter(|len| *len <= 128)
117        .ok_or_else(|| {
118            format!("Could not parse [{cidr}] - prefix length must be between 0 and 128")
119        })?;
120    // `getByName` strips one pair of brackets around an IPv6 literal.
121    let literal = address
122        .strip_prefix('[')
123        .and_then(|inner| inner.strip_suffix(']'))
124        .unwrap_or(address);
125    let ip: Ipv6Addr = literal
126        .parse()
127        .map_err(|_| format!("Could not parse [{address}]"))?;
128    if ip.to_ipv4_mapped().is_some() {
129        return Err(format!("Could not parse [{address}] - not an IPv6 address"));
130    }
131    Ok(Cidr {
132        ip: IpAddr::V6(ip),
133        prefix_len,
134    })
135}
136
137/// True when `text` is one to `max` ASCII digits, as the regex `\d{1,max}`.
138fn ascii_digits(text: &str, max: usize) -> bool {
139    (1..=max).contains(&text.len()) && text.bytes().all(|b| b.is_ascii_digit())
140}
141
142/// `Integer.parseInt` over ASCII: an optional sign, then one or more digits,
143/// within `i32`.
144fn java_parse_int(text: &str) -> Option<i32> {
145    // Rust's signed decimal parser already requires an optional ASCII sign
146    // followed by a nonempty ASCII digit run and checks the i32 bounds.
147    text.parse().ok()
148}
149
150/// A `/prefix_len` mask for a 32-bit address, high bits set. `prefix_len` is
151/// already bounds-checked to `0..=32` by [`Cidr::parse`], so the only edge
152/// case here is `prefix_len == 0`, where a plain `u32::MAX << 32` would be a
153/// shift-by-width (undefined behavior in C, a panic in debug Rust).
154fn v4_mask(prefix_len: u8) -> u32 {
155    if prefix_len == 0 {
156        0
157    } else {
158        u32::MAX << (32 - u32::from(prefix_len))
159    }
160}
161
162/// The 128-bit analog of [`v4_mask`].
163fn v6_mask(prefix_len: u8) -> u128 {
164    if prefix_len == 0 {
165        0
166    } else {
167        u128::MAX << (128 - u32::from(prefix_len))
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use {
174        super::*,
175        assert2::check,
176        std::net::{Ipv4Addr, Ipv6Addr},
177    };
178
179    #[yare::parameterized(
180        ipv4_8 = { "10.0.0.0/8", IpAddr::V4(Ipv4Addr::new(10, 0, 0, 0)), 8 },
181        ipv4_24 = { "192.168.0.0/24", IpAddr::V4(Ipv4Addr::new(192, 168, 0, 0)), 24 },
182        ipv4_single_host = { "10.0.0.5/32", IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), 32 },
183        ipv6_32 = { "2001:db8::/32", IpAddr::V6("2001:db8::".parse().unwrap()), 32 },
184        decimal_leading_zeroes = { "010.0.0.1/08", IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 8 },
185        bracketed_ipv6_signed_prefix = {
186            "[2001:db8::]/+32",
187            IpAddr::V6("2001:db8::".parse().unwrap()),
188            32,
189        },
190    )]
191    fn parse_accepts_valid_cidrs(input: &str, ip: IpAddr, prefix_len: u8) {
192        check!(Cidr::parse(input) == Ok(Cidr { ip, prefix_len }));
193    }
194
195    #[yare::parameterized(
196        not_ip = { "not-an-ip/8", "Could not parse [not-an-ip/8]" },
197        non_numeric_ipv4_prefix = { "10.0.0.0/thirty", "Could not parse [10.0.0.0/thirty]" },
198        ipv4_prefix_too_large = { "10.0.0.0/33", "Value [33] not in range [0,32]" },
199        ipv4_prefix_far_too_large = { "10.0.0.0/100", "Could not parse [10.0.0.0/100]" },
200        ipv4_octet_too_large = { "10.0.256.0/8", "Value [256] not in range [0,255]" },
201        short_ipv4 = { "10.0.0/8", "Could not parse [10.0.0/8]" },
202        repeated_prefix = { "10.0.0.0/8/8", "Could not parse [10.0.0.0/8/8]" },
203        ipv6_prefix_too_large = {
204            "2001:db8::/129",
205            "Could not parse [2001:db8::/129] - prefix length must be between 0 and 128",
206        },
207        ipv6_negative_prefix = {
208            "2001:db8::/-1",
209            "Could not parse [2001:db8::/-1] - prefix length must be between 0 and 128",
210        },
211        ipv6_non_numeric_prefix = {
212            "2001:db8::/x",
213            "Could not parse [2001:db8::/x] - invalid prefix length",
214        },
215        ipv6_repeated_prefix = {
216            "2001:db8::/32/5",
217            "Could not parse [2001:db8::/32/5] - invalid prefix length",
218        },
219        missing_prefix = { "2001:db8::", "Could not parse [2001:db8::] - missing prefix length" },
220        malformed_ipv6 = { "2001:zz8::/32", "Could not parse [2001:zz8::]" },
221        mapped_ipv4_in_ipv6_prefix = {
222            "::ffff:10.0.0.0/104",
223            "Could not parse [::ffff:10.0.0.0] - not an IPv6 address",
224        },
225    )]
226    fn parse_rejects_malformed_cidrs(input: &str, reason: &str) {
227        check!(Cidr::parse(input) == Err(reason.to_owned()));
228    }
229
230    fn check_membership(cidr: &Cidr, members: &[IpAddr], non_members: &[IpAddr]) {
231        for ip in members {
232            check!(cidr.contains(*ip), "ip {ip}");
233        }
234        for ip in non_members {
235            check!(!cidr.contains(*ip), "ip {ip}");
236        }
237    }
238
239    #[test]
240    fn contains_matches_ipv4_ranges() {
241        let cidr = Cidr::parse("10.0.0.0/8").unwrap();
242        check_membership(
243            &cidr,
244            &[
245                IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3)),
246                IpAddr::V4(Ipv4Addr::new(10, 255, 255, 255)),
247            ],
248            &[IpAddr::V4(Ipv4Addr::new(11, 0, 0, 1))],
249        );
250    }
251
252    #[test]
253    fn contains_matches_ipv6_ranges() {
254        let cidr = Cidr::parse("2001:db8::/32").unwrap();
255        check_membership(
256            &cidr,
257            &[
258                IpAddr::V6("2001:db8::5".parse().unwrap()),
259                IpAddr::V6("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff".parse().unwrap()),
260            ],
261            &[IpAddr::V6("2001:db9::5".parse().unwrap())],
262        );
263    }
264
265    #[test]
266    fn contains_matches_ipv4_mapped_ipv6_peer_against_ipv4_cidr() {
267        let v4_cidr = Cidr::parse("10.0.0.0/8").unwrap();
268        let v4_mapped_peer: IpAddr = "::ffff:10.1.2.3".parse().unwrap();
269        check!(v4_cidr.contains(v4_mapped_peer));
270
271        let v4_mapped_miss: IpAddr = "::ffff:11.0.0.1".parse().unwrap();
272        check!(!v4_cidr.contains(v4_mapped_miss));
273    }
274
275    #[test]
276    fn zero_prefix_len_matches_everything_in_family() {
277        let v4_any = Cidr::parse("0.0.0.0/0").unwrap();
278        check!(v4_any.contains(IpAddr::V4(Ipv4Addr::BROADCAST)));
279        check!(!v4_any.contains(IpAddr::V6(Ipv6Addr::LOCALHOST)));
280
281        let v6_any = Cidr::parse("::/0").unwrap();
282        check!(v6_any.contains(IpAddr::V6(Ipv6Addr::LOCALHOST)));
283    }
284
285    /// commons-net `SubnetUtils.SubnetInfo.isInRange(int)` answers `false`
286    /// for the zero address in every IPv4 range, including one that holds it
287    /// numerically. `SubnetUtils6.SubnetInfo.isInRange` has no such rule.
288    #[yare::parameterized(
289        ipv4_any_unspecified = { "0.0.0.0/0", IpAddr::V4(Ipv4Addr::UNSPECIFIED), false },
290        ipv4_eight_unspecified = { "0.0.0.0/8", IpAddr::V4(Ipv4Addr::UNSPECIFIED), false },
291        ipv4_host_unspecified = { "0.0.0.0/32", IpAddr::V4(Ipv4Addr::UNSPECIFIED), false },
292        ipv4_nonzero_in_range = { "0.0.0.0/8", IpAddr::V4(Ipv4Addr::new(0, 0, 0, 1)), true },
293        ipv4_mapped_unspecified = {
294            "0.0.0.0/0",
295            IpAddr::V6(Ipv4Addr::UNSPECIFIED.to_ipv6_mapped()),
296            false,
297        },
298        ipv6_any_unspecified = { "::/0", IpAddr::V6(Ipv6Addr::UNSPECIFIED), true },
299        ipv6_host_unspecified = { "::/128", IpAddr::V6(Ipv6Addr::UNSPECIFIED), true },
300    )]
301    fn unspecified_address_matches_only_ipv6_ranges(cidr: &str, ip: IpAddr, expected: bool) {
302        check!(Cidr::parse(cidr).unwrap().contains(ip) == expected);
303    }
304}