//! Explicit network ACLs. Peer IP is default; only the central explicit trusted-proxy policy may resolve a forwarded client IP. use anyhow::{ensure, Context, Result}; use ipnet::IpNet; use serde::{Deserialize, Serialize}; use std::net::IpAddr; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Action { Allow, Deny, } fn deny() -> Action { Action::Deny } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Rule { pub network: String, pub action: Action, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Acl { #[serde(default = "deny")] pub default: Action, #[serde(default)] pub rules: Vec, } #[derive(Debug, Clone)] pub struct CompiledAcl { default: Action, rules: Vec<(IpNet, Action)>, } impl Acl { pub fn compile(&self) -> Result { ensure!(self.rules.len() <= 128, "ACL supports at most 128 rules"); let mut rules = Vec::with_capacity(self.rules.len()); for rule in &self.rules { ensure!(rule.network.len() <= 64, "ACL network is too long"); let network: IpNet = rule .network .parse() .context("ACL requires an IPv4/IPv6 CIDR")?; if let IpNet::V6(net) = network { ensure!( net.addr().to_ipv4_mapped().is_none(), "use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules" ); } rules.push((network, rule.action)); } Ok(CompiledAcl { default: self.default, rules, }) } } impl CompiledAcl { /// Explicit deny beats all matching allow rules. Otherwise, allow or the default applies. /// Host policy and route policy are both evaluated, so route allow cannot override host deny. pub fn allows(&self, peer: IpAddr) -> bool { let peer = match peer { IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(peer), _ => peer, }; let mut allowed = false; for (network, action) in &self.rules { if network.contains(&peer) { if *action == Action::Deny { return false; } allowed = true; } } allowed || self.default == Action::Allow } } /// Validate the already-decoded routing representation used internally after /// matching/rewriting. Encoded spaces from a valid request are literal spaces /// at this stage; reserved separators, percent and dot-segment ambiguity stay /// forbidden. pub fn validate_decoded_routing_path(path: &str) -> Result<()> { ensure!( path.starts_with('/') && path.len() <= 8192, "invalid path length or form" ); ensure!( !path .bytes() .any(|b| b < 32 || b == 127 || matches!(b, b'\\' | b'%' | b'?' | b'#' | b';')), "invalid decoded path character" ); ensure!( !path.split('/').any(|part| part == "." || part == ".."), "ambiguous path segments" ); Ok(()) } /// Routing/ACL use one decoded representation; upstream still receives the original path. /// Reject encoded separators, percent (double decoding), controls, ambiguous dot segments, /// matrix parameters and malformed UTF-8. Repeated literal separators are /// collapsed for routing/ACL decisions (matching nginx's default merge_slashes /// behavior), while the upstream request still retains its original target. /// No silent fallback to a less-restrictive route. pub fn routing_path(raw: &str) -> Result { ensure!( raw.starts_with('/') && raw.len() <= 8192, "invalid path length or form" ); let mut out = Vec::with_capacity(raw.len()); let bytes = raw.as_bytes(); let mut index = 0; while index < bytes.len() { let b = bytes[index]; if b == b'%' { ensure!(index + 2 < bytes.len(), "truncated path escape"); let digit = |value: u8| (value as char).to_digit(16).map(|v| v as u8); let high = digit(bytes[index + 1]).context("invalid path escape")?; let low = digit(bytes[index + 2]).context("invalid path escape")?; let decoded = (high << 4) | low; ensure!( decoded >= 32 && decoded != 127 && !matches!(decoded, b'/' | b'\\' | b'%' | b'?' | b'#' | b';'), "ambiguous encoded path character" ); out.push(decoded); index += 3; } else { ensure!( b > 32 && b != 127 && !matches!(b, b'\\' | b'?' | b'#' | b';'), "invalid path character" ); if b != b'/' || out.last().copied() != Some(b'/') { out.push(b); } index += 1; } } let path = String::from_utf8(out).context("invalid path UTF-8")?; ensure!( !path.split('/').any(|part| part == "." || part == ".."), "ambiguous path segments" ); Ok(path) } #[cfg(test)] mod tests { use super::*; #[test] fn deny_overrides_allow_and_cidr_boundaries_hold() { let acl = Acl { default: Action::Deny, rules: vec![ Rule { network: "10.0.0.0/8".into(), action: Action::Allow, }, Rule { network: "10.2.0.0/16".into(), action: Action::Deny, }, ], } .compile() .unwrap(); assert!(acl.allows("10.1.0.1".parse().unwrap())); assert!(!acl.allows("10.2.3.4".parse().unwrap())); assert!(!acl.allows("11.0.0.1".parse().unwrap())); assert!(acl.allows("::ffff:10.1.0.1".parse().unwrap())); } #[test] fn ipv6_and_invalid_networks() { let acl = Acl { default: Action::Deny, rules: vec![Rule { network: "fd00::/8".into(), action: Action::Allow, }], } .compile() .unwrap(); assert!(acl.allows("fd00::1".parse().unwrap())); assert!(!acl.allows("fe00::1".parse().unwrap())); let bad = Acl { default: Action::Allow, rules: vec![Rule { network: "not-cidr".into(), action: Action::Allow, }], }; assert!(bad.compile().is_err()); } #[test] fn repeated_slashes_are_normalized_for_routing() { assert_eq!( routing_path("/static/js//events.js").unwrap(), "/static/js/events.js" ); for path in [ "/%2e%2e/secret", "/a/%2E%2E/b", "/a%2fb", "/a%5cb", "/bad%", "/bad%2", ] { assert!(routing_path(path).is_err(), "{path}"); } assert_eq!(routing_path("//admin///users").unwrap(), "/admin/users"); } #[test] fn decoded_rewrite_paths_keep_encoded_spaces_but_reject_ambiguity() { assert!(validate_decoded_routing_path("/Ubuntu 14.04 LTS/").is_ok()); for path in ["/a/../b", "/a%2fb", "/a;b", "/a?b", "/a\\b"] { assert!(validate_decoded_routing_path(path).is_err(), "{path}"); } } #[test] fn paths_cannot_escape_a_restricted_prefix() { assert_eq!(routing_path("/%61dmin/users").unwrap(), "/admin/users"); assert_eq!(routing_path("/caf%C3%A9").unwrap(), "/caf\u{e9}"); assert_eq!( routing_path("/Ubuntu%2014.04%20LTS/").unwrap(), "/Ubuntu 14.04 LTS/" ); assert_eq!( routing_path("/static/js//events.js").unwrap(), "/static/js/events.js" ); assert_eq!(routing_path("//admin///users").unwrap(), "/admin/users"); for path in [ "/api/../admin", "/api/%2e%2e/admin", "/%2fadmin", "/%5cadmin", "/%2561dmin", "/%00", "/%ff", "/%q0", "/a\\b", "/admin;session=x", "/admin%3buser=x", ] { assert!(routing_path(path).is_err(), "{path}"); } } }