From b8a1761a7226ab3d895d289dce228e2f96c14244 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Thu, 29 Oct 2026 13:21:58 +0000 Subject: [PATCH] Add destination port ACL rules --- README.md | 19 +++--- examples/hermes.yaml | 4 +- src/acl.rs | 157 ++++++++++++++++++++++++++++++++++++++----- src/config.rs | 39 +++++++++++ src/proxy.rs | 97 +++++++++++++++++++++++--- src/socks5.rs | 23 ++++++- 6 files changed, 302 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 2feecd6..811ab50 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Hermes is a minimal HTTP/1.1 forward proxy written in Rust. It supports streamin - SOCKS5 IPv4, IPv6, and domain destinations with shared ACL enforcement - Streaming request and response bodies - Upstream connection pooling and configurable timeouts -- Ordered allow/deny ACLs using Basic authentication, client IP networks, and destination domains +- Ordered allow/deny ACLs using Basic authentication, client IP networks, destination domains, and destination ports - Fast salted SHA-256 password hashes; plaintext passwords are never stored in YAML - Removal of standard and `Connection`-nominated hop-by-hop headers - W3C Trace Context extraction and injection for plain HTTP traffic @@ -109,6 +109,7 @@ acl: - name: approved-domains action: allow domains: ["docs.example.com", "example.org", "*.rust-lang.org"] + ports: [80, 443] ``` | Field | Default | Description | @@ -123,30 +124,31 @@ acl: | `acl.users` | empty | Usernames and salted SHA-256 password hashes | | `acl.rules` | empty | Ordered ACL rules | -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. +Configuration is loaded and validated at startup. Unknown fields, invalid CIDRs, domain patterns, or ports, duplicate or empty rules, references to unknown users, and unsupported password hashes prevent startup. Restart Hermes to apply configuration changes. `telemetry.log_filter` controls log filtering and defaults to `info`, for example `log_filter: "hermes=debug"`. `RUST_LOG` is not read. ### ACL rules - Rules are evaluated in order; the **first match wins**. -- Separate user, network, and domain rules provide Basic-auth, source-IP, or destination-domain authorization. +- Separate user, network, domain, and port rules provide Basic-auth, source-IP, destination-domain, or destination-port authorization. - 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. - `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`. +- `ports` matches the destination port. Plain HTTP defaults to port 80 when omitted; `CONNECT` and SOCKS5 provide an explicit port. - Requests without required credentials receive `407 Proxy Authentication Required` and a Basic `Proxy-Authenticate` challenge. - Invalid credentials receive 407; clients denied by policy receive 403. - ACLs are enforced before upstream HTTP requests or `CONNECT` connections are made. - `Proxy-Authorization` is consumed by Hermes and is never forwarded upstream. -### Destination domains +### Destination domains and ports 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. +- Domain matching is case-insensitive and normalizes one trailing DNS root dot. Use `ports` when port restrictions are also required. - Single-label names such as `localhost` are supported. International domains must use ASCII/punycode form. - IP literals never match domain patterns. @@ -159,6 +161,7 @@ acl: - name: approved-domains action: allow domains: ["example.com", "*.rust-lang.org"] + ports: [80, 443] ``` A domain blacklist uses deny rules, typically with `default_action: allow`: @@ -172,7 +175,7 @@ acl: 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 and port 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. Multiple ports are ORed; different selector types are ANDed. Ports must be in the range 1–65535, and omitting `ports` matches any destination port. **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. @@ -231,7 +234,7 @@ curl --socks5-hostname 127.0.0.1:1080 \ http://example.com/ ``` -SOCKS5 reuses the ACL's salted password hashes and applies `users`, `networks`, and `domains` before upstream dialing. Username/password negotiation is selected when required by the ACL; otherwise no-auth is used. Authentication is cleartext on the network, so restrict the listener to localhost or trusted networks. ACL denial returns SOCKS reply `0x02` before any upstream connection. +SOCKS5 reuses the ACL's salted password hashes and applies `users`, `networks`, `domains`, and `ports` before upstream dialing. Username/password negotiation is selected when required by the ACL; otherwise no-auth is used. Authentication is cleartext on the network, so restrict the listener to localhost or trusted networks. ACL denial returns SOCKS reply `0x02` before any upstream connection. Only CONNECT is supported. SOCKS4, BIND, and UDP ASSOCIATE are not implemented. Docker users can publish the listener with `-p 127.0.0.1:1080:1080` when it is enabled. @@ -300,7 +303,7 @@ Hermes uses standard HTTP instrument names where applicable and adds proxy-speci - `hermes.proxy.tunnel.bytes` - `hermes.proxy.acl.decisions` -Metric attributes are deliberately bounded. Tunnel and ACL metrics include `proxy.protocol` to distinguish HTTP, HTTP CONNECT, and SOCKS5. Raw URLs, destination hostnames, usernames, rule names, and client addresses are not metric attributes. +Metric attributes are deliberately bounded. Tunnel and ACL metrics include `proxy.protocol` to distinguish HTTP, HTTP CONNECT, and SOCKS5. Raw URLs, destination hostnames, destination ports, usernames, rule names, and client addresses are not metric attributes. ### Logs diff --git a/examples/hermes.yaml b/examples/hermes.yaml index f2170d0..9e06f1f 100644 --- a/examples/hermes.yaml +++ b/examples/hermes.yaml @@ -49,7 +49,9 @@ acl: # action: allow # networks: ["127.0.0.1/32", "::1/128"] # domains: ["example.com", "*.rust-lang.org"] +# ports: [80, 443] # # `*.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. +# connections to destination IP addresses. `ports` restricts destination ports; +# plain HTTP defaults to 80 when no port is specified. diff --git a/src/acl.rs b/src/acl.rs index 2ee3054..c8f8039 100644 --- a/src/acl.rs +++ b/src/acl.rs @@ -148,6 +148,7 @@ struct Rule { users: BTreeSet, networks: Vec, domains: Vec, + ports: BTreeSet, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -180,15 +181,24 @@ impl AclOutcome { rule.users.is_empty(), rule.networks.is_empty(), rule.domains.is_empty(), + rule.ports.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"), + (false, false, false, false) => "user_and_network_and_domain_and_port", + (false, false, false, true) => "user_and_network_and_domain", + (false, false, true, false) => "user_and_network_and_port", + (false, false, true, true) => "user_and_network", + (false, true, false, false) => "user_and_domain_and_port", + (false, true, false, true) => "user_and_domain", + (false, true, true, false) => "user_and_port", + (false, true, true, true) => "user", + (true, false, false, false) => "network_and_domain_and_port", + (true, false, false, true) => "network_and_domain", + (true, false, true, false) => "network_and_port", + (true, false, true, true) => "network", + (true, true, false, false) => "domain_and_port", + (true, true, false, true) => "domain", + (true, true, true, false) => "port", + (true, true, true, true) => unreachable!("empty ACL rules are rejected"), }; Self { decision: match rule.action { @@ -262,10 +272,11 @@ impl Acl { &self, peer: IpAddr, destination: Option<&DestinationHost>, + port: Option, authenticated_user: Option<&str>, ) -> AclOutcome { for rule in &self.rules { - if rule_matches(rule, peer, authenticated_user, destination) { + if rule_matches(rule, peer, authenticated_user, destination, port) { return AclOutcome::rule(rule); } } @@ -285,6 +296,7 @@ impl Acl { peer: IpAddr, headers: &HeaderMap, destination: Option<&DestinationHost>, + port: Option, ) -> AclOutcome { let credentials = match parse_basic_credentials(headers) { Credentials::Missing => None, @@ -302,7 +314,7 @@ impl Acl { None => None, }; - self.authorize_identity(peer, destination, authenticated_user.as_deref()) + self.authorize_identity(peer, destination, port, authenticated_user.as_deref()) } } @@ -310,7 +322,11 @@ fn compile_rule(rule: RuleConfig, users: &BTreeMap) -> Result) -> Result, destination: Option<&DestinationHost>, + port: Option, ) -> bool { let user_matches = rule.users.is_empty() || authenticated_user.is_some_and(|username| rule.users.contains(username)); @@ -366,7 +391,8 @@ fn rule_matches( .iter() .any(|pattern| pattern.matches(destination)) }); - user_matches && network_matches && domain_matches + let port_matches = rule.ports.is_empty() || port.is_some_and(|port| rule.ports.contains(&port)); + user_matches && network_matches && domain_matches && port_matches } enum Credentials { @@ -454,7 +480,7 @@ fn compute_digest(salt: &[u8; SALT_LEN], password: &[u8]) -> [u8; DIGEST_LEN] { pub enum AclError { #[error("ACL rule names must not be empty")] EmptyRuleName, - #[error("ACL rule {0:?} must have a user or network selector")] + #[error("ACL rule {0:?} must have a user, network, domain, or port selector")] EmptyRule(String), #[error("ACL rule name {0:?} is duplicated")] DuplicateRule(String), @@ -464,6 +490,8 @@ pub enum AclError { InvalidNetwork { rule: String, network: String }, #[error("ACL rule {rule:?} has invalid domain pattern {domain:?}")] InvalidDomain { rule: String, domain: String }, + #[error("ACL rule {rule:?} has invalid destination port {port}")] + InvalidPort { rule: String, port: u16 }, #[error("ACL user {0:?} has an invalid or unsupported salted SHA-256 password hash")] InvalidPasswordHash(String), } @@ -484,6 +512,7 @@ mod tests { users: vec![username.to_owned()], networks: vec![], domains: vec![], + ports: vec![], } } @@ -512,6 +541,7 @@ mod tests { "127.0.0.1".parse().unwrap(), &headers("alice", "correct horse battery staple"), None, + None, ); assert_eq!(outcome.decision, Decision::Allow); assert_eq!(outcome.reason, "user"); @@ -530,7 +560,7 @@ mod tests { headers("alice", "wrong"), malformed, ] { - let outcome = acl.authorize("127.0.0.1".parse().unwrap(), &headers, None); + let outcome = acl.authorize("127.0.0.1".parse().unwrap(), &headers, None, None); assert_eq!(outcome.decision, Decision::Challenge); } } @@ -546,14 +576,15 @@ mod tests { users: vec![], networks: vec!["127.0.0.0/8".to_owned(), "::1/128".to_owned()], domains: vec![], + ports: vec![], }], }) .unwrap(); - let allowed = acl.authorize("127.0.0.2".parse().unwrap(), &HeaderMap::new(), None); + let allowed = acl.authorize("127.0.0.2".parse().unwrap(), &HeaderMap::new(), None, None); assert_eq!(allowed.decision, Decision::Allow); - let denied = acl.authorize("192.0.2.1".parse().unwrap(), &HeaderMap::new(), None); + let denied = acl.authorize("192.0.2.1".parse().unwrap(), &HeaderMap::new(), None, None); assert_eq!(denied.decision, Decision::Deny); } @@ -570,6 +601,7 @@ mod tests { users: vec!["alice".to_owned()], networks: vec!["192.0.2.0/24".to_owned()], domains: vec![], + ports: vec![], }, user_rule("allow-alice", "alice", Action::Allow), ], @@ -580,6 +612,7 @@ mod tests { "192.0.2.10".parse().unwrap(), &headers("alice", "correct horse battery staple"), None, + None, ); assert_eq!(denied.decision, Decision::Deny); assert_eq!(denied.rule.as_deref(), Some("deny-alice-off-network")); @@ -588,6 +621,7 @@ mod tests { "198.51.100.10".parse().unwrap(), &headers("alice", "correct horse battery staple"), None, + None, ); assert_eq!(allowed.decision, Decision::Allow); } @@ -606,6 +640,7 @@ mod tests { users: vec![], networks: vec![], domains: domains.iter().map(|domain| (*domain).to_owned()).collect(), + ports: vec![], }], }) .unwrap() @@ -638,6 +673,7 @@ mod tests { "192.0.2.1".parse().unwrap(), &HeaderMap::new(), Some(&destination("example.com")), + None, ); assert_eq!(allowed.decision, Decision::Allow); assert_eq!(allowed.reason, "domain"); @@ -646,6 +682,7 @@ mod tests { "192.0.2.1".parse().unwrap(), &HeaderMap::new(), Some(&destination("doc.rust-lang.org")), + None, ); assert_eq!(nested.decision, Decision::Allow); @@ -654,6 +691,7 @@ mod tests { "192.0.2.1".parse().unwrap(), &HeaderMap::new(), Some(&destination(host)), + None, ); assert_eq!(denied.decision, Decision::Deny, "host: {host}"); } @@ -667,6 +705,7 @@ mod tests { "192.0.2.1".parse().unwrap(), &HeaderMap::new(), Some(&destination("ADS.EXAMPLE.NET.")), + None, ); assert_eq!(denied.decision, Decision::Deny); @@ -675,6 +714,7 @@ mod tests { "192.0.2.1".parse().unwrap(), &HeaderMap::new(), Some(&destination(host)), + None, ); assert_eq!(allowed.decision, Decision::Allow, "host: {host}"); } @@ -692,6 +732,7 @@ mod tests { users: vec!["alice".to_owned()], networks: vec![], domains: vec!["example.com".to_owned()], + ports: vec![], }], }) .unwrap(); @@ -700,6 +741,7 @@ mod tests { "127.0.0.1".parse().unwrap(), &headers("alice", "correct horse battery staple"), Some(&destination("example.com")), + None, ); assert_eq!(allowed.decision, Decision::Allow); assert_eq!(allowed.reason, "user_and_domain"); @@ -708,6 +750,7 @@ mod tests { "127.0.0.1".parse().unwrap(), &headers("alice", "correct horse battery staple"), Some(&destination("other.example")), + None, ); assert_eq!(denied.decision, Decision::Deny); @@ -715,6 +758,7 @@ mod tests { "127.0.0.1".parse().unwrap(), &HeaderMap::new(), Some(&destination("example.com")), + None, ); assert_eq!(challenged.decision, Decision::Challenge); } @@ -743,6 +787,86 @@ mod tests { assert!(DomainPattern::parse(&long_label).is_none()); } + fn port_acl(default_action: Action, action: Action, domains: &[&str], ports: &[u16]) -> Acl { + Acl::from_config(AclConfig { + default_action, + users: BTreeMap::new(), + rules: vec![RuleConfig { + name: "port-rule".to_owned(), + action, + users: vec![], + networks: vec![], + domains: domains.iter().map(|domain| (*domain).to_owned()).collect(), + ports: ports.to_vec(), + }], + }) + .unwrap() + } + + #[test] + fn enforces_destination_port_allowlist_and_blacklist() { + let allowlist = port_acl(Action::Deny, Action::Allow, &[], &[80, 443]); + for port in [80, 443] { + let allowed = allowlist.authorize( + "192.0.2.1".parse().unwrap(), + &HeaderMap::new(), + Some(&destination("example.com")), + Some(port), + ); + assert_eq!(allowed.decision, Decision::Allow); + assert_eq!(allowed.reason, "port"); + } + for port in [Some(8080), None] { + let denied = allowlist.authorize( + "192.0.2.1".parse().unwrap(), + &HeaderMap::new(), + Some(&destination("example.com")), + port, + ); + assert_eq!(denied.decision, Decision::Deny); + } + + let blacklist = port_acl(Action::Allow, Action::Deny, &[], &[23, 25]); + let denied = blacklist.authorize( + "192.0.2.1".parse().unwrap(), + &HeaderMap::new(), + Some(&destination("example.com")), + Some(23), + ); + assert_eq!(denied.decision, Decision::Deny); + let allowed = blacklist.authorize( + "192.0.2.1".parse().unwrap(), + &HeaderMap::new(), + Some(&destination("example.com")), + Some(22), + ); + assert_eq!(allowed.decision, Decision::Allow); + } + + #[test] + fn combines_domain_and_port_selectors() { + let acl = port_acl(Action::Deny, Action::Allow, &["example.com"], &[443]); + + let allowed = acl.authorize( + "192.0.2.1".parse().unwrap(), + &HeaderMap::new(), + Some(&destination("example.com")), + Some(443), + ); + assert_eq!(allowed.decision, Decision::Allow); + assert_eq!(allowed.reason, "domain_and_port"); + + for (host, port) in [("example.com", 80), ("other.example", 443)] { + let denied = acl.authorize( + "192.0.2.1".parse().unwrap(), + &HeaderMap::new(), + Some(&destination(host)), + Some(port), + ); + assert_eq!(denied.decision, Decision::Deny); + } + } + #[test] fn generates_salted_hashes_that_verify() { let first = hash_password(PASSWORD).unwrap(); @@ -768,6 +892,7 @@ mod tests { users: vec![], networks: vec![], domains: vec![], + ports: vec![], }], }); assert!(matches!(empty, Err(AclError::EmptyRule(_)))); diff --git a/src/config.rs b/src/config.rs index 174341c..628b675 100644 --- a/src/config.rs +++ b/src/config.rs @@ -277,6 +277,8 @@ pub(crate) struct RuleConfig { pub networks: Vec, #[serde(default)] pub domains: Vec, + #[serde(default)] + pub ports: Vec, } #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] @@ -469,6 +471,43 @@ acl: )); } + #[test] + fn validates_port_rules() { + let valid = Config::from_yaml( + r#" +acl: + rules: + - name: web-only + action: allow + domains: ["example.com"] + ports: [80, 443, 65535] +"#, + ); + assert!(valid.is_ok()); + + let zero = Config::from_yaml( + r#" +acl: + rules: + - name: invalid-port + action: deny + ports: [0] +"#, + ); + assert!(matches!( + zero, + Err(ConfigError::Acl(AclError::InvalidPort { .. })) + )); + + for yaml in [ + "acl:\n rules:\n - name: too-large\n action: deny\n ports: [65536]\n", + "acl:\n rules:\n - name: negative\n action: deny\n ports: [-1]\n", + "acl:\n rules:\n - name: not-numeric\n action: deny\n ports: [\"https\"]\n", + ] { + assert!(matches!(Config::from_yaml(yaml), Err(ConfigError::Yaml(_)))); + } + } + #[test] fn rejects_unknown_fields_and_zero_timeouts() { let unknown = Config::from_yaml("acl: {}\nunknown: true\n"); diff --git a/src/proxy.rs b/src/proxy.rs index adb9b77..8d28d5f 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -179,6 +179,7 @@ async fn handle_http(request: Request, peer: IpAddr, state: ProxyState url.scheme = %uri.scheme_str().unwrap_or_default(), url.path = %uri.path(), server.address = %uri.authority().map(Authority::host).unwrap_or_default(), + server.port = effective_port(&uri).unwrap_or_default(), http.response.status_code = field::Empty, error.type = field::Empty, acl.decision = field::Empty, @@ -207,16 +208,20 @@ fn authorize_request( peer: IpAddr, state: &ProxyState, ) -> Result, ProxyError> { - let destination = request.uri().authority().and_then(|authority| { + let authority = request.uri().authority(); + let destination = 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()); + let outcome = state.acl.authorize( + peer, + request.headers(), + destination.as_ref(), + effective_port(request.uri()), + ); let protocol = if request.method() == Method::CONNECT { "http_connect" @@ -509,6 +514,15 @@ fn finish_request( response } +fn effective_port(uri: &Uri) -> Option { + uri.authority() + .and_then(Authority::port_u16) + .or_else(|| match uri.scheme_str() { + Some("http") => Some(80), + _ => None, + }) +} + fn validate_http_target(uri: &Uri) -> Result { if uri.scheme_str() != Some("http") { return Err(ProxyError::bad_request( @@ -637,6 +651,26 @@ mod tests { assert!(validate_http_target(&Uri::from_static("http://user@example.com/")).is_err()); } + #[test] + fn resolves_effective_destination_ports() { + assert_eq!( + effective_port(&Uri::from_static("http://example.com/path")), + Some(80) + ); + assert_eq!( + effective_port(&Uri::from_static("http://example.com:8080/path")), + Some(8080) + ); + assert_eq!( + effective_port(&Uri::from_static("example.com:443")), + Some(443) + ); + assert_eq!( + effective_port(&Uri::from_static("https://example.com/path")), + None + ); + } + #[test] fn validates_connect_authority() { let uri = Uri::from_static("example.com:443"); @@ -684,7 +718,7 @@ mod tests { .unwrap(); }); - let (proxy_addr, proxy_task) = spawn_proxy_with_acl(basic_acl()).await; + let (proxy_addr, proxy_task) = spawn_proxy_with_acl(basic_acl(upstream_addr.port())).await; let (mut sender, connection_task) = connect_client(proxy_addr).await; let request = Request::builder() .method(Method::GET) @@ -726,7 +760,7 @@ mod tests { stream.shutdown().await.unwrap(); }); - let (proxy_addr, proxy_task) = spawn_proxy_with_acl(localhost_acl()).await; + let (proxy_addr, proxy_task) = spawn_proxy_with_acl(localhost_acl(echo_addr.port())).await; let (mut sender, connection_task) = connect_client(proxy_addr).await; let request = Request::builder() .method(Method::CONNECT) @@ -751,7 +785,7 @@ mod tests { #[tokio::test] async fn challenges_unauthenticated_http_and_connect_requests() { - let (proxy_addr, proxy_task) = spawn_proxy_with_acl(basic_acl()).await; + let (proxy_addr, proxy_task) = spawn_proxy_with_acl(basic_acl(80)).await; let (mut sender, connection_task) = connect_client(proxy_addr).await; for request in [ @@ -790,6 +824,7 @@ mod tests { users: vec![], networks: vec![], domains: vec!["blocked.example".to_owned()], + ports: vec![], }], }) .unwrap(); @@ -813,7 +848,7 @@ mod tests { 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 (proxy_addr, proxy_task) = spawn_proxy_with_acl(localhost_acl(echo_addr.port())).await; let (mut sender, connection_task) = connect_client(proxy_addr).await; let request = Request::builder() .method(Method::CONNECT) @@ -829,6 +864,46 @@ mod tests { connection_task.abort(); } + #[tokio::test] + async fn denies_http_on_unapproved_port_before_upstream_connection() { + let (proxy_addr, proxy_task) = spawn_proxy_with_acl(basic_acl(443)).await; + let (mut sender, connection_task) = connect_client(proxy_addr).await; + let request = Request::builder() + .method(Method::GET) + .uri("http://localhost:80/") + .header(PROXY_AUTHORIZATION, basic_authorization()) + .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 denies_connect_on_unapproved_port_before_upstream_connection() { + let echo_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let echo_addr = echo_listener.local_addr().unwrap(); + let allowed_port = if echo_addr.port() == 443 { 444 } else { 443 }; + let (proxy_addr, proxy_task) = spawn_proxy_with_acl(localhost_acl(allowed_port)).await; + let (mut sender, connection_task) = connect_client(proxy_addr).await; + let request = Request::builder() + .method(Method::CONNECT) + .uri(format!("localhost:{}", echo_addr.port())) + .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 {}", @@ -836,7 +911,7 @@ mod tests { ) } - fn basic_acl() -> Acl { + fn basic_acl(port: u16) -> Acl { Acl::from_config(AclConfig { default_action: Action::Deny, users: BTreeMap::from([( @@ -852,12 +927,13 @@ mod tests { users: vec!["alice".to_owned()], networks: vec![], domains: vec!["localhost".to_owned()], + ports: vec![port], }], }) .unwrap() } - fn localhost_acl() -> Acl { + fn localhost_acl(port: u16) -> Acl { Acl::from_config(AclConfig { default_action: Action::Deny, users: BTreeMap::new(), @@ -867,6 +943,7 @@ mod tests { users: vec![], networks: vec!["127.0.0.1/32".to_owned(), "::1/128".to_owned()], domains: vec!["localhost".to_owned()], + ports: vec![port], }], }) .unwrap() diff --git a/src/socks5.rs b/src/socks5.rs index 8dccff9..d7ce467 100644 --- a/src/socks5.rs +++ b/src/socks5.rs @@ -154,6 +154,7 @@ async fn handle_connection(mut stream: TcpStream, peer: SocketAddr, state: Socks let outcome = state.acl.authorize_identity( peer.ip(), Some(&request.destination), + Some(request.port), negotiated.username.as_deref(), ); state @@ -632,12 +633,13 @@ mod tests { users: vec!["alice".to_owned()], networks: vec![], domains: vec![], + ports: vec![], }], }) .unwrap() } - fn localhost_domain_acl() -> Acl { + fn localhost_domain_acl(port: u16) -> Acl { Acl::from_config(AclConfig { default_action: Action::Deny, users: BTreeMap::new(), @@ -647,6 +649,7 @@ mod tests { users: vec![], networks: vec!["127.0.0.0/8".to_owned()], domains: vec!["localhost".to_owned()], + ports: vec![port], }], }) .unwrap() @@ -750,7 +753,7 @@ mod tests { #[tokio::test] async fn connects_domain_and_applies_acl() { let (echo_addr, echo_task) = spawn_echo().await; - let (socks_addr, socks_task) = spawn_socks(localhost_domain_acl()).await; + let (socks_addr, socks_task) = spawn_socks(localhost_domain_acl(echo_addr.port())).await; let mut stream = TcpStream::connect(socks_addr).await.unwrap(); negotiate_without_auth(&mut stream).await; stream @@ -763,6 +766,22 @@ mod tests { socks_task.abort(); } + #[tokio::test] + async fn denies_unapproved_destination_port() { + let (echo_addr, echo_task) = spawn_echo().await; + let allowed_port = if echo_addr.port() == 443 { 444 } else { 443 }; + let (socks_addr, socks_task) = spawn_socks(localhost_domain_acl(allowed_port)).await; + let mut stream = TcpStream::connect(socks_addr).await.unwrap(); + negotiate_without_auth(&mut stream).await; + stream + .write_all(&domain_request("localhost", echo_addr.port())) + .await + .unwrap(); + assert_eq!(read_reply(&mut stream).await, REPLY_NOT_ALLOWED); + echo_task.abort(); + socks_task.abort(); + } + #[tokio::test] async fn authenticates_username_password() { let (echo_addr, echo_task) = spawn_echo().await;