diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dc109aeaba..d0767f4141 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3309,6 +3309,7 @@ dependencies = [ "landlock", "libc", "pretty_assertions", + "rustix 1.1.4", "seccompiler", "serde", "serde_json", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index bc4330731d..8dadc1f0e4 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -388,6 +388,7 @@ rustls = { version = "0.23", default-features = false, features = [ ] } rustls-native-certs = "0.8.3" rustls-pki-types = "1.14.0" +rustix = { version = "1.1.4", features = ["thread"] } schemars = "0.8.22" seccompiler = "0.5.0" semver = "1.0" diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 0f7c68a152..713e5d2cee 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -31,6 +31,7 @@ seccompiler = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } +rustix = { workspace = true } url = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] diff --git a/codex-rs/linux-sandbox/src/dns_routing.rs b/codex-rs/linux-sandbox/src/dns_routing.rs new file mode 100644 index 0000000000..0a0da59ab6 --- /dev/null +++ b/codex-rs/linux-sandbox/src/dns_routing.rs @@ -0,0 +1,173 @@ +use codex_network_proxy::DNS_PROXY_ENV_KEY; +use codex_network_proxy::DNS_PROXY_SESSION_PREFACE; +use std::io; +use std::io::Read; +use std::io::Write; +use std::net::Ipv4Addr; +use std::net::SocketAddr; +use std::net::TcpListener; +use std::net::TcpStream; +use std::net::UdpSocket; +use std::time::Duration; +use url::Url; + +const SANDBOX_DNS_LISTEN_ADDR: (Ipv4Addr, u16) = (Ipv4Addr::LOCALHOST, 53); +const DNS_IO_TIMEOUT: Duration = Duration::from_secs(3); +const MAX_DNS_MESSAGE_BYTES: usize = usize::from(u16::MAX); + +pub(crate) struct BoundDnsStub { + udp_socket: UdpSocket, + tcp_listener: TcpListener, +} + +pub(crate) fn bind_netns_dns_stub() -> io::Result { + let udp_socket = UdpSocket::bind(SANDBOX_DNS_LISTEN_ADDR)?; + let tcp_listener = TcpListener::bind(SANDBOX_DNS_LISTEN_ADDR)?; + Ok(BoundDnsStub { + udp_socket, + tcp_listener, + }) +} + +pub(crate) fn spawn_netns_dns_stub(bound: BoundDnsStub) -> io::Result<()> { + let endpoint = take_dns_proxy_endpoint_from_env()?.ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "DNS bridge expected a proxy endpoint", + ) + })?; + let pid = unsafe { libc::fork() }; + if pid < 0 { + return Err(io::Error::last_os_error()); + } + if pid == 0 { + let status = i32::from( + crate::proxy_routing::harden_bridge_process() + .and_then(|_| run_netns_dns_stub(bound, endpoint)) + .is_err(), + ); + unsafe { libc::_exit(status) }; + } + Ok(()) +} + +fn take_dns_proxy_endpoint_from_env() -> io::Result> { + let Some(value) = std::env::var_os(DNS_PROXY_ENV_KEY) else { + return Ok(None); + }; + // SAFETY: the helper process is single-threaded at this point, before execing + // the user command. + unsafe { std::env::remove_var(DNS_PROXY_ENV_KEY) }; + let value = value + .into_string() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "DNS proxy URL is not UTF-8"))?; + parse_dns_proxy_endpoint(value.as_str()).map(Some) +} + +fn parse_dns_proxy_endpoint(value: &str) -> io::Result { + let parsed = Url::parse(value).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid DNS proxy URL: {err}"), + ) + })?; + if parsed.scheme() != "tcp" { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "DNS proxy URL must use tcp scheme", + )); + } + let Some(host) = parsed.host_str() else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "DNS proxy URL is missing host", + )); + }; + if host != "127.0.0.1" { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "DNS proxy URL must point at 127.0.0.1", + )); + } + let Some(port) = parsed.port() else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "DNS proxy URL is missing port", + )); + }; + Ok(SocketAddr::from((Ipv4Addr::LOCALHOST, port))) +} + +fn run_netns_dns_stub(bound: BoundDnsStub, endpoint: SocketAddr) -> io::Result<()> { + let udp_endpoint = endpoint; + std::thread::Builder::new().spawn(move || netns_udp_loop(bound.udp_socket, udp_endpoint))?; + netns_tcp_loop(bound.tcp_listener, endpoint) +} + +fn netns_udp_loop(socket: UdpSocket, endpoint: SocketAddr) -> io::Result<()> { + let mut query = vec![0; MAX_DNS_MESSAGE_BYTES]; + loop { + let (len, peer) = socket.recv_from(&mut query)?; + let response = resolve_through_proxy(endpoint, &query[..len])?; + socket.send_to(&response, peer)?; + } +} + +fn netns_tcp_loop(listener: TcpListener, endpoint: SocketAddr) -> io::Result<()> { + loop { + let (mut client, _) = listener.accept()?; + client.set_read_timeout(Some(DNS_IO_TIMEOUT))?; + client.set_write_timeout(Some(DNS_IO_TIMEOUT))?; + std::thread::spawn(move || { + let _ = serve_tcp_client(&mut client, endpoint); + }); + } +} + +fn serve_tcp_client(client: &mut TcpStream, endpoint: SocketAddr) -> io::Result<()> { + while let Ok(query) = read_frame(client) { + let response = resolve_through_proxy(endpoint, &query)?; + write_frame(client, &response)?; + } + Ok(()) +} + +fn resolve_through_proxy(endpoint: SocketAddr, query: &[u8]) -> io::Result> { + let mut stream = TcpStream::connect(endpoint)?; + stream.set_read_timeout(Some(DNS_IO_TIMEOUT))?; + stream.set_write_timeout(Some(DNS_IO_TIMEOUT))?; + stream.write_all(DNS_PROXY_SESSION_PREFACE)?; + let mut ack = [0_u8; DNS_PROXY_SESSION_PREFACE.len()]; + stream.read_exact(&mut ack)?; + if &ack != DNS_PROXY_SESSION_PREFACE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "DNS proxy rejected session preface", + )); + } + write_frame(&mut stream, query)?; + read_frame(&mut stream) +} + +fn write_frame(writer: &mut impl Write, payload: &[u8]) -> io::Result<()> { + let len = u16::try_from(payload.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "DNS message exceeds maximum wire length", + ) + })?; + writer.write_all(&len.to_be_bytes())?; + writer.write_all(payload) +} + +fn read_frame(reader: &mut impl Read) -> io::Result> { + let mut len = [0; 2]; + reader.read_exact(&mut len)?; + let mut payload = vec![0; usize::from(u16::from_be_bytes(len))]; + reader.read_exact(&mut payload)?; + Ok(payload) +} + +#[cfg(test)] +#[path = "dns_routing_tests.rs"] +mod tests; diff --git a/codex-rs/linux-sandbox/src/dns_routing_tests.rs b/codex-rs/linux-sandbox/src/dns_routing_tests.rs new file mode 100644 index 0000000000..3a68f7bc9a --- /dev/null +++ b/codex-rs/linux-sandbox/src/dns_routing_tests.rs @@ -0,0 +1,45 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::net::TcpListener; +use std::sync::mpsc; + +#[test] +fn parses_loopback_dns_proxy_endpoint() { + assert_eq!( + parse_dns_proxy_endpoint("tcp://127.0.0.1:43128").expect("endpoint"), + SocketAddr::from((Ipv4Addr::LOCALHOST, 43128)) + ); +} + +#[test] +fn rejects_non_loopback_dns_proxy_endpoint() { + let err = + parse_dns_proxy_endpoint("tcp://192.0.2.10:43128").expect_err("non-loopback endpoint"); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); +} + +#[test] +fn forwards_dns_query_to_private_proxy_session() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind DNS proxy fixture"); + let endpoint = listener.local_addr().expect("fixture local addr"); + let (received_tx, received_rx) = mpsc::channel(); + let expected_response = b"dns-response".to_vec(); + let response = expected_response.clone(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept DNS proxy session"); + let mut preface = [0_u8; DNS_PROXY_SESSION_PREFACE.len()]; + stream.read_exact(&mut preface).expect("read preface"); + assert_eq!(&preface, DNS_PROXY_SESSION_PREFACE); + stream + .write_all(DNS_PROXY_SESSION_PREFACE) + .expect("write preface ack"); + let query = read_frame(&mut stream).expect("read query"); + received_tx.send(query).expect("send query"); + write_frame(&mut stream, &response).expect("write response"); + }); + + let response = resolve_through_proxy(endpoint, b"dns-query").expect("proxy response"); + + assert_eq!(received_rx.recv().expect("query"), b"dns-query"); + assert_eq!(response, expected_response); +} diff --git a/codex-rs/linux-sandbox/src/dns_setup.rs b/codex-rs/linux-sandbox/src/dns_setup.rs new file mode 100644 index 0000000000..bc6b10bb34 --- /dev/null +++ b/codex-rs/linux-sandbox/src/dns_setup.rs @@ -0,0 +1,163 @@ +use crate::bwrap::BwrapArgs; +use codex_protocol::permissions::ReadDenyMatcher; +use codex_protocol::protocol::FileSystemSandboxPolicy; +use rustix::thread::CapabilitySet; +use rustix::thread::CapabilitySets; +use rustix::thread::capabilities; +use rustix::thread::capability_is_in_ambient_set; +use rustix::thread::capability_is_in_bounding_set; +use rustix::thread::clear_ambient_capability_set; +use rustix::thread::remove_capability_from_bounding_set; +use rustix::thread::set_capabilities; +use std::fs::File; +use std::io; +use std::io::Seek; +use std::io::SeekFrom; +use std::io::Write; +use std::os::fd::AsRawFd; +use std::os::fd::FromRawFd; +use std::path::Path; +use std::path::PathBuf; + +const RESOLV_CONF_PATH: &str = "/etc/resolv.conf"; +const LOADER_ENV_KEYS: &[&str] = &["LD_AUDIT", "LD_LIBRARY_PATH", "LD_PRELOAD"]; + +pub(crate) type LoaderEnvironment = Vec<(String, String)>; + +#[derive(Debug)] +pub(crate) struct ResolvConfMount { + pub(crate) file: File, + pub(crate) path: PathBuf, +} + +pub(crate) fn create_resolv_conf_file() -> io::Result { + let fd = unsafe { libc::memfd_create(c"codex-resolv-conf".as_ptr(), libc::MFD_CLOEXEC) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + let mut file = unsafe { File::from_raw_fd(fd) }; + file.write_all(b"nameserver 127.0.0.1\n")?; + file.seek(SeekFrom::Start(0))?; + Ok(file) +} + +pub(crate) fn resolv_conf_mount_path( + policy: &FileSystemSandboxPolicy, + cwd: &Path, +) -> io::Result { + let logical_path = Path::new(RESOLV_CONF_PATH); + let target_path = logical_path.canonicalize()?; + ensure_resolver_paths_allowed(policy, cwd, logical_path, &target_path)?; + Ok(target_path) +} + +fn ensure_resolver_paths_allowed( + policy: &FileSystemSandboxPolicy, + cwd: &Path, + logical_path: &Path, + target_path: &Path, +) -> io::Result<()> { + let deny_matcher = ReadDenyMatcher::new(policy, cwd); + if [logical_path, target_path].into_iter().any(|path| { + !policy.can_read_path_with_cwd(path, cwd) + || deny_matcher + .as_ref() + .is_some_and(|deny| deny.is_read_denied(path)) + }) { + return Err(io::Error::from(io::ErrorKind::PermissionDenied)); + } + Ok(()) +} + +pub(crate) fn capture_loader_environment() -> LoaderEnvironment { + LOADER_ENV_KEYS + .iter() + .filter_map(|&key| { + std::env::var_os(key).map(|value| { + ( + key.to_string(), + value + .into_string() + .unwrap_or_else(|_| panic!("{key} must contain valid UTF-8")), + ) + }) + }) + .collect() +} + +pub(crate) fn restore_loader_environment(environment: LoaderEnvironment) { + for (key, value) in environment { + // SAFETY: the setup process is single-threaded and has dropped all capabilities. + unsafe { std::env::set_var(key, value) }; + } +} + +pub(crate) fn append_bwrap_args(bwrap_args: &mut BwrapArgs, mount: ResolvConfMount) { + let separator = bwrap_args + .args + .iter() + .position(|arg| arg == "--") + .unwrap_or_else(|| panic!("bubblewrap argv is missing command separator '--'")); + let Some(parent) = mount.path.parent() else { + panic!("resolver configuration path has no parent"); + }; + let fd = mount.file.as_raw_fd().to_string(); + bwrap_args.args.splice( + separator..separator, + LOADER_ENV_KEYS + .iter() + .flat_map(|key| ["--unsetenv".to_string(), (*key).to_string()]) + .chain([ + "--cap-drop".to_string(), + "ALL".to_string(), + "--cap-add".to_string(), + "CAP_NET_BIND_SERVICE".to_string(), + "--cap-add".to_string(), + "CAP_SETPCAP".to_string(), + "--dir".to_string(), + parent.to_string_lossy().into_owned(), + "--perms".to_string(), + "444".to_string(), + "--ro-bind-data".to_string(), + fd, + mount.path.to_string_lossy().into_owned(), + ]), + ); + bwrap_args.preserved_files.push(mount.file); +} + +pub(crate) fn drop_and_verify_capabilities() -> io::Result<()> { + let ignore_unknown = |result: rustix::io::Result<()>| match result { + Ok(()) | Err(rustix::io::Errno::INVAL) => Ok(()), + Err(err) => Err(io::Error::from_raw_os_error(err.raw_os_error())), + }; + ignore_unknown(clear_ambient_capability_set())?; + for bit in 0..u64::BITS { + let capability = CapabilitySet::from_bits_retain(1_u64 << bit); + match capability_is_in_ambient_set(capability) { + Ok(false) | Err(rustix::io::Errno::INVAL) => {} + Ok(true) => return Err(io::Error::other("capability remained in ambient set")), + Err(err) => return Err(io::Error::from_raw_os_error(err.raw_os_error())), + } + ignore_unknown(remove_capability_from_bounding_set(capability))?; + match capability_is_in_bounding_set(capability) { + Ok(false) | Err(rustix::io::Errno::INVAL) => {} + Ok(true) => return Err(io::Error::other("capability remained in bounding set")), + Err(err) => return Err(io::Error::from_raw_os_error(err.raw_os_error())), + } + } + let empty = CapabilitySets { + effective: CapabilitySet::empty(), + permitted: CapabilitySet::empty(), + inheritable: CapabilitySet::empty(), + }; + set_capabilities(None, empty).map_err(io::Error::from)?; + if capabilities(None).map_err(io::Error::from)? != empty { + return Err(io::Error::other("capabilities remained after DNS setup")); + } + Ok(()) +} + +#[cfg(test)] +#[path = "dns_setup_tests.rs"] +mod tests; diff --git a/codex-rs/linux-sandbox/src/dns_setup_tests.rs b/codex-rs/linux-sandbox/src/dns_setup_tests.rs new file mode 100644 index 0000000000..76e5bafe08 --- /dev/null +++ b/codex-rs/linux-sandbox/src/dns_setup_tests.rs @@ -0,0 +1,76 @@ +use super::*; +use crate::bwrap::BwrapArgs; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; + +#[test] +fn unsets_loader_environment_before_inner_command() { + let mut bwrap_args = BwrapArgs { + args: vec![ + "bwrap".to_string(), + "--".to_string(), + "/bin/true".to_string(), + ], + preserved_files: Vec::new(), + synthetic_mount_targets: Vec::new(), + protected_create_targets: Vec::new(), + }; + append_bwrap_args( + &mut bwrap_args, + ResolvConfMount { + file: tempfile::tempfile().expect("temporary resolver configuration"), + path: PathBuf::from("/etc/resolv.conf"), + }, + ); + + let separator = bwrap_args + .args + .iter() + .position(|arg| arg == "--") + .expect("command separator"); + let setup_args = &bwrap_args.args[..separator]; + for key in ["LD_AUDIT", "LD_LIBRARY_PATH", "LD_PRELOAD"] { + assert!( + setup_args + .windows(2) + .any(|args| args == ["--unsetenv", key]), + "{key} must be unset before the privileged DNS setup command" + ); + } +} + +#[test] +fn rejects_read_denied_resolver_paths() { + let logical_path = PathBuf::from("/etc/resolv.conf"); + let target_path = PathBuf::from("/run/systemd/resolve/stub-resolv.conf"); + for denied_path in [&logical_path, &target_path] { + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: AbsolutePathBuf::from_absolute_path(denied_path.clone()) + .expect("absolute resolver configuration"), + }, + access: FileSystemAccessMode::Deny, + }, + ]); + + let err = ensure_resolver_paths_allowed( + &file_system_sandbox_policy, + Path::new("/"), + &logical_path, + &target_path, + ) + .expect_err("read-denied resolver configuration must be rejected"); + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + } +} diff --git a/codex-rs/linux-sandbox/src/launcher.rs b/codex-rs/linux-sandbox/src/launcher.rs index 9b80075531..e8a431a842 100644 --- a/codex-rs/linux-sandbox/src/launcher.rs +++ b/codex-rs/linux-sandbox/src/launcher.rs @@ -3,6 +3,7 @@ use std::ffi::CString; use std::fs::File; use std::os::raw::c_char; use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::process::Command; use std::sync::OnceLock; @@ -25,16 +26,19 @@ enum BubblewrapLauncher { struct SystemBwrapLauncher { program: AbsolutePathBuf, supports_argv0: bool, + supports_cap_add: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct SystemBwrapCapabilities { supports_argv0: bool, + supports_cap_add: bool, supports_perms: bool, + is_setuid: bool, } pub(crate) fn exec_bwrap(argv: Vec, preserved_files: Vec) -> ! { - match preferred_bwrap_launcher() { + match preferred_bwrap_launcher_for_argv(&argv) { BubblewrapLauncher::System(launcher) => { exec_system_bwrap(&launcher.program, argv, preserved_files) } @@ -48,6 +52,13 @@ pub(crate) fn exec_bwrap(argv: Vec, preserved_files: Vec) -> ! { } } +fn preferred_bwrap_launcher_for_argv(argv: &[String]) -> BubblewrapLauncher { + if argv.iter().any(|arg| arg == "--cap-add") { + return preferred_bwrap_launcher_supporting_cap_add(); + } + preferred_bwrap_launcher() +} + fn preferred_bwrap_launcher() -> BubblewrapLauncher { static LAUNCHER: OnceLock = OnceLock::new(); LAUNCHER @@ -66,6 +77,21 @@ fn preferred_bwrap_launcher() -> BubblewrapLauncher { .clone() } +fn preferred_bwrap_launcher_supporting_cap_add() -> BubblewrapLauncher { + match preferred_bwrap_launcher() { + BubblewrapLauncher::System(launcher) if launcher.supports_cap_add => { + BubblewrapLauncher::System(launcher) + } + BubblewrapLauncher::System(_) | BubblewrapLauncher::Unavailable => { + match bundled_bwrap::launcher() { + Some(launcher) => BubblewrapLauncher::Bundled(launcher), + None => BubblewrapLauncher::Unavailable, + } + } + bundled @ BubblewrapLauncher::Bundled(_) => bundled, + } +} + fn system_bwrap_launcher_for_path(system_bwrap_path: &Path) -> Option { system_bwrap_launcher_for_path_with_probe(system_bwrap_path, system_bwrap_capabilities) } @@ -80,7 +106,9 @@ fn system_bwrap_launcher_for_path_with_probe( let Some(SystemBwrapCapabilities { supports_argv0, + supports_cap_add, supports_perms: true, + is_setuid, }) = system_bwrap_capabilities(system_bwrap_path) else { return None; @@ -95,6 +123,7 @@ fn system_bwrap_launcher_for_path_with_probe( Some(SystemBwrapLauncher { program: system_bwrap_path, supports_argv0, + supports_cap_add: supports_cap_add && !is_setuid, }) } @@ -105,6 +134,14 @@ pub(crate) fn preferred_bwrap_supports_argv0() -> bool { } } +pub(crate) fn preferred_bwrap_supports_cap_add() -> bool { + match preferred_bwrap_launcher_supporting_cap_add() { + BubblewrapLauncher::System(launcher) => launcher.supports_cap_add, + BubblewrapLauncher::Bundled(_) => true, + BubblewrapLauncher::Unavailable => false, + } +} + fn system_bwrap_capabilities(system_bwrap_path: &Path) -> Option { // bubblewrap added `--argv0` in v0.9.0: // https://github.com/containers/bubblewrap/releases/tag/v0.9.0 @@ -117,9 +154,12 @@ fn system_bwrap_capabilities(system_bwrap_path: &Path) -> Option, + /// Internal loader environment restored after temporary DNS setup capabilities are dropped. + #[arg(long = "loader-environment", hide = true)] + pub loader_environment: Option, + /// When set, skip mounting a fresh `/proc` even though PID isolation is /// still enabled. This is primarily intended for restrictive container /// environments that deny `--proc /proc`. @@ -153,6 +169,7 @@ pub fn run_main() -> ! { apply_seccomp_then_exec, allow_network_for_proxy, proxy_route_spec, + loader_environment, no_proc, command, } = LandlockCommand::parse(); @@ -177,12 +194,35 @@ pub fn run_main() -> ! { // established the filesystem view. if apply_seccomp_then_exec { if allow_network_for_proxy { + let bound_dns = if std::env::var_os(DNS_PROXY_ENV_KEY).is_some() { + Some( + bind_netns_dns_stub() + .unwrap_or_else(|err| panic!("error binding Linux DNS bridge: {err}")), + ) + } else { + None + }; + let loader_environment = parse_loader_environment(loader_environment.as_deref()) + .unwrap_or_else(|err| panic!("invalid loader environment: {err}")); + if bound_dns.is_some() { + drop_and_verify_capabilities().unwrap_or_else(|err| { + panic!("error dropping Linux DNS setup capabilities: {err}") + }); + if let Some(loader_environment) = loader_environment { + restore_loader_environment(loader_environment); + } + } let spec = proxy_route_spec .as_deref() .unwrap_or_else(|| panic!("managed proxy mode requires --proxy-route-spec")); if let Err(err) = activate_proxy_routes_in_netns(spec) { panic!("error activating Linux proxy routing bridge: {err}"); } + if let Some(bound_dns) = bound_dns + && let Err(err) = spawn_netns_dns_stub(bound_dns) + { + panic!("error activating Linux DNS bridge: {err}"); + } } let proxy_routing_active = allow_network_for_proxy; if let Err(e) = apply_permission_profile_to_current_thread( @@ -214,6 +254,11 @@ pub fn run_main() -> ! { // Outer stage: bubblewrap first, then re-enter this binary in the // sandboxed environment to apply seccomp. This path never falls back // to legacy Landlock on failure. + let dns_setup = prepare_dns_setup( + allow_network_for_proxy, + &file_system_sandbox_policy, + &sandbox_policy_cwd, + ); let proxy_route_spec = if allow_network_for_proxy { Some(prepare_host_proxy_route_spec().unwrap_or_else(|err| { @@ -228,6 +273,9 @@ pub fn run_main() -> ! { permission_profile: &permission_profile, allow_network_for_proxy, proxy_route_spec, + loader_environment: dns_setup + .as_ref() + .map(|setup| serialize_loader_environment(&setup.loader_environment)), command, }); run_bwrap_with_proc_fallback( @@ -238,6 +286,7 @@ pub fn run_main() -> ! { inner, !no_proc, allow_network_for_proxy, + dns_setup.map(|setup| setup.resolv_conf), ); } @@ -254,6 +303,11 @@ pub fn run_main() -> ! { exec_or_panic(command); } +struct DnsSetup { + resolv_conf: ResolvConfMount, + loader_environment: LoaderEnvironment, +} + #[derive(Debug, Clone)] struct EffectivePermissions { permission_profile: PermissionProfile, @@ -278,6 +332,45 @@ fn parse_permission_profile(value: &str) -> std::result::Result, +) -> std::result::Result, String> { + value + .map(serde_json::from_str) + .transpose() + .map_err(|err| format!("invalid loader environment JSON: {err}")) +} + +fn serialize_loader_environment(environment: &LoaderEnvironment) -> String { + serde_json::to_string(environment) + .unwrap_or_else(|err| panic!("failed to serialize loader environment: {err}")) +} + +fn prepare_dns_setup( + allow_network_for_proxy: bool, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + sandbox_policy_cwd: &Path, +) -> Option { + if !allow_network_for_proxy || std::env::var_os(DNS_PROXY_ENV_KEY).is_none() { + return None; + } + if !preferred_bwrap_supports_cap_add() { + // SAFETY: the sandbox helper is single-threaded here, before it forks bridge workers or + // executes the user command. + unsafe { std::env::remove_var(DNS_PROXY_ENV_KEY) }; + return None; + } + Some(DnsSetup { + resolv_conf: ResolvConfMount { + file: create_resolv_conf_file() + .unwrap_or_else(|err| panic!("failed to create resolver configuration: {err}")), + path: resolv_conf_mount_path(file_system_sandbox_policy, sandbox_policy_cwd) + .unwrap_or_else(|err| panic!("failed to prepare resolver configuration: {err}")), + }, + loader_environment: capture_loader_environment(), + }) +} + fn resolve_permission_profile( permission_profile: Option, ) -> Result { @@ -322,6 +415,7 @@ fn run_bwrap_with_proc_fallback( inner: Vec, mount_proc: bool, allow_network_for_proxy: bool, + dns_resolv_conf: Option, ) -> ! { let network_mode = bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy); let mut mount_proc = mount_proc; @@ -354,6 +448,9 @@ fn run_bwrap_with_proc_fallback( options, ) .unwrap_or_else(|err| exit_with_bwrap_build_error(err)); + if let Some(dns_resolv_conf) = dns_resolv_conf { + append_dns_bwrap_args(&mut bwrap_args, dns_resolv_conf); + } apply_inner_command_argv0(&mut bwrap_args.args); run_or_exec_bwrap(bwrap_args); } @@ -1394,6 +1491,7 @@ struct InnerSeccompCommandArgs<'a> { permission_profile: &'a PermissionProfile, allow_network_for_proxy: bool, proxy_route_spec: Option, + loader_environment: Option, command: Vec, } @@ -1405,6 +1503,7 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec permission_profile, allow_network_for_proxy, proxy_route_spec, + loader_environment, command, } = args; let current_exe = match std::env::current_exe() { @@ -1436,6 +1535,10 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec .unwrap_or_else(|| panic!("managed proxy mode requires a proxy route spec")); inner.push("--proxy-route-spec".to_string()); inner.push(proxy_route_spec); + if let Some(loader_environment) = loader_environment { + inner.push("--loader-environment".to_string()); + inner.push(loader_environment); + } } inner.push("--".to_string()); inner.extend(command); diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index 4441af7809..e54b57cb18 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -468,6 +468,7 @@ fn managed_proxy_inner_command_includes_route_spec() { permission_profile: &permission_profile, allow_network_for_proxy: true, proxy_route_spec: Some("{\"routes\":[]}".to_string()), + loader_environment: None, command: vec!["/bin/true".to_string()], }); @@ -484,6 +485,7 @@ fn inner_command_includes_permission_profile_flag() { permission_profile: &permission_profile, allow_network_for_proxy: false, proxy_route_spec: None, + loader_environment: None, command: vec!["/bin/true".to_string()], }); @@ -503,6 +505,7 @@ fn non_managed_inner_command_omits_route_spec() { permission_profile: &permission_profile, allow_network_for_proxy: false, proxy_route_spec: None, + loader_environment: None, command: vec!["/bin/true".to_string()], }); @@ -519,6 +522,7 @@ fn managed_proxy_inner_command_requires_route_spec() { permission_profile: &permission_profile, allow_network_for_proxy: true, proxy_route_spec: None, + loader_environment: None, command: vec!["/bin/true".to_string()], }) }); diff --git a/codex-rs/linux-sandbox/src/proxy_routing.rs b/codex-rs/linux-sandbox/src/proxy_routing.rs index 707cbaafbe..be3217875e 100644 --- a/codex-rs/linux-sandbox/src/proxy_routing.rs +++ b/codex-rs/linux-sandbox/src/proxy_routing.rs @@ -1,3 +1,4 @@ +use codex_network_proxy::DNS_PROXY_ENV_KEY; use codex_network_proxy::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; use codex_network_proxy::write_attribution_frame; use serde::Deserialize; @@ -41,6 +42,7 @@ const PROXY_ENV_KEYS: &[&str] = &[ "PIP_PROXY", "DOCKER_HTTP_PROXY", "DOCKER_HTTPS_PROXY", + DNS_PROXY_ENV_KEY, ]; const PROXY_SOCKET_DIR_PREFIX: &str = "codex-linux-sandbox-proxy-"; @@ -672,7 +674,7 @@ fn set_parent_death_signal() -> io::Result<()> { } } -fn harden_bridge_process() -> io::Result<()> { +pub(crate) fn harden_bridge_process() -> io::Result<()> { set_parent_death_signal()?; codex_process_hardening::disable_process_dumping() } diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs index 6c9a6a0c67..3f34f627a9 100644 --- a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -2,6 +2,8 @@ #![allow(clippy::unwrap_used)] use codex_core::exec_env::create_env; +use codex_network_proxy::DNS_PROXY_ENV_KEY; +use codex_network_proxy::DNS_PROXY_SESSION_PREFACE; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::models::PermissionProfile; use pretty_assertions::assert_eq; @@ -40,6 +42,7 @@ const PROXY_ENV_KEYS: &[&str] = &[ "PIP_PROXY", "DOCKER_HTTP_PROXY", "DOCKER_HTTPS_PROXY", + DNS_PROXY_ENV_KEY, ]; fn create_env_from_core_vars() -> HashMap { @@ -301,3 +304,142 @@ async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() { String::from_utf8_lossy(&output.stderr) ); } + +#[tokio::test] +async fn managed_proxy_mode_routes_native_dns_through_bridge() { + if let Some(skip_reason) = managed_proxy_skip_reason().await { + eprintln!("skipping managed proxy test: {skip_reason}"); + return; + } + + if !command_available("python3").await { + eprintln!("skipping managed proxy DNS test: python3 is unavailable"); + return; + } + + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind DNS proxy listener"); + let proxy_port = listener.local_addr().expect("DNS proxy local addr").port(); + let (query_tx, query_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept DNS proxy connection"); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("set DNS proxy read timeout"); + let mut preface = [0_u8; DNS_PROXY_SESSION_PREFACE.len()]; + stream.read_exact(&mut preface).expect("read DNS preface"); + assert_eq!(&preface, DNS_PROXY_SESSION_PREFACE); + stream + .write_all(DNS_PROXY_SESSION_PREFACE) + .expect("write DNS preface ack"); + let query = read_frame(&mut stream).expect("read DNS query"); + let response = dns_a_response(&query, [203, 0, 113, 9]); + query_tx.send(query).expect("send DNS query"); + write_frame(&mut stream, &response).expect("write DNS response"); + }); + + let mut env = create_env_from_core_vars(); + strip_proxy_env(&mut env); + env.insert( + DNS_PROXY_ENV_KEY.to_string(), + format!("tcp://127.0.0.1:{proxy_port}"), + ); + + let output = run_linux_sandbox_direct( + &[ + "python3", + "-c", + "import os,socket,sys\nif 'CODEX_NETWORK_PROXY_DNS' in os.environ:\n sys.exit(3)\ninfo = socket.getaddrinfo('fixture.test', 80, socket.AF_INET, socket.SOCK_STREAM)\nprint(info[0][4][0])\n", + ], + &PermissionProfile::Disabled, + /*allow_network_for_proxy*/ true, + env, + NETWORK_TIMEOUT_MS, + ) + .await; + + assert_eq!( + output.status.success(), + true, + "expected native DNS to resolve through bridge; status={:?}; stdout={}; stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "203.0.113.9" + ); + let query = query_rx + .recv_timeout(Duration::from_secs(3)) + .expect("expected DNS query through proxy bridge"); + assert!( + dns_query_name(&query).is_some_and(|name| name == "fixture.test"), + "expected fixture.test DNS query, got wire bytes: {query:?}" + ); +} + +fn dns_query_name(query: &[u8]) -> Option { + let mut offset = 12usize; + let mut labels = Vec::new(); + loop { + let len = *query.get(offset)? as usize; + offset += 1; + if len == 0 { + break; + } + let label = query.get(offset..offset.checked_add(len)?)?; + labels.push(std::str::from_utf8(label).ok()?); + offset += len; + } + Some(labels.join(".")) +} + +fn dns_a_response(query: &[u8], address: [u8; 4]) -> Vec { + let question_end = dns_question_end(query).expect("DNS question end"); + let mut response = Vec::new(); + response.extend_from_slice(&query[..2]); + response.extend_from_slice(&[0x81, 0x80]); + response.extend_from_slice(&[0x00, 0x01]); + response.extend_from_slice(&[0x00, 0x01]); + response.extend_from_slice(&[0x00, 0x00]); + response.extend_from_slice(&[0x00, 0x00]); + response.extend_from_slice(&query[12..question_end]); + response.extend_from_slice(&[0xc0, 0x0c]); + response.extend_from_slice(&[0x00, 0x01]); + response.extend_from_slice(&[0x00, 0x01]); + response.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); + response.extend_from_slice(&[0x00, 0x04]); + response.extend_from_slice(&address); + response +} + +fn dns_question_end(query: &[u8]) -> Option { + let mut offset = 12usize; + loop { + let len = *query.get(offset)? as usize; + offset += 1; + if len == 0 { + return offset.checked_add(4).filter(|end| *end <= query.len()); + } + offset = offset.checked_add(len)?; + } +} + +fn write_frame(writer: &mut impl Write, payload: &[u8]) -> std::io::Result<()> { + let len = u16::try_from(payload.len()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "DNS message exceeds maximum wire length", + ) + })?; + writer.write_all(&len.to_be_bytes())?; + writer.write_all(payload) +} + +fn read_frame(reader: &mut impl Read) -> std::io::Result> { + let mut len = [0; 2]; + reader.read_exact(&mut len)?; + let mut payload = vec![0; usize::from(u16::from_be_bytes(len))]; + reader.read_exact(&mut payload)?; + Ok(payload) +}