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
+49 -6
View File
@@ -8,7 +8,7 @@ Hermes is a minimal HTTP/1.1 forward proxy written in Rust. It supports streamin
- HTTPS and arbitrary TCP tunneling with `CONNECT host:port` - HTTPS and arbitrary TCP tunneling with `CONNECT host:port`
- Streaming request and response bodies - Streaming request and response bodies
- Upstream connection pooling and configurable timeouts - Upstream connection pooling and configurable timeouts
- Ordered allow/deny ACLs using Basic authentication and/or client IP networks - Ordered allow/deny ACLs using Basic authentication, client IP networks, and destination domains
- Fast salted SHA-256 password hashes; plaintext passwords are never stored in YAML - Fast salted SHA-256 password hashes; plaintext passwords are never stored in YAML
- Removal of standard and `Connection`-nominated hop-by-hop headers - Removal of standard and `Connection`-nominated hop-by-hop headers
- W3C Trace Context extraction and injection for plain HTTP traffic - W3C Trace Context extraction and injection for plain HTTP traffic
@@ -57,6 +57,10 @@ acl:
- name: office-network - name: office-network
action: allow action: allow
networks: ["10.20.0.0/16", "2001:db8:20::/48"] networks: ["10.20.0.0/16", "2001:db8:20::/48"]
- name: approved-domains
action: allow
domains: ["docs.example.com", "example.org", "*.rust-lang.org"]
``` ```
| Field | Default | Description | | Field | Default | Description |
@@ -68,22 +72,61 @@ acl:
| `acl.users` | empty | Usernames and salted SHA-256 password hashes | | `acl.users` | empty | Usernames and salted SHA-256 password hashes |
| `acl.rules` | empty | Ordered ACL rules | | `acl.rules` | empty | Ordered ACL rules |
Configuration is loaded and validated at startup. Unknown fields, invalid CIDRs, duplicate or empty rules, references to unknown users, and unsupported password hashes prevent startup. Restart Hermes to apply configuration changes. Configuration is loaded and validated at startup. Unknown fields, invalid CIDRs or domain patterns, duplicate or empty rules, references to unknown users, and unsupported password hashes prevent startup. Restart Hermes to apply configuration changes.
`RUST_LOG` controls log filtering and defaults to `info`, for example `RUST_LOG=hermes=debug`. `RUST_LOG` controls log filtering and defaults to `info`, for example `RUST_LOG=hermes=debug`.
### ACL rules ### ACL rules
- Rules are evaluated in order; the **first match wins**. - Rules are evaluated in order; the **first match wins**.
- Separate user and network rules provide Basic-auth OR IP-address authorization. - Separate user, network, and domain rules provide Basic-auth, source-IP, or destination-domain authorization.
- If a rule contains both `users` and `networks`, both selectors must match. Entries within each selector are ORed. - If a rule contains multiple selector types, every specified selector must match. Entries within each selector are ORed.
- A rule must contain at least one selector; accidental match-all rules are rejected. - A rule must contain at least one selector; accidental match-all rules are rejected.
- IP matching uses the actual TCP peer address. `X-Forwarded-For` is never trusted. - `networks` uses the actual client TCP peer address. `X-Forwarded-For` is never trusted.
- `domains` uses the request-target authority, never the downstream `Host` header, and works for both HTTP and `CONNECT`.
- Requests without required credentials receive `407 Proxy Authentication Required` and a Basic `Proxy-Authenticate` challenge. - Requests without required credentials receive `407 Proxy Authentication Required` and a Basic `Proxy-Authenticate` challenge.
- Invalid credentials receive 407; authenticated or IP-identified clients denied by policy receive 403. - Invalid credentials receive 407; clients denied by policy receive 403.
- ACLs are enforced before upstream HTTP requests or `CONNECT` connections are made. - ACLs are enforced before upstream HTTP requests or `CONNECT` connections are made.
- `Proxy-Authorization` is consumed by Hermes and is never forwarded upstream. - `Proxy-Authorization` is consumed by Hermes and is never forwarded upstream.
### Destination domains
Domain entries support exact names and leading-subdomain wildcards:
- `example.com` matches only that name.
- `*.example.com` matches `www.example.com` and `a.b.example.com`, but not the apex `example.com` or a lookalike such as `evil-example.com`.
- Matching is case-insensitive, ignores ports, and normalizes one trailing DNS root dot.
- Single-label names such as `localhost` are supported. International domains must use ASCII/punycode form.
- IP literals never match domain patterns.
A domain whitelist uses `default_action: deny` with allow rules:
```yaml
acl:
default_action: deny
rules:
- name: approved-domains
action: allow
domains: ["example.com", "*.rust-lang.org"]
```
A domain blacklist uses deny rules, typically with `default_action: allow`:
```yaml
acl:
default_action: allow
rules:
- name: blocked-domains
action: deny
domains: ["ads.example.net", "*.tracker.example"]
```
Domain selectors can be combined with `users` and/or `networks` when client and destination restrictions must both apply. A domain-only allow rule does not implicitly require authentication.
**Domain matching is not destination-IP filtering.** A hostname blacklist can be bypassed by connecting directly to an IP address. Hermes also does not inspect resolved addresses or provide DNS-rebinding protection. A domain-only whitelist with `default_action: deny` rejects IP-literal destinations unless another rule allows them.
### Basic authentication
For Basic authentication, curl can supply proxy credentials with `--proxy-user`: For Basic authentication, curl can supply proxy credentials with `--proxy-user`:
```sh ```sh
+11
View File
@@ -22,3 +22,14 @@ acl:
# - name: alice # - name: alice
# action: allow # action: allow
# users: ["alice"] # users: ["alice"]
#
# Add destination domains to any rule to allow only those destinations:
#
# - name: localhost-approved-domains
# action: allow
# networks: ["127.0.0.1/32", "::1/128"]
# domains: ["example.com", "*.rust-lang.org"]
#
# `*.example.com` matches subdomains but not `example.com` itself. Domain
# patterns never match IP literals, and domain blacklists do not block direct
# connections to destination IP addresses.
+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 { struct Rule {
name: String, name: String,
action: Action, action: Action,
users: BTreeSet<String>, users: BTreeSet<String>,
networks: Vec<IpNet>, networks: Vec<IpNet>,
domains: Vec<DomainPattern>,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -92,11 +176,19 @@ pub(crate) struct AclOutcome {
impl AclOutcome { impl AclOutcome {
fn rule(rule: &Rule) -> Self { fn rule(rule: &Rule) -> Self {
let reason = match (rule.users.is_empty(), rule.networks.is_empty()) { let reason = match (
(false, false) => "user_and_network", rule.users.is_empty(),
(false, true) => "user", rule.networks.is_empty(),
(true, false) => "network", rule.domains.is_empty(),
(true, true) => unreachable!("empty ACL rules are rejected"), ) {
(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 { Self {
decision: match rule.action { 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) { let credentials = match parse_basic_credentials(headers) {
Credentials::Missing => None, Credentials::Missing => None,
Credentials::Invalid => return AclOutcome::challenge("invalid_credentials"), Credentials::Invalid => return AclOutcome::challenge("invalid_credentials"),
@@ -177,7 +274,7 @@ impl Acl {
}; };
for rule in &self.rules { 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); 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() { if rule.name.trim().is_empty() {
return Err(AclError::EmptyRuleName); 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)); return Err(AclError::EmptyRule(rule.name));
} }
@@ -219,20 +316,41 @@ fn compile_rule(rule: RuleConfig, users: &BTreeMap<String, User>) -> Result<Rule
networks.push(parsed); 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 { Ok(Rule {
name: rule.name, name: rule.name,
action: rule.action, action: rule.action,
users: rule.users.into_iter().collect(), users: rule.users.into_iter().collect(),
networks, 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() let user_matches = rule.users.is_empty()
|| authenticated_user.is_some_and(|username| rule.users.contains(username)); || authenticated_user.is_some_and(|username| rule.users.contains(username));
let network_matches = let network_matches =
rule.networks.is_empty() || rule.networks.iter().any(|network| network.contains(&peer)); 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 { enum Credentials {
@@ -328,6 +446,8 @@ pub enum AclError {
UnknownUser { rule: String, username: String }, UnknownUser { rule: String, username: String },
#[error("ACL rule {rule:?} has invalid network {network:?}")] #[error("ACL rule {rule:?} has invalid network {network:?}")]
InvalidNetwork { rule: String, network: String }, 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")] #[error("ACL user {0:?} has an invalid or unsupported salted SHA-256 password hash")]
InvalidPasswordHash(String), InvalidPasswordHash(String),
} }
@@ -347,6 +467,7 @@ mod tests {
action, action,
users: vec![username.to_owned()], users: vec![username.to_owned()],
networks: vec![], networks: vec![],
domains: vec![],
} }
} }
@@ -374,6 +495,7 @@ mod tests {
let outcome = acl.authorize( let outcome = acl.authorize(
"127.0.0.1".parse().unwrap(), "127.0.0.1".parse().unwrap(),
&headers("alice", "correct horse battery staple"), &headers("alice", "correct horse battery staple"),
None,
); );
assert_eq!(outcome.decision, Decision::Allow); assert_eq!(outcome.decision, Decision::Allow);
assert_eq!(outcome.reason, "user"); assert_eq!(outcome.reason, "user");
@@ -392,7 +514,7 @@ mod tests {
headers("alice", "wrong"), headers("alice", "wrong"),
malformed, 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); assert_eq!(outcome.decision, Decision::Challenge);
} }
} }
@@ -407,14 +529,15 @@ mod tests {
action: Action::Allow, action: Action::Allow,
users: vec![], users: vec![],
networks: vec!["127.0.0.0/8".to_owned(), "::1/128".to_owned()], networks: vec!["127.0.0.0/8".to_owned(), "::1/128".to_owned()],
domains: vec![],
}], }],
}) })
.unwrap(); .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); 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); assert_eq!(denied.decision, Decision::Deny);
} }
@@ -430,6 +553,7 @@ mod tests {
action: Action::Deny, action: Action::Deny,
users: vec!["alice".to_owned()], users: vec!["alice".to_owned()],
networks: vec!["192.0.2.0/24".to_owned()], networks: vec!["192.0.2.0/24".to_owned()],
domains: vec![],
}, },
user_rule("allow-alice", "alice", Action::Allow), user_rule("allow-alice", "alice", Action::Allow),
], ],
@@ -439,6 +563,7 @@ mod tests {
let denied = acl.authorize( let denied = acl.authorize(
"192.0.2.10".parse().unwrap(), "192.0.2.10".parse().unwrap(),
&headers("alice", "correct horse battery staple"), &headers("alice", "correct horse battery staple"),
None,
); );
assert_eq!(denied.decision, Decision::Deny); assert_eq!(denied.decision, Decision::Deny);
assert_eq!(denied.rule.as_deref(), Some("deny-alice-off-network")); assert_eq!(denied.rule.as_deref(), Some("deny-alice-off-network"));
@@ -446,10 +571,162 @@ mod tests {
let allowed = acl.authorize( let allowed = acl.authorize(
"198.51.100.10".parse().unwrap(), "198.51.100.10".parse().unwrap(),
&headers("alice", "correct horse battery staple"), &headers("alice", "correct horse battery staple"),
None,
); );
assert_eq!(allowed.decision, Decision::Allow); 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] #[test]
fn generates_salted_hashes_that_verify() { fn generates_salted_hashes_that_verify() {
let first = hash_password(PASSWORD).unwrap(); let first = hash_password(PASSWORD).unwrap();
@@ -474,6 +751,7 @@ mod tests {
action: Action::Allow, action: Action::Allow,
users: vec![], users: vec![],
networks: vec![], networks: vec![],
domains: vec![],
}], }],
}); });
assert!(matches!(empty, Err(AclError::EmptyRule(_)))); assert!(matches!(empty, Err(AclError::EmptyRule(_))));
+30
View File
@@ -94,6 +94,8 @@ pub(crate) struct RuleConfig {
pub users: Vec<String>, pub users: Vec<String>,
#[serde(default)] #[serde(default)]
pub networks: Vec<String>, pub networks: Vec<String>,
#[serde(default)]
pub domains: Vec<String>,
} }
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
@@ -142,6 +144,34 @@ acl:
assert_eq!(config.upstream_timeout, Duration::from_secs(30)); 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] #[test]
fn rejects_unknown_fields_and_zero_timeouts() { fn rejects_unknown_fields_and_zero_timeouts() {
let unknown = Config::from_yaml("acl: {}\nunknown: true\n"); let unknown = Config::from_yaml("acl: {}\nunknown: true\n");
+69 -5
View File
@@ -28,7 +28,7 @@ use tracing_opentelemetry::OpenTelemetrySpanExt;
use crate::{ use crate::{
BoxError, BoxError,
acl::{Acl, Decision}, acl::{Acl, Decision, DestinationHost},
config::Config, config::Config,
metrics::Metrics, metrics::Metrics,
telemetry::{HeaderExtractor, HeaderInjector, remove_hop_by_hop_headers}, telemetry::{HeaderExtractor, HeaderInjector, remove_hop_by_hop_headers},
@@ -177,7 +177,16 @@ fn authorize_request(
peer: IpAddr, peer: IpAddr,
state: &ProxyState, state: &ProxyState,
) -> Result<Option<ProxyResponse>, ProxyError> { ) -> 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 state
.metrics .metrics
@@ -671,7 +680,10 @@ mod tests {
let (mut sender, connection_task) = connect_client(proxy_addr).await; let (mut sender, connection_task) = connect_client(proxy_addr).await;
let request = Request::builder() let request = Request::builder()
.method(Method::GET) .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(HOST, "wrong.invalid")
.header(PROXY_AUTHORIZATION, basic_authorization()) .header(PROXY_AUTHORIZATION, basic_authorization())
.body(Full::new(Bytes::new())) .body(Full::new(Bytes::new()))
@@ -685,7 +697,7 @@ mod tests {
upstream_task.abort(); upstream_task.abort();
let observed = observed.lock().unwrap(); let observed = observed.lock().unwrap();
assert_eq!(observed.target, "/hello?q=1"); 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); assert!(!observed.forwarded_proxy_authorization);
drop(observed); drop(observed);
@@ -710,7 +722,7 @@ mod tests {
let (mut sender, connection_task) = connect_client(proxy_addr).await; let (mut sender, connection_task) = connect_client(proxy_addr).await;
let request = Request::builder() let request = Request::builder()
.method(Method::CONNECT) .method(Method::CONNECT)
.uri(echo_addr.to_string()) .uri(format!("localhost:{}", echo_addr.port()))
.body(Full::new(Bytes::new())) .body(Full::new(Bytes::new()))
.unwrap(); .unwrap();
@@ -759,6 +771,56 @@ mod tests {
connection_task.abort(); 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 { fn basic_authorization() -> String {
format!( format!(
"Basic {}", "Basic {}",
@@ -781,6 +843,7 @@ mod tests {
action: Action::Allow, action: Action::Allow,
users: vec!["alice".to_owned()], users: vec!["alice".to_owned()],
networks: vec![], networks: vec![],
domains: vec!["localhost".to_owned()],
}], }],
}) })
.unwrap() .unwrap()
@@ -795,6 +858,7 @@ mod tests {
action: Action::Allow, action: Action::Allow,
users: vec![], users: vec![],
networks: vec!["127.0.0.1/32".to_owned(), "::1/128".to_owned()], networks: vec!["127.0.0.1/32".to_owned(), "::1/128".to_owned()],
domains: vec!["localhost".to_owned()],
}], }],
}) })
.unwrap() .unwrap()