From 788e9e8d9770f73a7284be6b596cd091166a73e8 Mon Sep 17 00:00:00 2001 From: Adel Zaalouk Date: Sat, 25 Jul 2026 17:56:48 +0200 Subject: [PATCH 1/2] fix(sandbox): resolve host-gateway aliases from proxy /etc/hosts The SSRF engine in 0.0.91 blocks host.openshell.internal because DNS resolution fails for this synthetic hostname. The sandbox process runs in a separate mount namespace whose /etc/hosts does not contain the driver-injected entry, and the system DNS resolver cannot resolve proxy-internal names. Add resolve_from_proxy_hosts() as a fallback in the DNS resolution chain for host-gateway aliases. When the sandbox's /etc/hosts and system DNS both fail, the proxy reads its own /etc/hosts (the container-level file populated by the compute driver's --add-host flag) to resolve the hostname. The resolved IP then flows through the normal SSRF validation pipeline (allowed_ips or exact_declared_endpoint_host path) without weakening SSRF guards. Resolution order is now: 1. IP literal 2. Sandbox /etc/hosts (via /proc/{pid}/root/etc/hosts) 3. Proxy /etc/hosts (host-gateway aliases only) 4. System DNS Fixes #2478 Signed-off-by: Adel Zaalouk --- .../openshell-supervisor-network/src/proxy.rs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 69245e11e..5765a4192 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -2832,6 +2832,25 @@ async fn resolve_from_sandbox_hosts( None } +/// Resolve a hostname using the proxy's own `/etc/hosts` file. +/// +/// Used as a fallback for host-gateway aliases when the sandbox's `/etc/hosts` +/// (read via `/proc/{pid}/root/etc/hosts`) does not contain the entry — e.g. +/// because the sandbox runs in a separate mount namespace. The proxy's +/// `/etc/hosts` is the container-level file populated by the compute driver's +/// `--add-host` flag. +#[cfg(any(target_os = "linux", test))] +fn resolve_from_proxy_hosts(host: &str, port: u16) -> Option> { + let contents = std::fs::read_to_string("/etc/hosts").ok()?; + let addrs = resolve_from_hosts_file_contents(&contents, host, port); + if addrs.is_empty() { None } else { Some(addrs) } +} + +#[cfg(not(any(target_os = "linux", test)))] +fn resolve_from_proxy_hosts(_host: &str, _port: u16) -> Option> { + None +} + async fn resolve_socket_addrs( host: &str, port: u16, @@ -2845,6 +2864,18 @@ async fn resolve_socket_addrs( return Ok(addrs); } + // Host-gateway aliases (host.openshell.internal, etc.) are synthetic + // hostnames injected by compute drivers into the container's /etc/hosts. + // The sandbox process may run in a separate mount namespace whose + // /etc/hosts does not contain the entry. Fall back to the proxy's own + // /etc/hosts (the container's) before trying system DNS, which will + // also fail for these synthetic names. + if is_host_gateway_alias(&host.to_ascii_lowercase()) + && let Some(addrs) = resolve_from_proxy_hosts(host, port) + { + return Ok(addrs); + } + let lookup_host = normalize_host_lookup_key(host); let addrs: Vec = tokio::net::lookup_host((lookup_host, port)) .await @@ -7163,6 +7194,43 @@ network_policies: ); } + // -- resolve_from_proxy_hosts -- + + #[test] + fn test_resolve_from_proxy_hosts_finds_host_gateway_alias() { + // The proxy's /etc/hosts is the container-level file populated by + // --add-host. resolve_from_proxy_hosts reads it directly, bypassing + // the system resolver, so host-gateway aliases resolve even when the + // sandbox's mount namespace has a different /etc/hosts. + // + // We test the underlying parser since the production function reads + // the real /etc/hosts. + let contents = "172.17.0.1\thost.openshell.internal host.containers.internal\n\ + 127.0.0.1\tlocalhost\n"; + let addrs = resolve_from_hosts_file_contents(contents, "host.openshell.internal", 9318); + assert_eq!(addrs.len(), 1); + assert_eq!(addrs[0].ip(), IpAddr::V4(Ipv4Addr::new(172, 17, 0, 1))); + assert_eq!(addrs[0].port(), 9318); + } + + #[test] + fn test_resolve_from_proxy_hosts_returns_none_when_missing() { + let contents = "127.0.0.1\tlocalhost\n"; + let addrs = resolve_from_hosts_file_contents(contents, "host.openshell.internal", 9318); + assert!(addrs.is_empty()); + } + + #[test] + fn test_host_gateway_alias_triggers_proxy_hosts_fallback() { + // Verify that is_host_gateway_alias returns true for all known + // aliases, ensuring the resolve_socket_addrs fallback is reachable. + assert!(is_host_gateway_alias("host.openshell.internal")); + assert!(is_host_gateway_alias("host.containers.internal")); + assert!(is_host_gateway_alias("host.docker.internal")); + assert!(!is_host_gateway_alias("example.com")); + assert!(!is_host_gateway_alias("inference.local")); + } + #[tokio::test] async fn inference_interception_applies_router_header_allowlist() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; From 91efad63ee07b820ac4b511ae60f15db7c3d84aa Mon Sep 17 00:00:00 2001 From: Adel Zaalouk Date: Sun, 26 Jul 2026 18:13:58 +0200 Subject: [PATCH 2/2] refactor(sandbox): make proxy hosts fallback async and unit-testable - Convert resolve_from_proxy_hosts to async with tokio::fs, matching the resolve_from_sandbox_hosts I/O pattern - Extract path-parameterized resolve_from_hosts_file helper so the file-reading path is unit-testable with a temp hosts file - Log read failures at debug level instead of silently falling through to DNS - Drop redundant lowercase allocation at the alias guard call site - Replace parser-only duplicate tests with temp-file tests covering alias resolution, missing alias, and unreadable file Signed-off-by: Adel Zaalouk --- .../openshell-supervisor-network/src/proxy.rs | 86 ++++++++++++------- 1 file changed, 57 insertions(+), 29 deletions(-) diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 5765a4192..545178d22 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -20,6 +20,8 @@ use openshell_ocsf::{ NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, }; use std::net::{IpAddr, SocketAddr}; +#[cfg(any(target_os = "linux", test))] +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; @@ -2840,17 +2842,33 @@ async fn resolve_from_sandbox_hosts( /// `/etc/hosts` is the container-level file populated by the compute driver's /// `--add-host` flag. #[cfg(any(target_os = "linux", test))] -fn resolve_from_proxy_hosts(host: &str, port: u16) -> Option> { - let contents = std::fs::read_to_string("/etc/hosts").ok()?; - let addrs = resolve_from_hosts_file_contents(&contents, host, port); - if addrs.is_empty() { None } else { Some(addrs) } +async fn resolve_from_proxy_hosts(host: &str, port: u16) -> Option> { + resolve_from_hosts_file(Path::new("/etc/hosts"), host, port).await } #[cfg(not(any(target_os = "linux", test)))] -fn resolve_from_proxy_hosts(_host: &str, _port: u16) -> Option> { +#[allow(clippy::unused_async)] +async fn resolve_from_proxy_hosts(_host: &str, _port: u16) -> Option> { None } +#[cfg(any(target_os = "linux", test))] +async fn resolve_from_hosts_file(path: &Path, host: &str, port: u16) -> Option> { + let contents = match tokio::fs::read_to_string(path).await { + Ok(contents) => contents, + Err(error) => { + debug!( + path = %path.display(), + host, + "Falling back to DNS; failed to read hosts file: {error}" + ); + return None; + } + }; + let addrs = resolve_from_hosts_file_contents(&contents, host, port); + if addrs.is_empty() { None } else { Some(addrs) } +} + async fn resolve_socket_addrs( host: &str, port: u16, @@ -2870,8 +2888,8 @@ async fn resolve_socket_addrs( // /etc/hosts does not contain the entry. Fall back to the proxy's own // /etc/hosts (the container's) before trying system DNS, which will // also fail for these synthetic names. - if is_host_gateway_alias(&host.to_ascii_lowercase()) - && let Some(addrs) = resolve_from_proxy_hosts(host, port) + if is_host_gateway_alias(host) + && let Some(addrs) = resolve_from_proxy_hosts(host, port).await { return Ok(addrs); } @@ -7196,39 +7214,49 @@ network_policies: // -- resolve_from_proxy_hosts -- - #[test] - fn test_resolve_from_proxy_hosts_finds_host_gateway_alias() { + #[tokio::test] + async fn test_resolve_from_hosts_file_reads_host_gateway_alias_from_disk() { // The proxy's /etc/hosts is the container-level file populated by // --add-host. resolve_from_proxy_hosts reads it directly, bypassing // the system resolver, so host-gateway aliases resolve even when the // sandbox's mount namespace has a different /etc/hosts. - // - // We test the underlying parser since the production function reads - // the real /etc/hosts. - let contents = "172.17.0.1\thost.openshell.internal host.containers.internal\n\ - 127.0.0.1\tlocalhost\n"; - let addrs = resolve_from_hosts_file_contents(contents, "host.openshell.internal", 9318); + let dir = tempfile::tempdir().unwrap(); + let hosts_path = dir.path().join("hosts"); + std::fs::write( + &hosts_path, + "172.17.0.1\thost.openshell.internal host.containers.internal\n\ + 127.0.0.1\tlocalhost\n", + ) + .unwrap(); + + let addrs = resolve_from_hosts_file(&hosts_path, "host.openshell.internal", 9318).await; + let addrs = addrs.expect("alias should resolve from hosts file"); assert_eq!(addrs.len(), 1); assert_eq!(addrs[0].ip(), IpAddr::V4(Ipv4Addr::new(172, 17, 0, 1))); assert_eq!(addrs[0].port(), 9318); } - #[test] - fn test_resolve_from_proxy_hosts_returns_none_when_missing() { - let contents = "127.0.0.1\tlocalhost\n"; - let addrs = resolve_from_hosts_file_contents(contents, "host.openshell.internal", 9318); - assert!(addrs.is_empty()); + #[tokio::test] + async fn test_resolve_from_hosts_file_returns_none_when_alias_missing() { + let dir = tempfile::tempdir().unwrap(); + let hosts_path = dir.path().join("hosts"); + std::fs::write(&hosts_path, "127.0.0.1\tlocalhost\n").unwrap(); + + let addrs = resolve_from_hosts_file(&hosts_path, "host.openshell.internal", 9318).await; + assert!(addrs.is_none(), "missing alias should yield None"); } - #[test] - fn test_host_gateway_alias_triggers_proxy_hosts_fallback() { - // Verify that is_host_gateway_alias returns true for all known - // aliases, ensuring the resolve_socket_addrs fallback is reachable. - assert!(is_host_gateway_alias("host.openshell.internal")); - assert!(is_host_gateway_alias("host.containers.internal")); - assert!(is_host_gateway_alias("host.docker.internal")); - assert!(!is_host_gateway_alias("example.com")); - assert!(!is_host_gateway_alias("inference.local")); + #[tokio::test] + async fn test_resolve_from_hosts_file_returns_none_when_file_unreadable() { + // Missing file path: read fails, function returns None (logged at debug) + // rather than panicking or surfacing a DNS error. + let addrs = resolve_from_hosts_file( + Path::new("/nonexistent/openshell-test-hosts-9f3a2c"), + "host.openshell.internal", + 9318, + ) + .await; + assert!(addrs.is_none(), "unreadable hosts file should yield None"); } #[tokio::test]