v0.24.5
This commit is contained in:
commit
5e23e9af02
240 files changed
+99957
No files matched your search
+240
@@ -0,0 +1,240 @@
|
||||
//! 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<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: 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<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)]
|
||||
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"
|
||||
);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user