feat(linux-sandbox): activate managed DNS resolution

Co-authored-by: Codex noreply@openai.com
This commit is contained in:
viyatb-oai
2026-07-08 13:57:49 -07:00
parent a44b6fea62
commit 7e6915f1db
9 changed files with 544 additions and 16 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -3305,6 +3305,7 @@ dependencies = [
"landlock",
"libc",
"pretty_assertions",
"rustix 1.1.4",
"seccompiler",
"serde",
"serde_json",

View File

@@ -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",

View File

@@ -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 }

View File

@@ -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

View 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;

View 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);
}
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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}"
);
}