mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
feat(linux-sandbox): route direct HTTP traffic
Co-authored-by: Codex noreply@openai.com
This commit is contained in:
co-authored by
Codex noreply@openai.com
parent
927004c06d
commit
4a534cd1f8
@@ -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![
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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() -> ! {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user