//! 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 } fn parse_network(value: &str) -> Result { if let Ok(network) = value.parse::() { return Ok(network); } let ip: IpAddr = value .parse() .context("ACL requires an IPv4/IPv6 address or CIDR")?; let prefix = if ip.is_ipv4() { 32 } else { 128 }; format!("{ip}/{prefix}") .parse::() .context("ACL requires an IPv4/IPv6 address or CIDR") } #[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 = parse_network(&rule.network)?; 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 { fn explicit_action(&self, peer: IpAddr) -> Option { 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 Some(Action::Deny); } allowed = true; } } allowed.then_some(Action::Allow) } /// 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 { match self.explicit_action(peer) { Some(Action::Deny) => false, Some(Action::Allow) => true, None => self.default == Action::Allow, } } } /// Evaluate the IP and country controls that belong to one access-policy scope. /// Explicit IP denies and blocked countries always deny. In allow-only country mode, /// a listed country or an explicit IP allow is sufficient; this lets a default-deny /// policy express e.g. "private networks OR PL/DE" without weakening other scopes. pub fn allows_with_country( acl: Option<&CompiledAcl>, countries: &crate::rate_limit::CountryAccess, peer: IpAddr, country: Option<&str>, ) -> bool { let ip_action = acl.and_then(|value| value.explicit_action(peer)); if ip_action == Some(Action::Deny) { return false; } let country_listed = country.is_some_and(|code| { countries .countries .iter() .any(|item| item.eq_ignore_ascii_case(code)) }); match countries.mode { crate::rate_limit::CountryMode::Off => { ip_action == Some(Action::Allow) || acl.is_none_or(|value| value.default == Action::Allow) } crate::rate_limit::CountryMode::Block => { !country_listed && (ip_action == Some(Action::Allow) || acl.is_none_or(|value| value.default == Action::Allow)) } crate::rate_limit::CountryMode::Allow => { ip_action == Some(Action::Allow) || country_listed } } } /// 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)] include!("acl/tests.rs");