From 329dc440656c07943d70e90c5f95e87060c316dd Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Mon, 13 Jul 2026 19:55:18 +0100 Subject: [PATCH 1/2] fix(proxy): normalize trailing-dot CONNECT hosts before policy evaluation Signed-off-by: Artem Lytvyn --- .../openshell-supervisor-network/src/proxy.rs | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 18ac92f6bc..981ff1154d 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -1249,7 +1249,8 @@ async fn handle_tcp_connection( .await; } - let (host, port) = parse_target(target)?; + let (raw_host, port) = parse_target(target)?; + let host = normalize_host(&raw_host); let host_lc = host.to_ascii_lowercase(); if host_lc == INFERENCE_LOCAL_HOST && port == INFERENCE_LOCAL_PORT { @@ -1440,7 +1441,7 @@ async fn handle_tcp_connection( // Defense-in-depth: resolve DNS and reject connections to internal IPs. let dns_connect_start = std::time::Instant::now(); let connector = match validate_destination(DestinationRequest { - host: &host, + host: &raw_host, port, sandbox_entrypoint_pid, plan: destination_plan, @@ -2874,9 +2875,11 @@ fn implicit_allowed_ips_for_ip_host(host: &str) -> Vec { } fn normalize_host_lookup_key(host: &str) -> &str { - host.strip_prefix('[') + let h = host + .strip_prefix('[') .and_then(|trimmed| trimmed.strip_suffix(']')) - .unwrap_or(host) + .unwrap_or(host); + h.strip_suffix('.').unwrap_or(h) } /// Returns `true` if `host` is one of the well-known driver-injected aliases @@ -3117,15 +3120,18 @@ async fn resolve_socket_addrs( return Ok(addrs); } - let lookup_host = normalize_host_lookup_key(host); - let addrs: Vec = tokio::net::lookup_host((lookup_host, port)) + let dns_host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + let addrs: Vec = tokio::net::lookup_host((dns_host, port)) .await - .map_err(|e| format!("DNS resolution failed for {lookup_host}:{port}: {e}"))? + .map_err(|e| format!("DNS resolution failed for {dns_host}:{port}: {e}"))? .collect(); if addrs.is_empty() { return Err(format!( - "DNS resolution returned no addresses for {lookup_host}:{port}" + "DNS resolution returned no addresses for {dns_host}:{port}" )); } @@ -4107,6 +4113,9 @@ async fn handle_forward_proxy( respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); }; + + let raw_host = host; + let host = normalize_host(&raw_host); let host_lc = host.to_ascii_lowercase(); if host_lc == POLICY_LOCAL_HOST { @@ -4860,7 +4869,7 @@ async fn handle_forward_proxy( .expect("destination plan hydrated"); let connector = match validate_destination(DestinationRequest { - host: &host, + host: &raw_host, port, sandbox_entrypoint_pid, plan: destination_plan, @@ -5404,6 +5413,10 @@ fn parse_target(target: &str) -> Result<(String, u16)> { Ok((host.to_string(), port)) } +fn normalize_host(raw_host: &str) -> &str { + raw_host.strip_suffix('.').unwrap_or(raw_host) +} + async fn respond(client: &mut TcpStream, bytes: &[u8]) -> Result<()> { client.write_all(bytes).await.into_diagnostic()?; Ok(()) @@ -9663,6 +9676,16 @@ network_policies: assert_eq!(port, 443); } + #[test] + fn test_normalize_host_strips_single_trailing_dot() { + assert_eq!(normalize_host("api.example.com."), "api.example.com"); + } + + #[test] + fn test_normalize_host_remains_the_same() { + assert_eq!(normalize_host("api.example.com"), "api.example.com"); + } + #[test] fn test_parse_target_preserves_case() { let (host, port) = parse_target("EXAMPLE.COM:443").unwrap(); @@ -9822,6 +9845,14 @@ network_policies: // -- parse_proxy_uri: hostname parser regression tests -- + #[test] + fn test_parse_proxy_uri_trailing_dot_host() { + let (_, host, port, _) = parse_proxy_uri("http://api.example.com.:80/path").unwrap(); + let host = normalize_host(&host); + assert_eq!(host, "api.example.com"); + assert_eq!(port, 80_u16); + } + #[test] fn test_parse_proxy_uri_nul_byte_in_host() { let (_, host, port, _) = parse_proxy_uri("http://evil.com\0.safe.com:80/path").unwrap(); From 6f0ad2f6acdf15c261073cb2e82a50f08c3b0569 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Sun, 23 Aug 2026 22:17:17 +0100 Subject: [PATCH 2/2] fix(supervisor-network): keep trailing dot on upstream proxy CONNECT host and add regression test Signed-off-by: Artem Lytvyn --- .../openshell-supervisor-network/src/proxy.rs | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 981ff1154d..49b0a24693 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -1251,7 +1251,7 @@ async fn handle_tcp_connection( let (raw_host, port) = parse_target(target)?; let host = normalize_host(&raw_host); - let host_lc = host.to_ascii_lowercase(); + let (host_lc, raw_host_lc) = (host.to_ascii_lowercase(), raw_host.to_ascii_lowercase()); if host_lc == INFERENCE_LOCAL_HOST && port == INFERENCE_LOCAL_PORT { respond(&mut client, b"HTTP/1.1 200 Connection Established\r\n\r\n").await?; @@ -1525,7 +1525,7 @@ async fn handle_tcp_connection( } let upstream_result = tokio::select! { - result = dial_upstream(&upstream_proxy, &host_lc, port, connector.addrs()) => Some(result), + result = dial_upstream(&upstream_proxy, &host_lc, &raw_host_lc, port, connector.addrs()) => Some(result), () = connect_generation_guard.wait_until_stale() => None, }; let Some(upstream_result) = upstream_result else { @@ -3262,6 +3262,7 @@ fn validate_declared_endpoint_resolved_addrs( async fn dial_upstream( upstream_proxy: &Option, host_lc: &str, + raw_host_lc: &str, port: u16, addrs: &[SocketAddr], ) -> std::io::Result { @@ -3271,7 +3272,7 @@ async fn dial_upstream( if cfg.connect_by_hostname() { upstream_proxy::connect_via( endpoint, - host_lc, + raw_host_lc, port, upstream_proxy::ConnectTarget::Hostname, ) @@ -5603,6 +5604,8 @@ fn is_benign_relay_error(err: &miette::Report) -> bool { reason = "Test code: test fixtures and explicit control-flow markers are idiomatic in tests." )] mod tests { + use crate::upstream_proxy::UpstreamProxyArgs; + use super::*; use openshell_core::proposals::AgentProposals; use std::collections::HashMap as TestHashMap; @@ -6108,6 +6111,49 @@ network_policies: } } + #[tokio::test] + async fn dial_upstream_preserves_trailing_dot_in_hostname_connect() { + // Fake upstream proxy: capture the request line, then 200. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = [0_u8; 1024_usize]; + let n = sock.read(&mut buf).await.unwrap(); + sock.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + String::from_utf8_lossy(&buf[..n]).into_owned() + }); + + // Operator config: proxy set + connect-by-hostname opt-in. + let cfg = UpstreamProxyConfig::from_args(&UpstreamProxyArgs { + https_proxy: Some(format!("http://{proxy_addr}")), + proxy_connect_by_hostname: true, + ..Default::default() + }) + .unwrap(); + + // host_lc = normalized (undotted), raw_host_lc = absolute (dotted). + let stream = dial_upstream( + &cfg, + "api.example.com", + "api.example.com.", + 443, + &[], // addrs unused in the hostname branch + ) + .await + .unwrap(); + + drop(stream); + + let request = handle.await.unwrap(); + assert!( + request.starts_with("CONNECT api.example.com.:443 HTTP/1.1\r\n"), + "proxy must receive the absolute FQDN: {request}" + ); + } + #[test] fn middleware_failure_response_uses_platform_text_without_policy_guidance() { let response = build_middleware_failure_response("api-policy");