Skip to content
Open
Show file tree
Hide file tree
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
44 changes: 42 additions & 2 deletions crates/openshell-sandbox/src/child_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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())
Expand All @@ -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");
Expand Down
77 changes: 74 additions & 3 deletions crates/openshell-sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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]
Expand All @@ -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();
Expand Down Expand Up @@ -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()?;
Expand All @@ -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(())
Expand Down
169 changes: 169 additions & 0 deletions crates/openshell-sandbox/src/sandbox/linux/netns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<IpAddr> {
if let Ok(val) = std::env::var("OPENSHELL_DNS_SERVER") {
if let Ok(addr) = val.parse::<IpAddr>() {
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::<IpAddr>() {
return Some(addr);
}
}
}
}

None
}

/// Handle to a network namespace with veth pair.
///
/// The namespace and veth interfaces are automatically cleaned up on drop.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down
Loading