Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 89 additions & 12 deletions crates/openshell-supervisor-network/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1249,8 +1249,9 @@ async fn handle_tcp_connection(
.await;
}

let (host, port) = parse_target(target)?;
let host_lc = host.to_ascii_lowercase();
let (raw_host, port) = parse_target(target)?;
let host = normalize_host(&raw_host);
Comment thread
johntmyers marked this conversation as resolved.
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?;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1524,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 {
Expand Down Expand Up @@ -2874,9 +2875,11 @@ fn implicit_allowed_ips_for_ip_host(host: &str) -> Vec<String> {
}

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
Expand Down Expand Up @@ -3117,15 +3120,18 @@ async fn resolve_socket_addrs(
return Ok(addrs);
}

let lookup_host = normalize_host_lookup_key(host);
let addrs: Vec<SocketAddr> = 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<SocketAddr> = 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}"
));
}

Expand Down Expand Up @@ -3256,6 +3262,7 @@ fn validate_declared_endpoint_resolved_addrs(
async fn dial_upstream(
upstream_proxy: &Option<UpstreamProxyConfig>,
host_lc: &str,
raw_host_lc: &str,
port: u16,
addrs: &[SocketAddr],
) -> std::io::Result<upstream_proxy::PrefixedStream> {
Expand All @@ -3265,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,
)
Expand Down Expand Up @@ -4107,6 +4114,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 {
Expand Down Expand Up @@ -4860,7 +4870,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,
Expand Down Expand Up @@ -5404,6 +5414,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(())
Expand Down Expand Up @@ -5590,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;
Expand Down Expand Up @@ -6095,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");
Expand Down Expand Up @@ -9663,6 +9722,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();
Expand Down Expand Up @@ -9822,6 +9891,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();
Expand Down
Loading