feat(linux-sandbox): route direct HTTP traffic

Co-authored-by: Codex noreply@openai.com
This commit is contained in:
viyatb-oai
2026-07-08 12:41:20 -07:00
co-authored by Codex noreply@openai.com
parent 927004c06d
commit 4a534cd1f8
9 changed files with 724 additions and 68 deletions
+75 -7
View File
@@ -17,7 +17,9 @@ use std::fs;
use std::fs::File;
use std::fs::Metadata;
use std::io;
use std::io::Write;
use std::os::fd::AsRawFd;
use std::os::fd::FromRawFd;
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::MetadataExt;
use std::path::Path;
@@ -54,6 +56,9 @@ const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[
];
const MAX_UNREADABLE_GLOB_MATCHES: usize = 8192;
const RESOLV_CONF_PATH: &str = "/etc/resolv.conf";
const SANDBOX_RESOLV_CONF: &[u8] = b"nameserver 127.0.0.1\n";
const PROXY_SETUP_CAPABILITIES: &[&str] = &["CAP_NET_ADMIN", "CAP_NET_BIND_SERVICE", "CAP_SETPCAP"];
/// Options that control how bubblewrap is invoked.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -65,6 +70,8 @@ pub(crate) struct BwrapOptions {
pub mount_proc: bool,
/// How networking should be configured inside the bubblewrap sandbox.
pub network_mode: BwrapNetworkMode,
/// Whether proxy-only mode should configure transparent HTTP routing.
pub enable_transparent_proxy: bool,
/// Optional maximum depth for expanding unreadable glob patterns with ripgrep.
///
/// Keep this uncapped by default so existing nested deny-read matches are
@@ -77,6 +84,7 @@ impl Default for BwrapOptions {
Self {
mount_proc: true,
network_mode: BwrapNetworkMode::FullAccess,
enable_transparent_proxy: false,
glob_scan_max_depth: None,
}
}
@@ -251,7 +259,7 @@ pub(crate) fn create_bwrap_command_args(
protected_create_targets: Vec::new(),
})
} else {
Ok(create_bwrap_flags_full_filesystem(command, options))
create_bwrap_flags_full_filesystem(command, options)
};
}
@@ -264,7 +272,10 @@ pub(crate) fn create_bwrap_command_args(
)
}
fn create_bwrap_flags_full_filesystem(command: Vec<String>, options: BwrapOptions) -> BwrapArgs {
fn create_bwrap_flags_full_filesystem(
command: Vec<String>,
options: BwrapOptions,
) -> Result<BwrapArgs> {
let mut args = vec![
"--new-session".to_string(),
"--die-with-parent".to_string(),
@@ -283,14 +294,21 @@ fn create_bwrap_flags_full_filesystem(command: Vec<String>, options: BwrapOption
args.push("--proc".to_string());
args.push("/proc".to_string());
}
let mut preserved_files = Vec::new();
append_proxy_only_setup_args(
&mut args,
&mut preserved_files,
options.network_mode,
options.enable_transparent_proxy,
)?;
args.push("--".to_string());
args.extend(command);
BwrapArgs {
Ok(BwrapArgs {
args,
preserved_files: Vec::new(),
preserved_files,
synthetic_mount_targets: Vec::new(),
protected_create_targets: Vec::new(),
}
})
}
/// Build the bubblewrap flags (everything after `argv[0]`).
@@ -303,7 +321,7 @@ fn create_bwrap_flags(
) -> Result<BwrapArgs> {
let BwrapArgs {
args: filesystem_args,
preserved_files,
mut preserved_files,
synthetic_mount_targets,
protected_create_targets,
} = create_filesystem_args(
@@ -330,6 +348,12 @@ fn create_bwrap_flags(
args.push("--proc".to_string());
args.push("/proc".to_string());
}
append_proxy_only_setup_args(
&mut args,
&mut preserved_files,
options.network_mode,
options.enable_transparent_proxy,
)?;
if normalized_command_cwd.as_path() != command_cwd {
// Bubblewrap otherwise inherits the helper's logical cwd, which can be
// a symlink alias that disappears once the sandbox only mounts
@@ -348,6 +372,51 @@ fn create_bwrap_flags(
})
}
fn append_proxy_only_setup_args(
args: &mut Vec<String>,
preserved_files: &mut Vec<File>,
network_mode: BwrapNetworkMode,
enable_transparent_proxy: bool,
) -> Result<()> {
if network_mode != BwrapNetworkMode::ProxyOnly || !enable_transparent_proxy {
return Ok(());
}
for capability in PROXY_SETUP_CAPABILITIES {
args.push("--cap-add".to_string());
args.push((*capability).to_string());
}
let resolv_conf_target = fs::canonicalize(RESOLV_CONF_PATH)?;
let resolv_conf = anonymous_data_file(SANDBOX_RESOLV_CONF)?;
let resolv_conf_fd = resolv_conf.as_raw_fd().to_string();
preserved_files.push(resolv_conf);
append_mount_target_parent_dir_args(args, &resolv_conf_target, Path::new("/"));
args.extend([
"--perms".to_string(),
"0444".to_string(),
"--ro-bind-data".to_string(),
resolv_conf_fd,
path_to_string(&resolv_conf_target),
]);
Ok(())
}
fn anonymous_data_file(contents: &[u8]) -> io::Result<File> {
let mut pipe_fds = [0; 2];
if unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: pipe2 initialized both descriptors, and each is moved into exactly one File.
let read_file = unsafe { File::from_raw_fd(pipe_fds[0]) };
// SAFETY: see above.
let mut write_file = unsafe { File::from_raw_fd(pipe_fds[1]) };
write_file.write_all(contents)?;
drop(write_file);
Ok(read_file)
}
/// Build the bubblewrap filesystem mounts for a given filesystem policy.
///
/// The mount order is important:
@@ -1391,7 +1460,6 @@ mod tests {
},
)
.expect("create bwrap args");
assert_eq!(
args.args,
vec![
+111
View File
@@ -0,0 +1,111 @@
use std::io;
const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
const MAX_CAPABILITY_INDEX: libc::c_int = 63;
const SECBIT_KEEP_CAPS: libc::c_int = 1 << 4;
const SECUREBITS_NO_CAPABILITY_REGAIN: libc::c_int =
(1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 5) | (1 << 6) | (1 << 7);
#[repr(C)]
struct CapabilityHeader {
version: u32,
pid: libc::c_int,
}
#[derive(Clone, Copy, Default, Eq, PartialEq)]
#[repr(C)]
struct CapabilityData {
effective: u32,
permitted: u32,
inheritable: u32,
}
/// Permanently remove setup-only capabilities from the calling thread.
pub(crate) fn drop_all_capabilities() -> io::Result<()> {
prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0)?;
lock_securebits()?;
drop_bounding_set()?;
clear_and_verify_ambient_set()?;
clear_and_verify_thread_capabilities()
}
fn prctl(option: libc::c_int, arg: libc::c_ulong, arg2: libc::c_ulong) -> io::Result<libc::c_int> {
let result = unsafe { libc::prctl(option, arg, arg2, 0, 0) };
if result < 0 {
return Err(io::Error::last_os_error());
}
Ok(result)
}
fn lock_securebits() -> io::Result<()> {
let current = prctl(libc::PR_GET_SECUREBITS, 0, 0)?;
let desired = (current & !SECBIT_KEEP_CAPS) | SECUREBITS_NO_CAPABILITY_REGAIN;
prctl(libc::PR_SET_SECUREBITS, desired as libc::c_ulong, 0)?;
let actual = prctl(libc::PR_GET_SECUREBITS, 0, 0)?;
if actual & SECUREBITS_NO_CAPABILITY_REGAIN != SECUREBITS_NO_CAPABILITY_REGAIN
|| actual & SECBIT_KEEP_CAPS != 0
{
return Err(io::Error::other("capability regain paths remain open"));
}
Ok(())
}
fn drop_bounding_set() -> io::Result<()> {
for capability in 0..=MAX_CAPABILITY_INDEX {
match prctl(libc::PR_CAPBSET_DROP, capability as libc::c_ulong, 0) {
Ok(_) => {}
Err(err) if err.raw_os_error() == Some(libc::EINVAL) && capability > 0 => break,
Err(err) => return Err(err),
}
if prctl(libc::PR_CAPBSET_READ, capability as libc::c_ulong, 0)? != 0 {
let message = format!("capability {capability} remained in bounding set");
return Err(io::Error::other(message));
}
}
Ok(())
}
fn clear_and_verify_ambient_set() -> io::Result<()> {
prctl(
libc::PR_CAP_AMBIENT,
libc::PR_CAP_AMBIENT_CLEAR_ALL as libc::c_ulong,
0,
)?;
for capability in 0..=MAX_CAPABILITY_INDEX {
match prctl(
libc::PR_CAP_AMBIENT,
libc::PR_CAP_AMBIENT_IS_SET as libc::c_ulong,
capability as libc::c_ulong,
) {
Ok(0) => {}
Ok(_) => return Err(io::Error::other("ambient capabilities remained set")),
Err(err) if err.raw_os_error() == Some(libc::EINVAL) && capability > 0 => break,
Err(err) => return Err(err),
}
}
Ok(())
}
fn clear_and_verify_thread_capabilities() -> io::Result<()> {
let mut header = CapabilityHeader {
version: LINUX_CAPABILITY_VERSION_3,
pid: 0,
};
let mut capabilities = [CapabilityData::default(); 2];
for operation in [libc::SYS_capset, libc::SYS_capget] {
let result = unsafe {
libc::syscall(
operation,
&mut header as *mut CapabilityHeader,
capabilities.as_mut_ptr(),
)
};
if result != 0 {
return Err(io::Error::last_os_error());
}
}
if capabilities != [CapabilityData::default(); 2] {
return Err(io::Error::other("thread capabilities remained set"));
}
Ok(())
}
+37
View File
@@ -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;
@@ -34,6 +35,9 @@ struct SystemBwrapCapabilities {
}
pub(crate) fn exec_bwrap(argv: Vec<String>, preserved_files: Vec<File>) -> ! {
if requests_setup_capabilities(&argv) {
clear_loader_environment();
}
match preferred_bwrap_launcher() {
BubblewrapLauncher::System(launcher) => {
exec_system_bwrap(&launcher.program, argv, preserved_files)
@@ -48,6 +52,39 @@ pub(crate) fn exec_bwrap(argv: Vec<String>, preserved_files: Vec<File>) -> ! {
}
}
fn requests_setup_capabilities(argv: &[String]) -> bool {
argv.iter()
.take_while(|arg| arg.as_str() != "--")
.any(|arg| arg == "--cap-add")
}
fn clear_loader_environment() {
let keys = std::env::vars_os()
.map(|(key, _)| key)
.filter(|key| key.as_os_str().as_bytes().starts_with(b"LD_"))
.collect::<Vec<_>>();
for key in keys {
unsafe { std::env::remove_var(key) };
}
unsafe { std::env::remove_var("GLIBC_TUNABLES") };
}
fn is_nonroot_setuid_program(path: &Path) -> bool {
(unsafe { libc::geteuid() }) != 0
&& path
.metadata()
.is_ok_and(|metadata| metadata.permissions().mode() & 0o4000 != 0)
}
pub(crate) fn preferred_bwrap_supports_cap_add() -> bool {
match preferred_bwrap_launcher() {
BubblewrapLauncher::System(launcher) => {
!is_nonroot_setuid_program(launcher.program.as_path())
}
BubblewrapLauncher::Bundled(_) | BubblewrapLauncher::Unavailable => true,
}
}
fn preferred_bwrap_launcher() -> BubblewrapLauncher {
static LAUNCHER: OnceLock<BubblewrapLauncher> = OnceLock::new();
LAUNCHER
+4
View File
@@ -10,6 +10,8 @@ mod bundled_bwrap;
#[cfg(target_os = "linux")]
mod bwrap;
#[cfg(target_os = "linux")]
mod capabilities;
#[cfg(target_os = "linux")]
mod exec_util;
#[cfg(target_os = "linux")]
mod landlock;
@@ -19,6 +21,8 @@ mod launcher;
mod linux_run_main;
#[cfg(target_os = "linux")]
mod proxy_routing;
#[cfg(target_os = "linux")]
mod transparent_network;
#[cfg(target_os = "linux")]
pub fn run_main() -> ! {
+24 -25
View File
@@ -214,14 +214,14 @@ 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}")
}))
} else {
None
};
let prepared_proxy_route = allow_network_for_proxy.then(|| {
prepare_host_proxy_route_spec()
.unwrap_or_else(|err| panic!("failed to prepare host proxy routing bridge: {err}"))
});
let enable_transparent_proxy = prepared_proxy_route
.as_ref()
.is_some_and(|route| route.enable_transparent_proxy);
let proxy_route_spec = prepared_proxy_route.map(|route| route.serialized_spec);
let inner = build_inner_seccomp_command(InnerSeccompCommandArgs {
sandbox_policy_cwd: &sandbox_policy_cwd,
command_cwd: command_cwd.as_deref(),
@@ -230,14 +230,18 @@ pub fn run_main() -> ! {
proxy_route_spec,
command,
});
let bwrap_options = BwrapOptions {
mount_proc: !no_proc,
network_mode: bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy),
enable_transparent_proxy,
..Default::default()
};
run_bwrap_with_proc_fallback(
&sandbox_policy_cwd,
command_cwd.as_deref(),
&file_system_sandbox_policy,
network_sandbox_policy,
inner,
!no_proc,
allow_network_for_proxy,
bwrap_options,
);
}
@@ -318,34 +322,25 @@ fn run_bwrap_with_proc_fallback(
sandbox_policy_cwd: &Path,
command_cwd: Option<&Path>,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
inner: Vec<String>,
mount_proc: bool,
allow_network_for_proxy: bool,
mut options: BwrapOptions,
) -> ! {
let network_mode = bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy);
let mut mount_proc = mount_proc;
let command_cwd = command_cwd.unwrap_or(sandbox_policy_cwd);
if mount_proc
if options.mount_proc
&& !preflight_proc_mount_support(
sandbox_policy_cwd,
command_cwd,
file_system_sandbox_policy,
network_mode,
options.network_mode,
)
.unwrap_or_else(|err| exit_with_bwrap_build_error(err))
{
// Keep the retry silent so sandbox-internal diagnostics do not leak into the
// child process stderr stream.
mount_proc = false;
options.mount_proc = false;
}
let options = BwrapOptions {
mount_proc,
network_mode,
..Default::default()
};
let mut bwrap_args = build_bwrap_argv(
inner,
file_system_sandbox_policy,
@@ -464,6 +459,10 @@ fn build_preflight_bwrap_argv(
network_mode: BwrapNetworkMode,
) -> CodexResult<crate::bwrap::BwrapArgs> {
let preflight_command = vec![resolve_true_command()];
let preflight_network_mode = match network_mode {
BwrapNetworkMode::ProxyOnly => BwrapNetworkMode::Isolated,
mode => mode,
};
build_bwrap_argv(
preflight_command,
file_system_sandbox_policy,
@@ -471,7 +470,7 @@ fn build_preflight_bwrap_argv(
command_cwd,
BwrapOptions {
mount_proc: true,
network_mode,
network_mode: preflight_network_mode,
..Default::default()
},
)
@@ -180,22 +180,18 @@ fn inserts_unshare_net_when_network_isolation_requested() {
}
#[test]
fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
fn proxy_only_proc_preflight_unshares_network_without_setup_capabilities() {
let file_system_sandbox_policy = read_only_file_system_policy();
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
let argv = build_preflight_bwrap_argv(
Path::new("/"),
Path::new("/"),
&file_system_sandbox_policy,
Path::new("/"),
Path::new("/"),
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::ProxyOnly,
..Default::default()
},
BwrapNetworkMode::ProxyOnly,
)
.expect("build bwrap argv")
.expect("build preflight bwrap argv")
.args;
assert!(argv.contains(&"--unshare-net".to_string()));
assert!(!argv.contains(&"--cap-add".to_string()));
}
#[test]
+116 -9
View File
@@ -15,9 +15,11 @@ use std::net::Ipv4Addr;
use std::net::SocketAddr;
use std::net::TcpListener;
use std::net::TcpStream;
use std::os::fd::AsRawFd;
use std::os::fd::FromRawFd;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::DirBuilderExt;
use std::os::unix::fs::MetadataExt;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixListener;
use std::os::unix::net::UnixStream;
@@ -26,6 +28,10 @@ use std::path::PathBuf;
use std::time::Duration;
use url::Url;
use crate::capabilities::drop_all_capabilities;
use crate::launcher::preferred_bwrap_supports_cap_add;
use crate::transparent_network::configure_and_spawn;
const PROXY_ENV_KEYS: &[&str] = &[
"HTTP_PROXY",
"HTTPS_PROXY",
@@ -52,6 +58,13 @@ const UNIX_SOCKET_PATH_MAX_BYTES: usize = 107;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct ProxyRouteSpec {
routes: Vec<ProxyRouteEntry>,
host_netns_inode: u64,
transparent_networking: bool,
}
pub(crate) struct PreparedProxyRoute {
pub serialized_spec: String,
pub enable_transparent_proxy: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -70,9 +83,10 @@ struct PlannedProxyRoute {
struct ProxyRoutePlan {
routes: Vec<PlannedProxyRoute>,
has_proxy_config: bool,
has_http_proxy_route: bool,
}
pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<String> {
pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<PreparedProxyRoute> {
let (attribution_token, plan) = extract_attribution_token_and_plan(std::env::vars().collect());
// SAFETY: the sandbox helper is single-threaded here, before it forks bridge workers or
// executes the user command.
@@ -88,6 +102,10 @@ pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<String> {
};
return Err(io::Error::new(io::ErrorKind::InvalidInput, message));
}
let host_netns_inode = std::fs::metadata("/proc/self/ns/net")?.ino();
let transparent_networking = plan.has_http_proxy_route
&& preferred_bwrap_supports_cap_add()
&& std::fs::canonicalize("/etc/resolv.conf").is_ok();
let socket_parent_dir = proxy_socket_parent_dir();
let _ = cleanup_stale_proxy_socket_dirs_in(socket_parent_dir.as_path());
@@ -128,7 +146,16 @@ pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<String> {
});
}
serde_json::to_string(&ProxyRouteSpec { routes }).map_err(io::Error::other)
let serialized_spec = serde_json::to_string(&ProxyRouteSpec {
routes,
host_netns_inode,
transparent_networking,
})
.map_err(io::Error::other)?;
Ok(PreparedProxyRoute {
serialized_spec,
enable_transparent_proxy: transparent_networking,
})
}
fn extract_attribution_token_and_plan(
@@ -148,16 +175,42 @@ pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Resul
"proxy routing spec contained no routes",
));
}
if spec.transparent_networking
&& current_network_namespace_inode_from_socket()? == spec.host_netns_inode
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"refusing to activate proxy routes in the host network namespace",
));
}
let mut local_port_by_uds_path: BTreeMap<PathBuf, u16> = BTreeMap::new();
for route in &spec.routes {
if local_port_by_uds_path.contains_key(&route.uds_path) {
continue;
}
let local_port = spawn_local_bridge(route.uds_path.as_path())?;
let local_port = spawn_local_bridge(
route.uds_path.as_path(),
/*drop_setup_capabilities*/ spec.transparent_networking,
)?;
local_port_by_uds_path.insert(route.uds_path.clone(), local_port);
}
let http_proxy_port = if spec.transparent_networking {
Some(
spec.routes
.iter()
.find(|route| {
std::env::var(&route.env_key).is_ok_and(|value| is_plain_http_proxy_url(&value))
})
.and_then(|route| local_port_by_uds_path.get(&route.uds_path))
.copied()
.ok_or_else(|| io::Error::other("missing local HTTP proxy bridge"))?,
)
} else {
None
};
for route in spec.routes {
let Some(local_port) = local_port_by_uds_path.get(&route.uds_path) else {
return Err(io::Error::other(format!(
@@ -184,12 +237,28 @@ pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Resul
}
}
if let Some(http_proxy_port) = http_proxy_port {
configure_and_spawn(http_proxy_port)?;
drop_all_capabilities()?;
}
Ok(())
}
fn current_network_namespace_inode_from_socket() -> io::Result<u64> {
let socket = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0))?;
let namespace_fd = unsafe { libc::ioctl(socket.as_raw_fd(), libc::SIOCGSKNS) };
if namespace_fd < 0 {
return Err(io::Error::last_os_error());
}
let namespace = unsafe { File::from_raw_fd(namespace_fd) };
namespace.metadata().map(|metadata| metadata.ino())
}
fn plan_proxy_routes(env: &HashMap<String, String>) -> ProxyRoutePlan {
let mut routes = Vec::new();
let mut has_proxy_config = false;
let mut has_http_proxy_route = false;
for (key, value) in env {
if !is_proxy_env_key(key) {
@@ -205,6 +274,9 @@ fn plan_proxy_routes(env: &HashMap<String, String>) -> ProxyRoutePlan {
let Some(endpoint) = parse_loopback_proxy_endpoint(trimmed) else {
continue;
};
if is_plain_http_proxy_url(trimmed) {
has_http_proxy_route = true;
}
routes.push(PlannedProxyRoute {
env_key: key.clone(),
endpoint,
@@ -215,6 +287,7 @@ fn plan_proxy_routes(env: &HashMap<String, String>) -> ProxyRoutePlan {
ProxyRoutePlan {
routes,
has_proxy_config,
has_http_proxy_route,
}
}
@@ -256,6 +329,17 @@ fn parse_loopback_proxy_endpoint(proxy_url: &str) -> Option<SocketAddr> {
}
}
fn is_plain_http_proxy_url(proxy_url: &str) -> bool {
let candidate = if proxy_url.contains("://") {
proxy_url.to_string()
} else {
format!("http://{proxy_url}")
};
Url::parse(&candidate).is_ok_and(|url| {
url.scheme() == "http" && url.username().is_empty() && url.password().is_none()
})
}
fn is_loopback_host(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}
@@ -530,7 +614,7 @@ fn run_host_bridge(
}
}
fn spawn_local_bridge(uds_path: &Path) -> io::Result<u16> {
fn spawn_local_bridge(uds_path: &Path, drop_setup_capabilities: bool) -> io::Result<u16> {
let (read_fd, write_fd) = create_ready_pipe()?;
let pid = unsafe { libc::fork() };
if pid < 0 {
@@ -544,7 +628,7 @@ fn spawn_local_bridge(uds_path: &Path) -> io::Result<u16> {
if close_fd(read_fd).is_err() {
unsafe { libc::_exit(1) };
}
let result = run_local_bridge(uds_path, write_fd);
let result = run_local_bridge(uds_path, write_fd, drop_setup_capabilities);
if result.is_err() {
unsafe { libc::_exit(1) };
}
@@ -558,10 +642,17 @@ fn spawn_local_bridge(uds_path: &Path) -> io::Result<u16> {
Ok(u16::from_be_bytes(port_bytes))
}
fn run_local_bridge(uds_path: &Path, ready_fd: libc::c_int) -> io::Result<()> {
fn run_local_bridge(
uds_path: &Path,
ready_fd: libc::c_int,
drop_setup_capabilities: bool,
) -> io::Result<()> {
harden_bridge_process()?;
let listener = bind_local_loopback_listener()?;
let port = listener.local_addr()?.port();
if drop_setup_capabilities {
drop_all_capabilities()?;
}
let mut ready_file = unsafe { File::from_raw_fd(ready_fd) };
ready_file.write_all(&port.to_be_bytes())?;
@@ -599,7 +690,7 @@ fn bind_local_loopback_listener() -> io::Result<TcpListener> {
}
}
fn ensure_loopback_interface_up() -> io::Result<()> {
pub(crate) fn ensure_loopback_interface_up() -> io::Result<()> {
let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) };
if fd < 0 {
return Err(io::Error::last_os_error());
@@ -658,6 +749,19 @@ fn ensure_loopback_interface_up() -> io::Result<()> {
}
}
for (index, byte) in b"lo:codex".iter().copied().enumerate() {
addr_req.ifr_name[index] = byte as libc::c_char;
}
let mut proxy_addr = loopback_addr;
proxy_addr.sin_addr.s_addr = libc::htonl(0xc000_0201);
addr_req.ifr_ifru.ifru_addr =
unsafe { *(&proxy_addr as *const libc::sockaddr_in as *const libc::sockaddr) };
if unsafe { libc::ioctl(fd, libc::SIOCSIFADDR as libc::Ioctl, &addr_req) } < 0 {
let err = io::Error::last_os_error();
let _ = close_fd(fd);
return Err(err);
}
close_fd(fd)
}
@@ -672,7 +776,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()
}
@@ -806,6 +910,7 @@ mod tests {
.expect("valid socket"),
}],
has_proxy_config: true,
has_http_proxy_route: true,
}
);
}
@@ -857,12 +962,14 @@ mod tests {
env_key: "HTTP_PROXY".to_string(),
uds_path: PathBuf::from("/tmp/proxy-route-0.sock"),
}],
host_netns_inode: 42,
transparent_networking: true,
};
let serialized = serde_json::to_string(&spec).expect("proxy route spec should serialize");
assert_eq!(
serialized,
r#"{"routes":[{"env_key":"HTTP_PROXY","uds_path":"/tmp/proxy-route-0.sock"}]}"#
r#"{"routes":[{"env_key":"HTTP_PROXY","uds_path":"/tmp/proxy-route-0.sock"}],"host_netns_inode":42,"transparent_networking":true}"#
);
}
@@ -0,0 +1,280 @@
use std::collections::HashMap;
use std::fs::File;
use std::io;
use std::io::Read;
use std::io::Write;
use std::net::Ipv4Addr;
use std::net::Shutdown;
use std::net::TcpListener;
use std::net::TcpStream;
use std::net::UdpSocket;
use std::os::fd::FromRawFd;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use crate::capabilities::drop_all_capabilities;
use crate::proxy_routing::ensure_loopback_interface_up;
use crate::proxy_routing::harden_bridge_process;
const FAKE_POOL_BASE: u32 = (127 << 24) | (1 << 16);
const MAX_DNS_PACKET: usize = 4096;
const READY: u8 = 1;
pub(crate) fn configure_and_spawn(http_bridge_port: u16) -> io::Result<()> {
let mut fds = [0; 2];
if unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: pipe2 initialized both descriptors, each of which is moved into one File.
let mut read_pipe = unsafe { File::from_raw_fd(fds[0]) };
let mut write_pipe = unsafe { File::from_raw_fd(fds[1]) };
let pid = unsafe { libc::fork() };
if pid < 0 {
return Err(io::Error::last_os_error());
}
if pid == 0 {
drop(read_pipe);
let exit_code = i32::from(run_service(http_bridge_port, &mut write_pipe).is_err());
unsafe { libc::_exit(exit_code) };
}
drop(write_pipe);
let mut ready = [0];
if read_pipe.read_exact(&mut ready).is_ok() && ready == [READY] {
return Ok(());
}
let _ = unsafe { libc::waitpid(pid, std::ptr::null_mut(), 0) };
Err(io::Error::other(
"transparent network service failed before becoming ready",
))
}
fn run_service(http_bridge_port: u16, ready: &mut File) -> io::Result<()> {
harden_bridge_process()?;
ensure_loopback_interface_up()?;
let dns = UdpSocket::bind((Ipv4Addr::LOCALHOST, 53))?;
let http = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 80))?;
let https = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 443))?;
let pool = Arc::new(Mutex::new(FakeIpPool::default()));
drop_all_capabilities()?;
spawn_service("codex-dns", {
let pool = Arc::clone(&pool);
move || serve_dns(dns, pool)
})?;
spawn_service("codex-http-capture", {
let pool = Arc::clone(&pool);
move || serve_capture(http, 80, http_bridge_port, pool)
})?;
ready.write_all(&[READY])?;
serve_capture(https, 443, http_bridge_port, pool)
}
fn spawn_service<F>(name: &str, task: F) -> io::Result<()>
where
F: FnOnce() -> io::Result<()> + Send + 'static,
{
std::thread::Builder::new()
.name(name.to_string())
.spawn(task)
.map(drop)
}
#[derive(Default)]
struct FakeIpPool {
host_to_ip: HashMap<String, Ipv4Addr>,
ip_to_host: HashMap<Ipv4Addr, String>,
}
impl FakeIpPool {
fn address_for(&mut self, hostname: &str) -> Option<Ipv4Addr> {
let hostname = hostname.to_ascii_lowercase();
if let Some(address) = self.host_to_ip.get(&hostname) {
return Some(*address);
}
let slot = u32::try_from(self.ip_to_host.len()).ok()?.checked_add(1)?;
if slot >= 1 << 16 {
return None;
}
let address = Ipv4Addr::from(FAKE_POOL_BASE + slot);
self.host_to_ip.insert(hostname.clone(), address);
self.ip_to_host.insert(address, hostname);
Some(address)
}
}
fn parse_dns_query(packet: &[u8]) -> Option<(u16, String, u16, usize)> {
if !(12..=MAX_DNS_PACKET).contains(&packet.len()) {
return None;
}
let flags = read_u16(packet, 2)?;
if flags & 0xf800 != 0 || read_u16(packet, 4)? != 1 {
return None;
}
let mut labels = Vec::new();
let mut offset = 12;
let mut name_len = 1;
loop {
let len = usize::from(*packet.get(offset)?);
offset += 1;
if len == 0 {
break;
}
if len > 63 || offset.checked_add(len)? > packet.len() {
return None;
}
name_len += len + 1;
let label = packet.get(offset..offset + len)?;
if name_len > 255
|| !label
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return None;
}
labels.push(std::str::from_utf8(label).ok()?.to_ascii_lowercase());
offset += len;
}
let end = offset.checked_add(4)?;
if read_u16(packet, offset + 2)? != 1 {
return None;
}
Some((flags, labels.join("."), read_u16(packet, offset)?, end))
}
fn dns_response(packet: &[u8], pool: &Mutex<FakeIpPool>) -> Option<Vec<u8>> {
let (flags, name, query_type, question_end) = parse_dns_query(packet)?;
let answer = if query_type != 1 || name.is_empty() {
None
} else if name == "localhost" {
Some(Ipv4Addr::LOCALHOST)
} else {
pool.lock().ok()?.address_for(&name)
};
let mut response = Vec::with_capacity(question_end + 16);
response.extend_from_slice(packet.get(..2)?);
response.extend_from_slice(&(0x8080 | (flags & 0x0100)).to_be_bytes());
response.extend_from_slice(&1_u16.to_be_bytes());
response.extend_from_slice(&u16::from(answer.is_some()).to_be_bytes());
response.extend_from_slice(&[0; 4]);
response.extend_from_slice(packet.get(12..question_end)?);
if let Some(address) = answer {
response.extend_from_slice(&[0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0, 1, 0, 4]);
response.extend_from_slice(&address.octets());
}
Some(response)
}
fn serve_dns(socket: UdpSocket, pool: Arc<Mutex<FakeIpPool>>) -> io::Result<()> {
let mut packet = [0_u8; MAX_DNS_PACKET];
loop {
let (len, peer) = socket.recv_from(&mut packet)?;
if let Some(response) = dns_response(&packet[..len], &pool) {
let _ = socket.send_to(&response, peer);
}
}
}
fn serve_capture(
listener: TcpListener,
port: u16,
bridge_port: u16,
pool: Arc<Mutex<FakeIpPool>>,
) -> io::Result<()> {
loop {
let (client, _) = listener.accept()?;
let pool = Arc::clone(&pool);
spawn_service("codex-captured-connection", move || {
capture_connection(client, port, bridge_port, &pool)
})?;
}
}
fn capture_connection(
mut client: TcpStream,
port: u16,
bridge_port: u16,
pool: &Mutex<FakeIpPool>,
) -> io::Result<()> {
let destination = match client.local_addr()?.ip() {
std::net::IpAddr::V4(address) => address,
std::net::IpAddr::V6(_) => return Err(io::Error::other("unexpected IPv6 capture")),
};
let target = destination_target(destination, pool).ok_or_else(|| {
io::Error::new(
io::ErrorKind::PermissionDenied,
"captured destination is unsafe",
)
})?;
let mut bridge = TcpStream::connect((Ipv4Addr::LOCALHOST, bridge_port))?;
if port == 443 {
let authority = format!("{target}:443");
write!(
bridge,
"CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n\r\n"
)?;
let leftover = read_connect_response(&mut bridge)?;
client.write_all(&leftover)?;
}
splice(client, bridge)
}
fn destination_target(address: Ipv4Addr, pool: &Mutex<FakeIpPool>) -> Option<String> {
matches!(address.octets(), [127, 1, _, _]).then_some(())?;
pool.lock().ok()?.ip_to_host.get(&address).cloned()
}
fn read_connect_response(stream: &mut TcpStream) -> io::Result<Vec<u8>> {
stream.set_read_timeout(Some(Duration::from_secs(300)))?;
let mut head = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk)?;
if read == 0 {
return Err(io::ErrorKind::UnexpectedEof.into());
}
head.extend_from_slice(&chunk[..read]);
if head.len() > 8192 {
return Err(io::ErrorKind::InvalidData.into());
}
if let Some(end) = head.windows(4).position(|window| window == b"\r\n\r\n") {
let line_end = head
.windows(2)
.position(|window| window == b"\r\n")
.unwrap_or(end);
let line = std::str::from_utf8(&head[..line_end])
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
if line.split_ascii_whitespace().nth(1) != Some("200") {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"proxy refused captured connection",
));
}
stream.set_read_timeout(/*duration*/ None)?;
return Ok(head[end + 4..].to_vec());
}
}
}
fn splice(mut left: TcpStream, mut right: TcpStream) -> io::Result<()> {
let mut left_reader = left.try_clone()?;
let mut right_writer = right.try_clone()?;
let forward = std::thread::spawn(move || {
let result = io::copy(&mut left_reader, &mut right_writer);
let _ = right_writer.shutdown(Shutdown::Write);
result
});
let reverse = io::copy(&mut right, &mut left);
let _ = left.shutdown(Shutdown::Write);
forward
.join()
.map_err(|_| io::Error::other("capture forwarding thread panicked"))??;
reverse?;
Ok(())
}
fn read_u16(bytes: &[u8], offset: usize) -> Option<u16> {
Some(u16::from_be_bytes(
bytes.get(offset..offset + 2)?.try_into().ok()?,
))
}
@@ -177,7 +177,7 @@ async fn managed_proxy_mode_fails_closed_without_proxy_env() {
}
#[tokio::test]
async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() {
async fn managed_proxy_mode_routes_proxy_aware_and_direct_ipv4_traffic() {
if let Some(skip_reason) = managed_proxy_skip_reason().await {
eprintln!("skipping managed proxy test: {skip_reason}");
return;
@@ -190,17 +190,31 @@ async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() {
.port();
let (request_tx, request_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept proxy connection");
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.expect("set read timeout");
let mut buf = [0_u8; 4096];
let read = stream.read(&mut buf).expect("read proxy request");
let request = String::from_utf8_lossy(&buf[..read]).to_string();
request_tx.send(request).expect("send proxy request");
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")
.expect("write proxy response");
for connection in 0..3 {
let (mut stream, _) = listener.accept().expect("accept proxy connection");
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.expect("set read timeout");
let mut buf = [0_u8; 4096];
let read = stream.read(&mut buf).expect("read proxy request");
let request = String::from_utf8_lossy(&buf[..read]).to_string();
request_tx.send(request).expect("send proxy request");
if connection == 2 {
stream
.write_all(b"HTTP/1.1 200 OK\r\n\r\n")
.expect("accept CONNECT");
let mut payload = [0_u8; 4];
stream
.read_exact(&mut payload)
.expect("read tunnel payload");
assert_eq!(&payload, b"ping");
stream.write_all(b"pong").expect("write tunnel payload");
} else {
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")
.expect("write proxy response");
}
}
});
let mut env = create_env_from_core_vars();
@@ -245,15 +259,55 @@ async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() {
"expected HTTP proxy absolute-form request, got request: {request}"
);
let direct_egress_output = run_linux_sandbox_direct(
&["bash", "-c", "echo hi > /dev/tcp/192.0.2.1/80"],
let direct_output = run_linux_sandbox_direct(
&[
"bash",
"-c",
"getent ahostsv4 example.com >/dev/null; cap_eff=$(sed -n 's/^CapEff:[[:space:]]*//p' /proc/self/status); cap_bnd=$(sed -n 's/^CapBnd:[[:space:]]*//p' /proc/self/status); cap_amb=$(sed -n 's/^CapAmb:[[:space:]]*//p' /proc/self/status); exec 3<>/dev/tcp/example.com/80; printf 'GET /direct HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' >&3; IFS= read -r line <&3; exec 3>&-; printf '%s\nCapEff=%s\nCapBnd=%s\nCapAmb=%s\n' \"$line\" \"$cap_eff\" \"$cap_bnd\" \"$cap_amb\"; exec 4<>/dev/tcp/example.com/443; printf ping >&4; dd bs=1 count=4 <&4 2>/dev/null; if exec 5<>/dev/tcp/example.com/81; then exit 1; fi",
],
&PermissionProfile::Disabled,
/*allow_network_for_proxy*/ true,
env,
env.clone(),
NETWORK_TIMEOUT_MS,
)
.await;
assert_eq!(direct_egress_output.status.success(), false);
assert!(
direct_output.status.success(),
"direct routing failed: status={:?}, stderr={}",
direct_output.status.code(),
String::from_utf8_lossy(&direct_output.stderr)
);
let stdout = String::from_utf8_lossy(&direct_output.stdout);
assert!(
stdout.contains("HTTP/1.1 200 OK"),
"expected captured HTTP response, got stdout: {stdout}"
);
assert!(
["CapEff", "CapBnd", "CapAmb"]
.iter()
.all(|name| stdout.contains(&format!("{name}=0000000000000000"))),
"expected workload capabilities to be cleared, got stdout: {stdout}"
);
assert!(
stdout.ends_with("pong"),
"expected HTTPS tunnel payload, got: {stdout}"
);
let request = request_rx
.recv_timeout(Duration::from_secs(3))
.expect("expected direct HTTP request at proxy");
assert!(
request.starts_with("GET /direct HTTP/1.1\r\n"),
"expected captured origin-form request, got request: {request}"
);
let request = request_rx
.recv_timeout(Duration::from_secs(3))
.expect("expected CONNECT request at proxy");
assert!(
request.starts_with("CONNECT example.com:443 HTTP/1.1\r\n"),
"expected captured hostname in CONNECT request, got: {request}"
);
}
#[tokio::test]