mirror of
https://github.com/openai/codex.git
synced 2026-09-06 15:29:32 +00:00
feat(linux-sandbox): activate managed DNS resolution
Co-authored-by: Codex noreply@openai.com
This commit is contained in:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -3305,6 +3305,7 @@ dependencies = [
|
||||
"landlock",
|
||||
"libc",
|
||||
"pretty_assertions",
|
||||
"rustix 1.1.4",
|
||||
"seccompiler",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -381,6 +381,7 @@ regex-lite = "0.1.8"
|
||||
reqwest = { version = "0.12", features = ["cookies"] }
|
||||
rmcp = { version = "1.8.0", default-features = false }
|
||||
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
|
||||
rustix = { version = "1.1.4", features = ["thread"] }
|
||||
rustls = { version = "0.23", default-features = false, features = [
|
||||
"aws_lc_rs",
|
||||
"std",
|
||||
|
||||
@@ -29,6 +29,7 @@ globset = { workspace = true }
|
||||
hickory-proto = { workspace = true }
|
||||
landlock = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
rustix = { workspace = true }
|
||||
seccompiler = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -88,6 +88,10 @@ commands that would enter the bubblewrap path.
|
||||
- In managed proxy mode, the helper uses `--unshare-net` plus an internal
|
||||
TCP->UDS->TCP routing bridge so tool traffic reaches only configured proxy
|
||||
endpoints.
|
||||
- When a managed domain policy is active, a loopback resolver answers only
|
||||
permitted names through the host resolver while direct DNS egress remains
|
||||
isolated. The policy is snapshotted when the command starts, and canonical
|
||||
names are returned only when they are also permitted.
|
||||
- In managed proxy mode, after the bridge is live, seccomp blocks new
|
||||
AF_UNIX/socketpair creation for the user command.
|
||||
- When bubblewrap is active, it mounts a fresh `/proc` via `--proc /proc` by default, but
|
||||
|
||||
162
codex-rs/linux-sandbox/src/dns_setup.rs
Normal file
162
codex-rs/linux-sandbox/src/dns_setup.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use crate::bwrap::BwrapArgs;
|
||||
use crate::proxy_routing::LoaderEnvironment;
|
||||
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"];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ResolvConfMount {
|
||||
pub(crate) file: File,
|
||||
pub(crate) path: PathBuf,
|
||||
}
|
||||
|
||||
pub(crate) fn create_resolv_conf_file() -> io::Result<File> {
|
||||
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<PathBuf> {
|
||||
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)
|
||||
}
|
||||
|
||||
pub(crate) 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;
|
||||
76
codex-rs/linux-sandbox/src/dns_setup_tests.rs
Normal file
76
codex-rs/linux-sandbox/src/dns_setup_tests.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ mod bwrap;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod dns_routing;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod dns_setup;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod exec_util;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod landlock;
|
||||
|
||||
@@ -5,7 +5,6 @@ use std::fs;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Read;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::fd::FromRawFd;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::path::Path;
|
||||
@@ -20,11 +19,23 @@ use std::time::Duration;
|
||||
use crate::bwrap::BwrapNetworkMode;
|
||||
use crate::bwrap::BwrapOptions;
|
||||
use crate::bwrap::create_bwrap_command_args;
|
||||
use crate::dns_setup::ResolvConfMount;
|
||||
use crate::dns_setup::append_bwrap_args as append_dns_setup_bwrap_args;
|
||||
use crate::dns_setup::capture_loader_environment;
|
||||
use crate::dns_setup::create_resolv_conf_file;
|
||||
use crate::dns_setup::drop_and_verify_capabilities;
|
||||
use crate::dns_setup::resolv_conf_mount_path;
|
||||
use crate::dns_setup::restore_loader_environment as apply_loader_environment;
|
||||
use crate::landlock::apply_permission_profile_to_current_thread;
|
||||
use crate::launcher::exec_bwrap;
|
||||
use crate::launcher::preferred_bwrap_supports_argv0;
|
||||
use crate::proxy_routing::DnsRouteConfig;
|
||||
use crate::proxy_routing::activate_proxy_routes_in_netns;
|
||||
use crate::proxy_routing::activate_proxy_routes_with_dns_in_netns;
|
||||
use crate::proxy_routing::bind_dns_route_in_netns;
|
||||
use crate::proxy_routing::prepare_host_proxy_route_spec;
|
||||
use crate::proxy_routing::prepare_host_proxy_routes;
|
||||
use codex_network_proxy::ManagedNetworkDomainPolicy;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::FileSystemSandboxPolicy;
|
||||
@@ -122,6 +133,14 @@ pub struct LandlockCommand {
|
||||
#[arg(long = "allow-network-for-proxy", hide = true, default_value_t = false)]
|
||||
pub allow_network_for_proxy: bool,
|
||||
|
||||
/// Internal domain policy for DNS routed through the managed proxy bridge.
|
||||
#[arg(
|
||||
long = "dns-domain-policy",
|
||||
hide = true,
|
||||
value_parser = parse_dns_domain_policy
|
||||
)]
|
||||
pub dns_domain_policy: Option<ManagedNetworkDomainPolicy>,
|
||||
|
||||
/// Internal route spec used for managed proxy routing in bwrap mode.
|
||||
#[arg(long = "proxy-route-spec", hide = true)]
|
||||
pub proxy_route_spec: Option<String>,
|
||||
@@ -152,6 +171,7 @@ pub fn run_main() -> ! {
|
||||
use_legacy_landlock,
|
||||
apply_seccomp_then_exec,
|
||||
allow_network_for_proxy,
|
||||
dns_domain_policy,
|
||||
proxy_route_spec,
|
||||
no_proc,
|
||||
command,
|
||||
@@ -160,6 +180,9 @@ pub fn run_main() -> ! {
|
||||
if command.is_empty() {
|
||||
panic!("No command specified to execute.");
|
||||
}
|
||||
if dns_domain_policy.is_some() && (!allow_network_for_proxy || use_legacy_landlock) {
|
||||
panic!("--dns-domain-policy requires managed proxy mode with bubblewrap");
|
||||
}
|
||||
ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_legacy_landlock);
|
||||
let EffectivePermissions {
|
||||
permission_profile,
|
||||
@@ -180,7 +203,20 @@ pub fn run_main() -> ! {
|
||||
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) {
|
||||
let bound_dns = bind_dns_route_in_netns(spec)
|
||||
.unwrap_or_else(|err| panic!("error binding Linux DNS bridge: {err}"));
|
||||
let bound_dns_stub = bound_dns.map(|route| {
|
||||
drop_and_verify_capabilities().unwrap_or_else(|err| {
|
||||
panic!("error dropping Linux DNS setup capabilities: {err}")
|
||||
});
|
||||
apply_loader_environment(route.loader_environment);
|
||||
route.stub
|
||||
});
|
||||
let activation = match bound_dns_stub {
|
||||
Some(stub) => activate_proxy_routes_with_dns_in_netns(spec, Some(stub)),
|
||||
None => activate_proxy_routes_in_netns(spec),
|
||||
};
|
||||
if let Err(err) = activation {
|
||||
panic!("error activating Linux proxy routing bridge: {err}");
|
||||
}
|
||||
}
|
||||
@@ -214,14 +250,36 @@ 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 proxy_route_spec =
|
||||
if allow_network_for_proxy {
|
||||
Some(prepare_host_proxy_route_spec().unwrap_or_else(|err| {
|
||||
panic!("failed to prepare host proxy routing bridge: {err}")
|
||||
let dns_resolv_conf = dns_domain_policy.as_ref().map(|_| 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}")),
|
||||
});
|
||||
let prepared_proxy_routes = allow_network_for_proxy.then(|| {
|
||||
if let Some(policy) = dns_domain_policy.as_ref() {
|
||||
prepare_host_proxy_routes(Some(DnsRouteConfig {
|
||||
policy,
|
||||
loader_environment: capture_loader_environment(),
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
prepare_host_proxy_route_spec().map(|serialized_spec| {
|
||||
crate::proxy_routing::PreparedProxyRoutes {
|
||||
serialized_spec,
|
||||
dns_relay_files: Vec::new(),
|
||||
}
|
||||
})
|
||||
}
|
||||
.unwrap_or_else(|err| panic!("failed to prepare host proxy routing bridge: {err}"))
|
||||
});
|
||||
let (proxy_route_spec, dns_resolv_conf, dns_relay_files) = match prepared_proxy_routes {
|
||||
Some(prepared) => (
|
||||
Some(prepared.serialized_spec),
|
||||
dns_resolv_conf,
|
||||
prepared.dns_relay_files,
|
||||
),
|
||||
None => (None, dns_resolv_conf, Vec::new()),
|
||||
};
|
||||
let inner = build_inner_seccomp_command(InnerSeccompCommandArgs {
|
||||
sandbox_policy_cwd: &sandbox_policy_cwd,
|
||||
command_cwd: command_cwd.as_deref(),
|
||||
@@ -238,6 +296,8 @@ pub fn run_main() -> ! {
|
||||
inner,
|
||||
!no_proc,
|
||||
allow_network_for_proxy,
|
||||
dns_resolv_conf,
|
||||
dns_relay_files,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -278,6 +338,10 @@ fn parse_permission_profile(value: &str) -> std::result::Result<PermissionProfil
|
||||
serde_json::from_str(value).map_err(|err| format!("invalid permission profile JSON: {err}"))
|
||||
}
|
||||
|
||||
fn parse_dns_domain_policy(value: &str) -> std::result::Result<ManagedNetworkDomainPolicy, String> {
|
||||
serde_json::from_str(value).map_err(|err| format!("invalid DNS domain policy JSON: {err}"))
|
||||
}
|
||||
|
||||
fn resolve_permission_profile(
|
||||
permission_profile: Option<PermissionProfile>,
|
||||
) -> Result<EffectivePermissions, ResolvePermissionProfileError> {
|
||||
@@ -322,6 +386,8 @@ fn run_bwrap_with_proc_fallback(
|
||||
inner: Vec<String>,
|
||||
mount_proc: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
dns_resolv_conf: Option<ResolvConfMount>,
|
||||
dns_relay_files: Vec<File>,
|
||||
) -> ! {
|
||||
let network_mode = bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy);
|
||||
let mut mount_proc = mount_proc;
|
||||
@@ -354,6 +420,10 @@ fn run_bwrap_with_proc_fallback(
|
||||
options,
|
||||
)
|
||||
.unwrap_or_else(|err| exit_with_bwrap_build_error(err));
|
||||
if let Some(resolv_conf) = dns_resolv_conf {
|
||||
append_dns_setup_bwrap_args(&mut bwrap_args, resolv_conf);
|
||||
}
|
||||
bwrap_args.preserved_files.extend(dns_relay_files);
|
||||
apply_inner_command_argv0(&mut bwrap_args.args);
|
||||
run_or_exec_bwrap(bwrap_args);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_protocol::config_types::ShellEnvironmentPolicy;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::Ipv4Addr;
|
||||
@@ -59,6 +60,18 @@ fn is_bwrap_unavailable_output(output: &Output) -> bool {
|
||||
String::from_utf8_lossy(&output.stderr).contains(BWRAP_UNAVAILABLE_ERR)
|
||||
}
|
||||
|
||||
async fn command_available(program: &str) -> bool {
|
||||
matches!(
|
||||
Command::new(program)
|
||||
.arg("--version")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.await,
|
||||
Ok(status) if status.success()
|
||||
)
|
||||
}
|
||||
|
||||
async fn should_skip_bwrap_tests() -> bool {
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
@@ -67,6 +80,7 @@ async fn should_skip_bwrap_tests() -> bool {
|
||||
&["bash", "-c", "true"],
|
||||
&PermissionProfile::read_only(),
|
||||
/*allow_network_for_proxy*/ false,
|
||||
/*dns_domain_policy*/ None,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
@@ -93,6 +107,7 @@ async fn managed_proxy_skip_reason() -> Option<String> {
|
||||
&["bash", "-c", "true"],
|
||||
&PermissionProfile::Disabled,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
/*dns_domain_policy*/ None,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
@@ -116,6 +131,7 @@ async fn run_linux_sandbox_direct(
|
||||
command: &[&str],
|
||||
permission_profile: &PermissionProfile,
|
||||
allow_network_for_proxy: bool,
|
||||
dns_domain_policy: Option<&str>,
|
||||
env: HashMap<String, String>,
|
||||
timeout_ms: u64,
|
||||
) -> Output {
|
||||
@@ -132,6 +148,9 @@ async fn run_linux_sandbox_direct(
|
||||
if allow_network_for_proxy {
|
||||
args.push("--allow-network-for-proxy".to_string());
|
||||
}
|
||||
if let Some(policy) = dns_domain_policy {
|
||||
args.extend(["--dns-domain-policy".to_string(), policy.to_string()]);
|
||||
}
|
||||
args.push("--".to_string());
|
||||
args.extend(command.iter().map(|entry| (*entry).to_string()));
|
||||
|
||||
@@ -163,6 +182,7 @@ async fn managed_proxy_mode_fails_closed_without_proxy_env() {
|
||||
&["bash", "-c", "true"],
|
||||
&PermissionProfile::Disabled,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
/*dns_domain_policy*/ None,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
@@ -218,6 +238,7 @@ async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() {
|
||||
],
|
||||
&PermissionProfile::Disabled,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
/*dns_domain_policy*/ None,
|
||||
env.clone(),
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
@@ -249,6 +270,7 @@ async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() {
|
||||
&["bash", "-c", "echo hi > /dev/tcp/192.0.2.1/80"],
|
||||
&PermissionProfile::Disabled,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
/*dns_domain_policy*/ None,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
@@ -263,14 +285,7 @@ async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() {
|
||||
return;
|
||||
}
|
||||
|
||||
let python_available = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg("command -v python3 >/dev/null")
|
||||
.status()
|
||||
.await
|
||||
.expect("python3 probe should execute")
|
||||
.success();
|
||||
if !python_available {
|
||||
if !command_available("python3").await {
|
||||
eprintln!("skipping managed proxy AF_UNIX test: python3 is unavailable");
|
||||
return;
|
||||
}
|
||||
@@ -287,6 +302,7 @@ async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() {
|
||||
],
|
||||
&PermissionProfile::Disabled,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
/*dns_domain_policy*/ None,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
@@ -301,3 +317,198 @@ async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() {
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_dns_applies_domain_policy_and_drops_setup_capabilities() {
|
||||
if managed_proxy_skip_reason().await.is_some()
|
||||
|| !command_available("python3").await
|
||||
|| !command_available("cc").await
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let temp = tempfile::tempdir().expect("temporary loader-hook directory");
|
||||
let source = temp.path().join("cap_hook.c");
|
||||
let library = temp.path().join("cap_hook.so");
|
||||
let hook_log = temp.path().join("cap_hook.log");
|
||||
fs::write(
|
||||
&source,
|
||||
r#"#define _GNU_SOURCE
|
||||
#include <dlfcn.h>
|
||||
#include <netdb.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
typedef int (*getaddrinfo_fn)(const char *, const char *, const struct addrinfo *, struct addrinfo **);
|
||||
static int dns_name_equal(const char *node, const char *fixture) {
|
||||
size_t fixture_len;
|
||||
if (!node || !fixture) return 0;
|
||||
fixture_len = strlen(fixture);
|
||||
return !strcmp(node, fixture) ||
|
||||
(!strncmp(node, fixture, fixture_len) && node[fixture_len] == '.' && !node[fixture_len + 1]);
|
||||
}
|
||||
static const char *resolver_fixture_address(const char *node) {
|
||||
const char *fixture4 = getenv("DNS_RESOLVER_FIXTURE4");
|
||||
const char *fixture6 = getenv("DNS_RESOLVER_FIXTURE6");
|
||||
const char *denied_canonical = getenv("DNS_RESOLVER_FIXTURE_DENIED_CANONICAL");
|
||||
char exe[4096];
|
||||
ssize_t len;
|
||||
if (!dns_name_equal(node, fixture4) && !dns_name_equal(node, fixture6) &&
|
||||
!dns_name_equal(node, denied_canonical)) return NULL;
|
||||
len = readlink("/proc/self/exe", exe, sizeof(exe) - 1);
|
||||
if (len < 0) return NULL;
|
||||
exe[len] = 0;
|
||||
if (!strstr(exe, "codex-linux-sandbox")) return NULL;
|
||||
if (dns_name_equal(node, fixture4)) return "192.0.2.53";
|
||||
if (dns_name_equal(node, fixture6)) return "2001:db8::53";
|
||||
return "192.0.2.54";
|
||||
}
|
||||
int getaddrinfo(const char *node, const char *service, const struct addrinfo *hints, struct addrinfo **res) {
|
||||
getaddrinfo_fn real = (getaddrinfo_fn)dlsym(RTLD_NEXT, "getaddrinfo");
|
||||
const char *address = resolver_fixture_address(node);
|
||||
struct addrinfo numeric = {0};
|
||||
if (!real) return EAI_SYSTEM;
|
||||
if (!address) return real(node, service, hints, res);
|
||||
if (hints) {
|
||||
numeric.ai_socktype = hints->ai_socktype;
|
||||
numeric.ai_protocol = hints->ai_protocol;
|
||||
}
|
||||
numeric.ai_flags = AI_NUMERICHOST;
|
||||
numeric.ai_family = strchr(address, ':') ? AF_INET6 : AF_INET;
|
||||
int status = real(address, service, &numeric, res);
|
||||
if (!status && hints && (hints->ai_flags & AI_CANONNAME) && *res) {
|
||||
const char *canonical = dns_name_equal(node, getenv("DNS_RESOLVER_FIXTURE_DENIED_CANONICAL"))
|
||||
? "blocked.fixture.test"
|
||||
: (strchr(address, ':') ? "canonical6.fixture.test" : "canonical.fixture.test");
|
||||
char *copy = strdup(canonical);
|
||||
if (!copy) { freeaddrinfo(*res); *res = NULL; return EAI_MEMORY; }
|
||||
(*res)->ai_canonname = copy;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
__attribute__((constructor)) static void record_caps(void) {
|
||||
const char *path = getenv("DNS_CAP_LOG");
|
||||
if (!path) return;
|
||||
FILE *status = fopen("/proc/self/status", "r");
|
||||
char line[256], exe[4096];
|
||||
unsigned long long caps = 0;
|
||||
while (status && fgets(line, sizeof(line), status))
|
||||
if (!strncmp(line, "Cap", 3)) caps |= strtoull(strchr(line, '\t') + 1, NULL, 16);
|
||||
if (status) fclose(status);
|
||||
ssize_t len = readlink("/proc/self/exe", exe, sizeof(exe) - 1);
|
||||
if (len < 0) return;
|
||||
exe[len] = 0;
|
||||
FILE *out = fopen(path, "a");
|
||||
if (out) { fprintf(out, "%s %llx\n", exe, caps); fclose(out); }
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.expect("write loader hook");
|
||||
let build = Command::new("cc")
|
||||
.args(["-shared", "-fPIC", "-o"])
|
||||
.arg(&library)
|
||||
.arg(&source)
|
||||
.arg("-ldl")
|
||||
.output()
|
||||
.await
|
||||
.expect("compile loader hook");
|
||||
assert!(
|
||||
build.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:9".to_string());
|
||||
env.insert("LD_PRELOAD".to_string(), library.display().to_string());
|
||||
env.insert("DNS_CAP_LOG".to_string(), hook_log.display().to_string());
|
||||
env.insert(
|
||||
"DNS_RESOLVER_FIXTURE4".to_string(),
|
||||
"resolver.fixture.test".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
"DNS_RESOLVER_FIXTURE6".to_string(),
|
||||
"resolver6.fixture.test".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
"DNS_RESOLVER_FIXTURE_DENIED_CANONICAL".to_string(),
|
||||
"denied-canonical.fixture.test".to_string(),
|
||||
);
|
||||
let policy = r#"{"allowedDomains":["localhost","**.fixture.test"],"deniedDomains":["blocked.fixture.test"]}"#;
|
||||
let script = r#"
|
||||
import socket, struct
|
||||
assert open('/etc/resolv.conf').read() == 'nameserver 127.0.0.1\n'
|
||||
def skip_name(wire, offset):
|
||||
while True:
|
||||
size = wire[offset]
|
||||
offset += 1
|
||||
if size == 0: return offset
|
||||
if size & 0xc0 == 0xc0: return offset + 1
|
||||
offset += size
|
||||
def query(name, qtype=1, tcp=False):
|
||||
labels = b''.join(bytes([len(part)]) + part.encode() for part in name.split('.')) + b'\0'
|
||||
wire = struct.pack('!HHHHHH', 1, 0x100, 1, 0, 0, 0) + labels + struct.pack('!HH', qtype, 1)
|
||||
if tcp:
|
||||
with socket.create_connection(('127.0.0.1', 53)) as sock:
|
||||
stream = sock.makefile('rwb')
|
||||
stream.write(struct.pack('!H', len(wire)) + wire); stream.flush()
|
||||
size = struct.unpack('!H', stream.read(2))[0]
|
||||
reply = stream.read(size)
|
||||
assert len(reply) == size
|
||||
else:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.sendto(wire, ('127.0.0.1', 53)); reply = sock.recv(65535)
|
||||
question_count, answer_count = struct.unpack('!HH', reply[4:8])
|
||||
offset = 12
|
||||
for _ in range(question_count): offset = skip_name(reply, offset) + 4
|
||||
answer_types = []
|
||||
for _ in range(answer_count):
|
||||
offset = skip_name(reply, offset)
|
||||
answer_types.append(struct.unpack('!H', reply[offset:offset + 2])[0])
|
||||
data_length = struct.unpack('!H', reply[offset + 8:offset + 10])[0]
|
||||
offset += 10 + data_length
|
||||
return reply[3] & 15, answer_types
|
||||
assert query('resolver.fixture.test') == (0, [5, 1])
|
||||
assert query('resolver6.fixture.test', qtype=28) == (0, [5, 28])
|
||||
assert query('resolver.fixture.test', qtype=5) == (0, [5])
|
||||
assert query('resolver.fixture.test', tcp=True) == (0, [5, 1])
|
||||
assert query('denied-canonical.fixture.test') == (0, [1])
|
||||
assert query('denied-canonical.fixture.test', qtype=5) == (5, [])
|
||||
assert query('blocked.fixture.test') == (5, [])
|
||||
resolved = socket.getaddrinfo('resolver.fixture.test', 0, socket.AF_INET, 0, 0, socket.AI_CANONNAME)
|
||||
assert {entry[4][0] for entry in resolved} == {'192.0.2.53'}
|
||||
assert resolved[0][3] == 'canonical.fixture.test'
|
||||
try: socket.getaddrinfo('blocked.fixture.test', 0)
|
||||
except socket.gaierror: pass
|
||||
else: raise AssertionError('denied name resolved')
|
||||
with open('/proc/self/status') as status:
|
||||
caps = [int(line.split()[1], 16) for line in status if line.startswith(('CapInh:', 'CapPrm:', 'CapEff:', 'CapBnd:', 'CapAmb:'))]
|
||||
assert caps == [0] * 5, caps
|
||||
"#;
|
||||
let output = run_linux_sandbox_direct(
|
||||
&["python3", "-c", script],
|
||||
&PermissionProfile::Disabled,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
/*dns_domain_policy*/ Some(policy),
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"expected policy-checked DNS with no residual capabilities: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let hook_log = fs::read_to_string(&hook_log).expect("read loader-hook log");
|
||||
assert!(
|
||||
hook_log.lines().any(|line| line.contains("python")),
|
||||
"{hook_log}"
|
||||
);
|
||||
assert!(
|
||||
hook_log.lines().all(|line| line.ends_with(" 0")),
|
||||
"loader hook ran with capabilities: {hook_log}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user