204 lines
7.1 KiB
Rust
204 lines
7.1 KiB
Rust
//! 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<IpNet> {
|
|
if let Ok(network) = value.parse::<IpNet>() {
|
|
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::<IpNet>()
|
|
.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<Rule>,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub struct CompiledAcl {
|
|
default: Action,
|
|
rules: Vec<(IpNet, Action)>,
|
|
}
|
|
impl Acl {
|
|
pub fn compile(&self) -> Result<CompiledAcl> {
|
|
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<Action> {
|
|
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<String> {
|
|
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");
|