Add destination domain ACL rules

This commit is contained in:
2026-10-28 14:13:53 +00:00
parent a33549e81a
commit 467624b747
5 changed files with 450 additions and 24 deletions
+291 -13
View File
@@ -59,11 +59,95 @@ impl SaltedHash {
}
}
#[derive(Debug, Eq, PartialEq)]
pub(crate) enum DestinationHost {
Domain(String),
Ip(IpAddr),
}
impl DestinationHost {
pub fn parse(host: &str) -> Option<Self> {
if let Some(ipv6) = host
.strip_prefix('[')
.and_then(|host| host.strip_suffix(']'))
{
return ipv6.parse().ok().map(Self::Ip);
}
let ip_candidate = host.strip_suffix('.').unwrap_or(host);
if let Ok(ip) = ip_candidate.parse() {
return Some(Self::Ip(ip));
}
if host.contains(':') {
return None;
}
normalize_domain(host).map(Self::Domain)
}
}
enum DomainPattern {
Exact(String),
Subdomain(String),
}
impl DomainPattern {
fn parse(pattern: &str) -> Option<Self> {
let (subdomain, domain) = pattern
.strip_prefix("*.")
.map_or((false, pattern), |domain| (true, domain));
if matches!(DestinationHost::parse(domain), Some(DestinationHost::Ip(_))) {
return None;
}
let domain = normalize_domain(domain)?;
if subdomain {
Some(Self::Subdomain(domain))
} else {
Some(Self::Exact(domain))
}
}
fn matches(&self, destination: &DestinationHost) -> bool {
let DestinationHost::Domain(domain) = destination else {
return false;
};
match self {
Self::Exact(expected) => domain == expected,
Self::Subdomain(suffix) => domain
.strip_suffix(suffix)
.is_some_and(|prefix| prefix.ends_with('.')),
}
}
}
fn normalize_domain(domain: &str) -> Option<String> {
let domain = domain.strip_suffix('.').unwrap_or(domain);
if domain.is_empty() || domain.len() > 253 || !domain.is_ascii() {
return None;
}
for label in domain.split('.') {
if label.is_empty()
|| label.len() > 63
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|| !label.as_bytes()[0].is_ascii_alphanumeric()
|| !label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
{
return None;
}
}
Some(domain.to_ascii_lowercase())
}
struct Rule {
name: String,
action: Action,
users: BTreeSet<String>,
networks: Vec<IpNet>,
domains: Vec<DomainPattern>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -92,11 +176,19 @@ pub(crate) struct AclOutcome {
impl AclOutcome {
fn rule(rule: &Rule) -> Self {
let reason = match (rule.users.is_empty(), rule.networks.is_empty()) {
(false, false) => "user_and_network",
(false, true) => "user",
(true, false) => "network",
(true, true) => unreachable!("empty ACL rules are rejected"),
let reason = match (
rule.users.is_empty(),
rule.networks.is_empty(),
rule.domains.is_empty(),
) {
(false, false, false) => "user_and_network_and_domain",
(false, false, true) => "user_and_network",
(false, true, false) => "user_and_domain",
(false, true, true) => "user",
(true, false, false) => "network_and_domain",
(true, false, true) => "network",
(true, true, false) => "domain",
(true, true, true) => unreachable!("empty ACL rules are rejected"),
};
Self {
decision: match rule.action {
@@ -156,7 +248,12 @@ impl Acl {
})
}
pub fn authorize(&self, peer: IpAddr, headers: &HeaderMap) -> AclOutcome {
pub fn authorize(
&self,
peer: IpAddr,
headers: &HeaderMap,
destination: Option<&DestinationHost>,
) -> AclOutcome {
let credentials = match parse_basic_credentials(headers) {
Credentials::Missing => None,
Credentials::Invalid => return AclOutcome::challenge("invalid_credentials"),
@@ -177,7 +274,7 @@ impl Acl {
};
for rule in &self.rules {
if rule_matches(rule, peer, authenticated_user.as_deref()) {
if rule_matches(rule, peer, authenticated_user.as_deref(), destination) {
return AclOutcome::rule(rule);
}
}
@@ -197,7 +294,7 @@ fn compile_rule(rule: RuleConfig, users: &BTreeMap<String, User>) -> Result<Rule
if rule.name.trim().is_empty() {
return Err(AclError::EmptyRuleName);
}
if rule.users.is_empty() && rule.networks.is_empty() {
if rule.users.is_empty() && rule.networks.is_empty() && rule.domains.is_empty() {
return Err(AclError::EmptyRule(rule.name));
}
@@ -219,20 +316,41 @@ fn compile_rule(rule: RuleConfig, users: &BTreeMap<String, User>) -> Result<Rule
networks.push(parsed);
}
let mut domains = Vec::with_capacity(rule.domains.len());
for domain in rule.domains {
let parsed = DomainPattern::parse(&domain).ok_or_else(|| AclError::InvalidDomain {
rule: rule.name.clone(),
domain,
})?;
domains.push(parsed);
}
Ok(Rule {
name: rule.name,
action: rule.action,
users: rule.users.into_iter().collect(),
networks,
domains,
})
}
fn rule_matches(rule: &Rule, peer: IpAddr, authenticated_user: Option<&str>) -> bool {
fn rule_matches(
rule: &Rule,
peer: IpAddr,
authenticated_user: Option<&str>,
destination: Option<&DestinationHost>,
) -> bool {
let user_matches = rule.users.is_empty()
|| authenticated_user.is_some_and(|username| rule.users.contains(username));
let network_matches =
rule.networks.is_empty() || rule.networks.iter().any(|network| network.contains(&peer));
user_matches && network_matches
let domain_matches = rule.domains.is_empty()
|| destination.is_some_and(|destination| {
rule.domains
.iter()
.any(|pattern| pattern.matches(destination))
});
user_matches && network_matches && domain_matches
}
enum Credentials {
@@ -328,6 +446,8 @@ pub enum AclError {
UnknownUser { rule: String, username: String },
#[error("ACL rule {rule:?} has invalid network {network:?}")]
InvalidNetwork { rule: String, network: String },
#[error("ACL rule {rule:?} has invalid domain pattern {domain:?}")]
InvalidDomain { rule: String, domain: String },
#[error("ACL user {0:?} has an invalid or unsupported salted SHA-256 password hash")]
InvalidPasswordHash(String),
}
@@ -347,6 +467,7 @@ mod tests {
action,
users: vec![username.to_owned()],
networks: vec![],
domains: vec![],
}
}
@@ -374,6 +495,7 @@ mod tests {
let outcome = acl.authorize(
"127.0.0.1".parse().unwrap(),
&headers("alice", "correct horse battery staple"),
None,
);
assert_eq!(outcome.decision, Decision::Allow);
assert_eq!(outcome.reason, "user");
@@ -392,7 +514,7 @@ mod tests {
headers("alice", "wrong"),
malformed,
] {
let outcome = acl.authorize("127.0.0.1".parse().unwrap(), &headers);
let outcome = acl.authorize("127.0.0.1".parse().unwrap(), &headers, None);
assert_eq!(outcome.decision, Decision::Challenge);
}
}
@@ -407,14 +529,15 @@ mod tests {
action: Action::Allow,
users: vec![],
networks: vec!["127.0.0.0/8".to_owned(), "::1/128".to_owned()],
domains: vec![],
}],
})
.unwrap();
let allowed = acl.authorize("127.0.0.2".parse().unwrap(), &HeaderMap::new());
let allowed = acl.authorize("127.0.0.2".parse().unwrap(), &HeaderMap::new(), None);
assert_eq!(allowed.decision, Decision::Allow);
let denied = acl.authorize("192.0.2.1".parse().unwrap(), &HeaderMap::new());
let denied = acl.authorize("192.0.2.1".parse().unwrap(), &HeaderMap::new(), None);
assert_eq!(denied.decision, Decision::Deny);
}
@@ -430,6 +553,7 @@ mod tests {
action: Action::Deny,
users: vec!["alice".to_owned()],
networks: vec!["192.0.2.0/24".to_owned()],
domains: vec![],
},
user_rule("allow-alice", "alice", Action::Allow),
],
@@ -439,6 +563,7 @@ mod tests {
let denied = acl.authorize(
"192.0.2.10".parse().unwrap(),
&headers("alice", "correct horse battery staple"),
None,
);
assert_eq!(denied.decision, Decision::Deny);
assert_eq!(denied.rule.as_deref(), Some("deny-alice-off-network"));
@@ -446,10 +571,162 @@ mod tests {
let allowed = acl.authorize(
"198.51.100.10".parse().unwrap(),
&headers("alice", "correct horse battery staple"),
None,
);
assert_eq!(allowed.decision, Decision::Allow);
}
fn destination(host: &str) -> DestinationHost {
DestinationHost::parse(host).unwrap()
}
fn domain_acl(default_action: Action, action: Action, domains: &[&str]) -> Acl {
Acl::from_config(AclConfig {
default_action,
users: BTreeMap::new(),
rules: vec![RuleConfig {
name: "domain-rule".to_owned(),
action,
users: vec![],
networks: vec![],
domains: domains.iter().map(|domain| (*domain).to_owned()).collect(),
}],
})
.unwrap()
}
#[test]
fn normalizes_domains_and_matches_wildcard_label_boundaries() {
let exact = DomainPattern::parse("ExAmple.COM.").unwrap();
assert!(exact.matches(&destination("example.com")));
assert!(exact.matches(&destination("EXAMPLE.COM.")));
assert!(!exact.matches(&destination("www.example.com")));
let wildcard = DomainPattern::parse("*.ExAmple.COM.").unwrap();
assert!(wildcard.matches(&destination("www.example.com")));
assert!(wildcard.matches(&destination("a.b.example.com")));
assert!(!wildcard.matches(&destination("example.com")));
assert!(!wildcard.matches(&destination("evil-example.com")));
assert!(!wildcard.matches(&destination("127.0.0.1")));
}
#[test]
fn enforces_domain_whitelist_including_ip_literal_denial() {
let acl = domain_acl(
Action::Deny,
Action::Allow,
&["example.com", "*.rust-lang.org"],
);
let allowed = acl.authorize(
"192.0.2.1".parse().unwrap(),
&HeaderMap::new(),
Some(&destination("example.com")),
);
assert_eq!(allowed.decision, Decision::Allow);
assert_eq!(allowed.reason, "domain");
let nested = acl.authorize(
"192.0.2.1".parse().unwrap(),
&HeaderMap::new(),
Some(&destination("doc.rust-lang.org")),
);
assert_eq!(nested.decision, Decision::Allow);
for host in ["rust-lang.org", "unrelated.example", "127.0.0.1"] {
let denied = acl.authorize(
"192.0.2.1".parse().unwrap(),
&HeaderMap::new(),
Some(&destination(host)),
);
assert_eq!(denied.decision, Decision::Deny, "host: {host}");
}
}
#[test]
fn enforces_domain_blacklist_without_matching_ip_literals() {
let acl = domain_acl(Action::Allow, Action::Deny, &["ads.example.net"]);
let denied = acl.authorize(
"192.0.2.1".parse().unwrap(),
&HeaderMap::new(),
Some(&destination("ADS.EXAMPLE.NET.")),
);
assert_eq!(denied.decision, Decision::Deny);
for host in ["www.example.net", "192.0.2.10"] {
let allowed = acl.authorize(
"192.0.2.1".parse().unwrap(),
&HeaderMap::new(),
Some(&destination(host)),
);
assert_eq!(allowed.decision, Decision::Allow, "host: {host}");
}
}
#[test]
fn combines_user_and_domain_selectors() {
let password_hash = hash_password(PASSWORD).unwrap();
let acl = Acl::from_config(AclConfig {
default_action: Action::Deny,
users: BTreeMap::from([("alice".to_owned(), UserConfig { password_hash })]),
rules: vec![RuleConfig {
name: "alice-example".to_owned(),
action: Action::Allow,
users: vec!["alice".to_owned()],
networks: vec![],
domains: vec!["example.com".to_owned()],
}],
})
.unwrap();
let allowed = acl.authorize(
"127.0.0.1".parse().unwrap(),
&headers("alice", "correct horse battery staple"),
Some(&destination("example.com")),
);
assert_eq!(allowed.decision, Decision::Allow);
assert_eq!(allowed.reason, "user_and_domain");
let denied = acl.authorize(
"127.0.0.1".parse().unwrap(),
&headers("alice", "correct horse battery staple"),
Some(&destination("other.example")),
);
assert_eq!(denied.decision, Decision::Deny);
let challenged = acl.authorize(
"127.0.0.1".parse().unwrap(),
&HeaderMap::new(),
Some(&destination("example.com")),
);
assert_eq!(challenged.decision, Decision::Challenge);
}
#[test]
fn rejects_invalid_domain_patterns() {
for pattern in [
"*",
"*.",
"example.*",
".example.com",
"example..com",
"127.0.0.1",
"[::1]",
"bad_domain.example",
"-bad.example",
"bad-.example",
"éxample.com",
] {
assert!(
DomainPattern::parse(pattern).is_none(),
"pattern: {pattern}"
);
}
let long_label = format!("{}.example", "a".repeat(64));
assert!(DomainPattern::parse(&long_label).is_none());
}
#[test]
fn generates_salted_hashes_that_verify() {
let first = hash_password(PASSWORD).unwrap();
@@ -474,6 +751,7 @@ mod tests {
action: Action::Allow,
users: vec![],
networks: vec![],
domains: vec![],
}],
});
assert!(matches!(empty, Err(AclError::EmptyRule(_))));
+30
View File
@@ -94,6 +94,8 @@ pub(crate) struct RuleConfig {
pub users: Vec<String>,
#[serde(default)]
pub networks: Vec<String>,
#[serde(default)]
pub domains: Vec<String>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
@@ -142,6 +144,34 @@ acl:
assert_eq!(config.upstream_timeout, Duration::from_secs(30));
}
#[test]
fn loads_domain_rules() {
let valid = Config::from_yaml(
r#"
acl:
rules:
- name: domains
action: allow
domains: ["example.com", "*.rust-lang.org"]
"#,
);
assert!(valid.is_ok());
let invalid = Config::from_yaml(
r#"
acl:
rules:
- name: invalid-domain
action: deny
domains: ["example.*"]
"#,
);
assert!(matches!(
invalid,
Err(ConfigError::Acl(AclError::InvalidDomain { .. }))
));
}
#[test]
fn rejects_unknown_fields_and_zero_timeouts() {
let unknown = Config::from_yaml("acl: {}\nunknown: true\n");
+69 -5
View File
@@ -28,7 +28,7 @@ use tracing_opentelemetry::OpenTelemetrySpanExt;
use crate::{
BoxError,
acl::{Acl, Decision},
acl::{Acl, Decision, DestinationHost},
config::Config,
metrics::Metrics,
telemetry::{HeaderExtractor, HeaderInjector, remove_hop_by_hop_headers},
@@ -177,7 +177,16 @@ fn authorize_request(
peer: IpAddr,
state: &ProxyState,
) -> Result<Option<ProxyResponse>, ProxyError> {
let outcome = state.acl.authorize(peer, request.headers());
let destination = request.uri().authority().and_then(|authority| {
if authority.as_str().contains('@') {
None
} else {
DestinationHost::parse(authority.host())
}
});
let outcome = state
.acl
.authorize(peer, request.headers(), destination.as_ref());
state
.metrics
@@ -671,7 +680,10 @@ mod tests {
let (mut sender, connection_task) = connect_client(proxy_addr).await;
let request = Request::builder()
.method(Method::GET)
.uri(format!("http://{upstream_addr}/hello?q=1"))
.uri(format!(
"http://localhost:{}/hello?q=1",
upstream_addr.port()
))
.header(HOST, "wrong.invalid")
.header(PROXY_AUTHORIZATION, basic_authorization())
.body(Full::new(Bytes::new()))
@@ -685,7 +697,7 @@ mod tests {
upstream_task.abort();
let observed = observed.lock().unwrap();
assert_eq!(observed.target, "/hello?q=1");
assert_eq!(observed.host, upstream_addr.to_string());
assert_eq!(observed.host, format!("localhost:{}", upstream_addr.port()));
assert!(!observed.forwarded_proxy_authorization);
drop(observed);
@@ -710,7 +722,7 @@ mod tests {
let (mut sender, connection_task) = connect_client(proxy_addr).await;
let request = Request::builder()
.method(Method::CONNECT)
.uri(echo_addr.to_string())
.uri(format!("localhost:{}", echo_addr.port()))
.body(Full::new(Bytes::new()))
.unwrap();
@@ -759,6 +771,56 @@ mod tests {
connection_task.abort();
}
#[tokio::test]
async fn denies_blacklisted_http_domain_before_upstream_connection() {
let acl = Acl::from_config(AclConfig {
default_action: Action::Allow,
users: BTreeMap::new(),
rules: vec![RuleConfig {
name: "blocked-domain".to_owned(),
action: Action::Deny,
users: vec![],
networks: vec![],
domains: vec!["blocked.example".to_owned()],
}],
})
.unwrap();
let (proxy_addr, proxy_task) = spawn_proxy_with_acl(acl).await;
let (mut sender, connection_task) = connect_client(proxy_addr).await;
let request = Request::builder()
.method(Method::GET)
.uri("http://blocked.example/")
.body(Full::new(Bytes::new()))
.unwrap();
let response = sender.send_request(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
response.into_body().collect().await.unwrap();
proxy_task.abort();
connection_task.abort();
}
#[tokio::test]
async fn domain_whitelist_rejects_ip_literal_connect() {
let echo_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let echo_addr = echo_listener.local_addr().unwrap();
let (proxy_addr, proxy_task) = spawn_proxy_with_acl(localhost_acl()).await;
let (mut sender, connection_task) = connect_client(proxy_addr).await;
let request = Request::builder()
.method(Method::CONNECT)
.uri(echo_addr.to_string())
.body(Full::new(Bytes::new()))
.unwrap();
let response = sender.send_request(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
response.into_body().collect().await.unwrap();
proxy_task.abort();
connection_task.abort();
}
fn basic_authorization() -> String {
format!(
"Basic {}",
@@ -781,6 +843,7 @@ mod tests {
action: Action::Allow,
users: vec!["alice".to_owned()],
networks: vec![],
domains: vec!["localhost".to_owned()],
}],
})
.unwrap()
@@ -795,6 +858,7 @@ mod tests {
action: Action::Allow,
users: vec![],
networks: vec!["127.0.0.1/32".to_owned(), "::1/128".to_owned()],
domains: vec!["localhost".to_owned()],
}],
})
.unwrap()