diff --git a/crates/openshell-sandbox/src/child_env.rs b/crates/openshell-sandbox/src/child_env.rs index ebd47e225..5edb3b651 100644 --- a/crates/openshell-sandbox/src/child_env.rs +++ b/crates/openshell-sandbox/src/child_env.rs @@ -5,15 +5,35 @@ use std::path::Path; const LOCAL_NO_PROXY: &str = "127.0.0.1,localhost,::1"; +/// Build the NO_PROXY value by combining localhost entries with any hosts +/// listed in `OPENSHELL_DIRECT_TCP_HOSTS`. Those hosts have iptables ACCEPT +/// rules for direct TCP 443 (set up by netns), so HTTP clients must also +/// skip the proxy to avoid TLS termination issues with non-Node binaries +/// (e.g. Rust/rustls programs that cannot trust the egress proxy CA). +fn build_no_proxy() -> String { + let mut no_proxy = LOCAL_NO_PROXY.to_owned(); + if let Ok(hosts) = std::env::var("OPENSHELL_DIRECT_TCP_HOSTS") { + for host in hosts.split(',') { + let host = host.trim(); + if !host.is_empty() { + no_proxy.push(','); + no_proxy.push_str(host); + } + } + } + no_proxy +} + pub(crate) fn proxy_env_vars(proxy_url: &str) -> [(&'static str, String); 9] { + let no_proxy = build_no_proxy(); [ ("ALL_PROXY", proxy_url.to_owned()), ("HTTP_PROXY", proxy_url.to_owned()), ("HTTPS_PROXY", proxy_url.to_owned()), - ("NO_PROXY", LOCAL_NO_PROXY.to_owned()), + ("NO_PROXY", no_proxy.clone()), ("http_proxy", proxy_url.to_owned()), ("https_proxy", proxy_url.to_owned()), - ("no_proxy", LOCAL_NO_PROXY.to_owned()), + ("no_proxy", no_proxy), ("grpc_proxy", proxy_url.to_owned()), // Node.js only honors HTTP(S)_PROXY for built-in fetch/http clients when // proxy support is explicitly enabled at process startup. @@ -43,6 +63,9 @@ mod tests { #[test] fn apply_proxy_env_includes_node_proxy_opt_in_and_local_bypass() { + // Ensure no leftover env from other tests affects NO_PROXY + std::env::remove_var("OPENSHELL_DIRECT_TCP_HOSTS"); + let mut cmd = Command::new("/usr/bin/env"); cmd.stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -61,6 +84,23 @@ mod tests { assert!(stdout.contains("no_proxy=127.0.0.1,localhost,::1")); } + #[test] + fn no_proxy_includes_direct_tcp_hosts() { + std::env::set_var( + "OPENSHELL_DIRECT_TCP_HOSTS", + "oauth2.googleapis.com,gmail.googleapis.com", + ); + + let no_proxy = build_no_proxy(); + assert_eq!( + no_proxy, + "127.0.0.1,localhost,::1,oauth2.googleapis.com,gmail.googleapis.com" + ); + + // Clean up + std::env::remove_var("OPENSHELL_DIRECT_TCP_HOSTS"); + } + #[test] fn apply_tls_env_sets_node_and_bundle_paths() { let mut cmd = Command::new("/usr/bin/env"); diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index f9e8fb4c5..fe67ce848 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -1149,8 +1149,14 @@ const PROXY_BASELINE_READ_ONLY: &[&str] = &[ ]; /// Minimum read-write paths required for a proxy-mode sandbox child process: -/// user working directory and temporary files. -const PROXY_BASELINE_READ_WRITE: &[&str] = &["/sandbox", "/tmp"]; +/// user working directory, temporary files, and PTY devices. +/// +/// `/dev/ptmx` and `/dev/pts`: VS Code Remote-SSH launches its server under the +/// sandbox policy, and the server later allocates PTYs for the integrated +/// terminal via `node-pty`. Landlock blocks device-file opens unless they are +/// explicitly whitelisted, so PTY allocation fails with `EACCES` unless both +/// the PTY multiplexer and the slave PTY directory are writable. +const PROXY_BASELINE_READ_WRITE: &[&str] = &["/sandbox", "/tmp", "/dev/ptmx", "/dev/pts"]; /// GPU read-only paths. /// @@ -1388,10 +1394,45 @@ mod baseline_tests { } #[test] - fn baseline_read_write_always_includes_sandbox_and_tmp() { + fn baseline_read_write_includes_core_runtime_and_pty_paths() { let (_ro, rw) = baseline_enrichment_paths(); assert!(rw.contains(&"/sandbox".to_string())); assert!(rw.contains(&"/tmp".to_string())); + assert!(rw.contains(&"/dev/ptmx".to_string())); + assert!(rw.contains(&"/dev/pts".to_string())); + } + + #[test] + fn enrich_proto_baseline_paths_adds_pty_paths_for_proxy_mode() { + let mut policy = openshell_core::proto::SandboxPolicy::default(); + policy.network_policies.insert( + "test".to_string(), + openshell_core::proto::NetworkPolicyRule::default(), + ); + + let modified = enrich_proto_baseline_paths(&mut policy); + assert!(modified, "proxy-mode policy should be enriched"); + + let fs = policy + .filesystem + .as_ref() + .expect("filesystem policy should be created during enrichment"); + assert!( + fs.read_write.iter().any(|p| p == "/sandbox"), + "proxy baseline should include /sandbox" + ); + assert!( + fs.read_write.iter().any(|p| p == "/tmp"), + "proxy baseline should include /tmp" + ); + assert!( + fs.read_write.iter().any(|p| p == "/dev/ptmx"), + "proxy baseline should include /dev/ptmx" + ); + assert!( + fs.read_write.iter().any(|p| p == "/dev/pts"), + "proxy baseline should include /dev/pts" + ); } #[test] @@ -1406,6 +1447,15 @@ mod baseline_tests { ); } + #[test] + fn runtime_device_paths_are_not_prepared_for_chown() { + assert!(is_runtime_device_path(std::path::Path::new("/dev/ptmx"))); + assert!(is_runtime_device_path(std::path::Path::new("/dev/pts"))); + assert!(is_runtime_device_path(std::path::Path::new("/proc"))); + assert!(!is_runtime_device_path(std::path::Path::new("/sandbox"))); + assert!(!is_runtime_device_path(std::path::Path::new("/tmp"))); + } + #[test] fn no_duplicate_paths_in_baseline() { let (ro, rw) = baseline_enrichment_paths(); @@ -1750,11 +1800,25 @@ fn prepare_filesystem(policy: &SandboxPolicy) -> Result<()> { // (e.g. /dev/null) are legitimate read_write entries and must be allowed. if let Ok(meta) = std::fs::symlink_metadata(path) { if meta.file_type().is_symlink() { + if is_runtime_device_path(path) { + debug!( + path = %path.display(), + "Skipping ownership change on runtime device symlink" + ); + continue; + } return Err(miette::miette!( "read_write path '{}' is a symlink — refusing to chown (potential privilege escalation)", path.display() )); } + if is_runtime_device_path(path) { + debug!( + path = %path.display(), + "Skipping ownership change on runtime device path" + ); + continue; + } } else { debug!(path = %path.display(), "Creating read_write directory"); std::fs::create_dir_all(path).into_diagnostic()?; @@ -1767,6 +1831,13 @@ fn prepare_filesystem(policy: &SandboxPolicy) -> Result<()> { Ok(()) } +#[cfg(unix)] +fn is_runtime_device_path(path: &std::path::Path) -> bool { + path.starts_with(std::path::Path::new("/dev")) + || path.starts_with(std::path::Path::new("/proc")) + || path.starts_with(std::path::Path::new("/sys")) +} + #[cfg(not(unix))] fn prepare_filesystem(_policy: &SandboxPolicy) -> Result<()> { Ok(()) diff --git a/crates/openshell-sandbox/src/sandbox/linux/netns.rs b/crates/openshell-sandbox/src/sandbox/linux/netns.rs index 37d11f0c3..303c2fa41 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/netns.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/netns.rs @@ -19,6 +19,50 @@ const SUBNET_PREFIX: &str = "10.200.0"; const HOST_IP_SUFFIX: u8 = 1; const SANDBOX_IP_SUFFIX: u8 = 2; +/// Parse the `OPENSHELL_DIRECT_TCP_HOSTS` environment variable into a list of +/// hostnames. Returns an empty vec if the variable is unset or empty. +fn parse_direct_tcp_hosts() -> Vec { + let hosts = match std::env::var("OPENSHELL_DIRECT_TCP_HOSTS") { + Ok(val) if !val.is_empty() => val, + _ => return Vec::new(), + }; + hosts + .split(',') + .map(|h| h.trim().to_owned()) + .filter(|h| !h.is_empty()) + .collect() +} + +/// Resolve the cluster DNS server IP for the iptables ACCEPT rule. +/// +/// Priority: +/// 1. `OPENSHELL_DNS_SERVER` environment variable (operator override) +/// 2. First `nameserver` entry in `/etc/resolv.conf` +/// +/// Returns `None` if neither source provides a valid IP, in which case +/// no DNS ACCEPT rule will be installed and UDP DNS remains blocked. +fn resolve_dns_server() -> Option { + if let Ok(val) = std::env::var("OPENSHELL_DNS_SERVER") { + if let Ok(addr) = val.parse::() { + return Some(addr); + } + warn!(value = %val, "OPENSHELL_DNS_SERVER is not a valid IP address, ignoring"); + } + + if let Ok(contents) = std::fs::read_to_string("/etc/resolv.conf") { + for line in contents.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("nameserver") { + if let Ok(addr) = rest.trim().parse::() { + return Some(addr); + } + } + } + } + + None +} + /// Handle to a network namespace with veth pair. /// /// The namespace and veth interfaces are automatically cleaned up on drop. @@ -315,6 +359,59 @@ impl NetworkNamespace { } } + // Enable IP forwarding and NAT on the host side of the veth for DNS. + if let Some(dns_ip) = resolve_dns_server() { + let dns_ip_str = dns_ip.to_string(); + let sandbox_ip_str = self.sandbox_ip.to_string(); + + let forwarding_path = format!( + "/proc/sys/net/ipv4/conf/{}/forwarding", + self.veth_host + ); + if let Err(e) = std::fs::write(&forwarding_path, "1") { + warn!( + error = %e, + path = %forwarding_path, + "Failed to enable IP forwarding on host veth (DNS may not work)" + ); + } + let _ = std::fs::write("/proc/sys/net/ipv4/ip_forward", "1"); + + let dns_cidr = format!("{dns_ip_str}/32"); + let sandbox_cidr = format!("{sandbox_ip_str}/32"); + let _ = Command::new(&iptables_path) + .args(["-t", "nat", "-A", "POSTROUTING", "-s", &sandbox_cidr, "-d", &dns_cidr, "-p", "udp", "--dport", "53", "-j", "MASQUERADE"]) + .output(); + let _ = Command::new(&iptables_path) + .args(["-A", "FORWARD", "-s", &sandbox_cidr, "-d", &dns_cidr, "-p", "udp", "--dport", "53", "-j", "ACCEPT"]) + .output(); + let _ = Command::new(&iptables_path) + .args(["-A", "FORWARD", "-m", "state", "--state", "ESTABLISHED,RELATED", "-j", "ACCEPT"]) + .output(); + + info!( + dns_server = %dns_ip_str, + veth = %self.veth_host, + "Enabled DNS forwarding from sandbox to cluster nameserver" + ); + } + + // Host-side forwarding for direct TCP 443 (OPENSHELL_DIRECT_TCP_HOSTS). + let direct_tcp_hosts = parse_direct_tcp_hosts(); + if !direct_tcp_hosts.is_empty() { + let sandbox_cidr = format!("{}/32", self.sandbox_ip); + let _ = Command::new(&iptables_path) + .args(["-t", "nat", "-A", "POSTROUTING", "-s", &sandbox_cidr, "-p", "tcp", "--dport", "443", "-j", "MASQUERADE"]) + .output(); + let _ = Command::new(&iptables_path) + .args(["-A", "FORWARD", "-s", &sandbox_cidr, "-p", "tcp", "--dport", "443", "-j", "ACCEPT"]) + .output(); + info!( + hosts = direct_tcp_hosts.len(), + "Enabled broad TCP 443 forwarding for OPENSHELL_DIRECT_TCP_HOSTS" + ); + } + openshell_ocsf::ocsf_emit!( openshell_ocsf::ConfigStateChangeBuilder::new(crate::ocsf_ctx()) .severity(openshell_ocsf::SeverityId::Informational) @@ -416,6 +513,30 @@ impl NetworkNamespace { .build()); } + // Rule 4.5: ACCEPT all TCP 443 when OPENSHELL_DIRECT_TCP_HOSTS is set. + // + // Some binaries (e.g. Rust/rustls programs like `gws`) cannot trust the + // egress proxy's TLS-terminating CA and need direct TCP 443 connections. + // Rather than tracking per-IP rules (which break when DNS round-robin + // returns new IPs), we ACCEPT all outbound TCP 443 from the sandbox. + // + // Security: applications still use HTTPS_PROXY for hosts not in NO_PROXY. + // This rule only affects the iptables layer — it means processes that + // intentionally bypass the proxy env vars can reach any HTTPS endpoint + // directly, which is an acceptable trade-off given the proxy cannot + // inspect TLS content anyway (HTTP CONNECT tunnel). + if !parse_direct_tcp_hosts().is_empty() { + if let Err(e) = run_iptables_netns( + &self.name, + iptables_cmd, + &["-A", "OUTPUT", "-p", "tcp", "--dport", "443", "-j", "ACCEPT"], + ) { + warn!(error = %e, "Failed to install TCP 443 ACCEPT rule"); + } else { + info!("Installed broad TCP 443 ACCEPT rule for OPENSHELL_DIRECT_TCP_HOSTS"); + } + } + // Rule 5: REJECT TCP bypass attempts (fast-fail) run_iptables_netns( &self.name, @@ -432,6 +553,33 @@ impl NetworkNamespace { ], )?; + // Rule 5.5: ACCEPT DNS (UDP port 53) to the cluster nameserver. + // + // Some libraries (e.g. Node.js `ws`, used by @slack/socket-mode) + // resolve hostnames directly via the system resolver, bypassing + // HTTP_PROXY / HTTPS_PROXY. Allow UDP DNS to the nameserver + // configured in /etc/resolv.conf so that resolution succeeds + // without opening a broad UDP hole. + if let Some(dns_ip) = resolve_dns_server() { + let dns_ip_cidr = format!("{dns_ip}/32"); + if let Err(e) = run_iptables_netns( + &self.name, + iptables_cmd, + &[ + "-A", "OUTPUT", "-d", &dns_ip_cidr, "-p", "udp", "--dport", "53", "-j", + "ACCEPT", + ], + ) { + warn!( + error = %e, + dns_server = %dns_ip, + "Failed to install DNS ACCEPT rule (non-fatal, UDP DNS will be rejected)" + ); + } else { + info!(dns_server = %dns_ip, "Installed DNS ACCEPT rule for UDP port 53"); + } + } + // Rule 6: LOG UDP bypass attempts (rate-limited, covers DNS bypass) if let Err(e) = run_iptables_netns( &self.name, @@ -842,6 +990,27 @@ mod tests { // These tests require root and network namespace support // Run with: sudo cargo test -- --ignored + #[test] + fn test_parse_direct_tcp_hosts() { + std::env::set_var( + "OPENSHELL_DIRECT_TCP_HOSTS", + "oauth2.googleapis.com, gmail.googleapis.com , ", + ); + let hosts = parse_direct_tcp_hosts(); + assert_eq!(hosts, vec!["oauth2.googleapis.com", "gmail.googleapis.com"]); + std::env::remove_var("OPENSHELL_DIRECT_TCP_HOSTS"); + } + + #[test] + fn test_parse_direct_tcp_hosts_empty() { + std::env::remove_var("OPENSHELL_DIRECT_TCP_HOSTS"); + assert!(parse_direct_tcp_hosts().is_empty()); + + std::env::set_var("OPENSHELL_DIRECT_TCP_HOSTS", ""); + assert!(parse_direct_tcp_hosts().is_empty()); + std::env::remove_var("OPENSHELL_DIRECT_TCP_HOSTS"); + } + #[test] #[ignore = "requires root privileges"] fn test_create_and_drop_namespace() {