Preserve Linux CA bundles for MITM trust

This commit is contained in:
Winston Howes
2026-05-26 16:30:59 -07:00
parent 595d035fbc
commit e29a8a4de6
10 changed files with 169 additions and 95 deletions

View File

@@ -300,7 +300,7 @@ async fn run_command_under_sandbox(
sandbox_policy_cwd.as_path(),
use_legacy_landlock,
allow_network_for_proxy(managed_network_requirements_enabled),
/*mitm_ca_trust_bundle_path*/ None,
/*mitm_ca_cert_path*/ None,
);
spawn_debug_sandbox_child(
codex_linux_sandbox_exe,

View File

@@ -34,7 +34,7 @@ where
P: AsRef<Path>,
{
let network_sandbox_policy = permission_profile.network_sandbox_policy();
let mitm_ca_trust_bundle_path = network.and_then(NetworkProxy::mitm_ca_trust_bundle_path);
let mitm_ca_cert_path = network.and_then(NetworkProxy::mitm_ca_cert_path);
let args = create_linux_sandbox_command_args_for_permission_profile(
command,
command_cwd.as_path(),
@@ -42,7 +42,7 @@ where
sandbox_policy_cwd,
use_legacy_landlock,
allow_network_for_proxy(/*enforce_managed_network*/ false),
mitm_ca_trust_bundle_path.as_deref(),
mitm_ca_cert_path.as_deref(),
);
let codex_linux_sandbox_exe = codex_linux_sandbox_exe.as_ref();
// Preserve the helper alias when we already have it; otherwise force argv0

View File

@@ -12,12 +12,17 @@
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashSet;
use std::ffi::CString;
use std::ffi::OsString;
use std::fs;
use std::fs::File;
use std::fs::Metadata;
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::os::unix::ffi::OsStringExt;
use std::os::unix::fs::MetadataExt;
use std::path::Path;
@@ -78,8 +83,8 @@ pub(crate) struct BwrapOptions {
/// Keep this uncapped by default so existing nested deny-read matches are
/// masked before the sandboxed command starts.
pub glob_scan_max_depth: Option<usize>,
/// Managed MITM CA bundle to materialize at common Linux trust-store paths.
pub mitm_ca_trust_bundle_path: Option<PathBuf>,
/// Managed MITM CA cert to append to common Linux trust-store paths.
pub mitm_ca_cert_path: Option<PathBuf>,
}
impl Default for BwrapOptions {
@@ -88,7 +93,7 @@ impl Default for BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::FullAccess,
glob_scan_max_depth: None,
mitm_ca_trust_bundle_path: None,
mitm_ca_cert_path: None,
}
}
}
@@ -255,7 +260,7 @@ pub(crate) fn create_bwrap_command_args(
// need concrete bwrap masks for the matches expanded below.
if file_system_sandbox_policy.has_full_disk_write_access() && unreadable_globs.is_empty() {
return if options.network_mode == BwrapNetworkMode::FullAccess
&& options.mitm_ca_trust_bundle_path.is_none()
&& options.mitm_ca_cert_path.is_none()
{
Ok(BwrapArgs {
args: command,
@@ -298,10 +303,7 @@ fn create_bwrap_flags_full_filesystem(
synthetic_mount_targets: Vec::new(),
protected_create_targets: Vec::new(),
};
append_mitm_ca_trust_bundle_args(
&mut bwrap_args,
options.mitm_ca_trust_bundle_path.as_deref(),
)?;
append_mitm_ca_trust_bundle_args(&mut bwrap_args, options.mitm_ca_cert_path.as_deref())?;
if options.network_mode.should_unshare_network() {
bwrap_args.args.push("--unshare-net".to_string());
}
@@ -345,10 +347,7 @@ fn create_bwrap_flags(
synthetic_mount_targets,
protected_create_targets,
};
append_mitm_ca_trust_bundle_args(
&mut bwrap_args,
options.mitm_ca_trust_bundle_path.as_deref(),
)?;
append_mitm_ca_trust_bundle_args(&mut bwrap_args, options.mitm_ca_cert_path.as_deref())?;
// Request a user namespace explicitly rather than relying on bubblewrap's
// auto-enable behavior, which is skipped when the caller runs as uid 0.
bwrap_args.args.push("--unshare-user".to_string());
@@ -378,31 +377,89 @@ fn create_bwrap_flags(
fn append_mitm_ca_trust_bundle_args(
bwrap_args: &mut BwrapArgs,
mitm_ca_trust_bundle_path: Option<&Path>,
mitm_ca_cert_path: Option<&Path>,
) -> Result<()> {
let Some(mitm_ca_trust_bundle_path) = mitm_ca_trust_bundle_path else {
let bundle_paths = LINUX_CA_BUNDLE_PATHS.map(Path::new);
append_mitm_ca_trust_bundle_args_for_paths(bwrap_args, mitm_ca_cert_path, &bundle_paths)
}
fn append_mitm_ca_trust_bundle_args_for_paths(
bwrap_args: &mut BwrapArgs,
mitm_ca_cert_path: Option<&Path>,
bundle_paths: &[&Path],
) -> Result<()> {
let Some(mitm_ca_cert_path) = mitm_ca_cert_path else {
return Ok(());
};
let managed_ca_cert = fs::read(mitm_ca_cert_path).map_err(|err| {
CodexErr::Fatal(format!(
"failed to read managed MITM CA cert {}: {err}",
mitm_ca_cert_path.display()
))
})?;
for bundle_path in LINUX_CA_BUNDLE_PATHS {
let bundle_file = File::open(mitm_ca_trust_bundle_path).map_err(|err| {
CodexErr::Fatal(format!(
"failed to open managed MITM CA trust bundle {}: {err}",
mitm_ca_trust_bundle_path.display()
))
})?;
for bundle_path in bundle_paths {
// Only overlay trust-store files the sandbox would already have seen.
let Ok(system_bundle) = fs::read(bundle_path) else {
continue;
};
let bundle_file = preserved_file_from_bytes(build_mitm_ca_bundle_overlay(
system_bundle,
&managed_ca_cert,
))?;
let bundle_fd = bundle_file.as_raw_fd().to_string();
bwrap_args.preserved_files.push(bundle_file);
bwrap_args.args.push("--perms".to_string());
bwrap_args.args.push("444".to_string());
bwrap_args.args.push("--ro-bind-data".to_string());
bwrap_args.args.push(bundle_fd);
bwrap_args.args.push(bundle_path.to_string());
bwrap_args.args.push(path_to_string(bundle_path));
}
Ok(())
}
fn build_mitm_ca_bundle_overlay(mut system_bundle: Vec<u8>, managed_ca_cert: &[u8]) -> Vec<u8> {
append_pem_bytes(&mut system_bundle, managed_ca_cert);
system_bundle
}
fn append_pem_bytes(bundle: &mut Vec<u8>, pem: &[u8]) {
if !bundle.is_empty() && !bundle.ends_with(b"\n") {
bundle.push(b'\n');
}
bundle.extend_from_slice(pem);
if !bundle.ends_with(b"\n") {
bundle.push(b'\n');
}
}
fn preserved_file_from_bytes(contents: Vec<u8>) -> Result<File> {
let memfd_name = CString::new("codex-mitm-ca-bundle").expect("static memfd name");
// SAFETY: `memfd_name` is a valid NUL-terminated C string and the flags are valid.
let fd = unsafe { libc::memfd_create(memfd_name.as_ptr(), libc::MFD_CLOEXEC) };
if fd < 0 {
return Err(CodexErr::Fatal(format!(
"failed to create managed MITM CA bundle memfd: {}",
io::Error::last_os_error()
)));
}
// SAFETY: `fd` is a newly created owned file descriptor from `memfd_create`.
let mut file = unsafe { File::from_raw_fd(fd) };
file.write_all(&contents).map_err(|err| {
CodexErr::Fatal(format!(
"failed to write managed MITM CA bundle memfd: {err}"
))
})?;
file.seek(SeekFrom::Start(0)).map_err(|err| {
CodexErr::Fatal(format!(
"failed to rewind managed MITM CA bundle memfd: {err}"
))
})?;
Ok(file)
}
/// Build the bubblewrap filesystem mounts for a given filesystem policy.
///
/// The mount order is important:
@@ -1400,8 +1457,8 @@ mod tests {
}
#[test]
fn default_mitm_ca_trust_bundle_path_is_unset() {
assert_eq!(BwrapOptions::default().mitm_ca_trust_bundle_path, None);
fn default_mitm_ca_cert_path_is_unset() {
assert_eq!(BwrapOptions::default().mitm_ca_cert_path, None);
}
fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry {
@@ -1472,32 +1529,39 @@ mod tests {
}
#[test]
fn mitm_ca_trust_bundle_overlays_common_linux_bundle_paths() {
fn mitm_ca_cert_appends_to_existing_linux_bundle_paths() {
let temp_dir = TempDir::new().expect("tempdir");
let mitm_ca_trust_bundle_path = temp_dir.path().join("ca-bundle.pem");
fs::write(&mitm_ca_trust_bundle_path, "managed bundle").expect("write bundle");
let mitm_ca_cert_path = temp_dir.path().join("ca.pem");
fs::write(&mitm_ca_cert_path, "managed ca").expect("write managed CA");
let system_bundle_path = temp_dir.path().join("ca-certificates.crt");
fs::write(&system_bundle_path, "system bundle").expect("write system bundle");
let missing_bundle_path = temp_dir.path().join("missing-bundle.crt");
let mut args = BwrapArgs {
args: Vec::new(),
preserved_files: Vec::new(),
synthetic_mount_targets: Vec::new(),
protected_create_targets: Vec::new(),
};
let args = create_bwrap_command_args(
vec!["/bin/true".to_string()],
&FileSystemSandboxPolicy::unrestricted(),
Path::new("/"),
Path::new("/"),
BwrapOptions {
mitm_ca_trust_bundle_path: Some(mitm_ca_trust_bundle_path),
..Default::default()
},
append_mitm_ca_trust_bundle_args_for_paths(
&mut args,
Some(&mitm_ca_cert_path),
&[&system_bundle_path, &missing_bundle_path],
)
.expect("create bwrap args");
.expect("append MITM CA trust bundle args");
assert_eq!(args.preserved_files.len(), LINUX_CA_BUNDLE_PATHS.len());
for bundle_path in LINUX_CA_BUNDLE_PATHS {
assert!(args.args.windows(5).any(|window| {
window[0] == "--perms"
&& window[1] == "444"
&& window[2] == "--ro-bind-data"
&& window[4] == bundle_path
}));
}
assert_eq!(args.preserved_files.len(), 1);
let system_bundle_path = path_to_string(&system_bundle_path);
assert!(args.args.windows(5).any(|window| {
window[0] == "--perms"
&& window[1] == "444"
&& window[2] == "--ro-bind-data"
&& window[4] == system_bundle_path
}));
let mut overlay_contents = String::new();
std::io::Read::read_to_string(&mut args.preserved_files[0], &mut overlay_contents)
.expect("read overlay contents");
assert_eq!(overlay_contents, "system bundle\nmanaged ca\n");
}
#[test]

View File

@@ -126,9 +126,9 @@ pub struct LandlockCommand {
#[arg(long = "proxy-route-spec", hide = true)]
pub proxy_route_spec: Option<String>,
/// Internal managed MITM CA bundle to materialize in bubblewrap sandboxes.
#[arg(long = "mitm-ca-trust-bundle", hide = true)]
pub mitm_ca_trust_bundle_path: Option<PathBuf>,
/// Internal managed MITM CA cert to add to bubblewrap trust bundles.
#[arg(long = "mitm-ca-cert", hide = true)]
pub mitm_ca_cert_path: Option<PathBuf>,
/// When set, skip mounting a fresh `/proc` even though PID isolation is
/// still enabled. This is primarily intended for restrictive container
@@ -157,7 +157,7 @@ pub fn run_main() -> ! {
apply_seccomp_then_exec,
allow_network_for_proxy,
proxy_route_spec,
mitm_ca_trust_bundle_path,
mitm_ca_cert_path,
no_proc,
command,
} = LandlockCommand::parse();
@@ -204,7 +204,7 @@ pub fn run_main() -> ! {
if file_system_sandbox_policy.has_full_disk_write_access()
&& !allow_network_for_proxy
&& mitm_ca_trust_bundle_path.is_none()
&& mitm_ca_cert_path.is_none()
{
if let Err(e) = apply_permission_profile_to_current_thread(
&permission_profile,
@@ -246,7 +246,7 @@ pub fn run_main() -> ! {
inner,
!no_proc,
allow_network_for_proxy,
mitm_ca_trust_bundle_path.as_deref(),
mitm_ca_cert_path.as_deref(),
);
}
@@ -331,7 +331,7 @@ fn run_bwrap_with_proc_fallback(
inner: Vec<String>,
mount_proc: bool,
allow_network_for_proxy: bool,
mitm_ca_trust_bundle_path: Option<&Path>,
mitm_ca_cert_path: Option<&Path>,
) -> ! {
let network_mode = bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy);
let mut mount_proc = mount_proc;
@@ -343,7 +343,7 @@ fn run_bwrap_with_proc_fallback(
command_cwd,
file_system_sandbox_policy,
network_mode,
mitm_ca_trust_bundle_path,
mitm_ca_cert_path,
)
.unwrap_or_else(|err| exit_with_bwrap_build_error(err))
{
@@ -355,7 +355,7 @@ fn run_bwrap_with_proc_fallback(
let options = BwrapOptions {
mount_proc,
network_mode,
mitm_ca_trust_bundle_path: mitm_ca_trust_bundle_path.map(Path::to_path_buf),
mitm_ca_cert_path: mitm_ca_cert_path.map(Path::to_path_buf),
..Default::default()
};
let mut bwrap_args = build_bwrap_argv(
@@ -458,14 +458,14 @@ fn preflight_proc_mount_support(
command_cwd: &Path,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_mode: BwrapNetworkMode,
mitm_ca_trust_bundle_path: Option<&Path>,
mitm_ca_cert_path: Option<&Path>,
) -> CodexResult<bool> {
let preflight_argv = build_preflight_bwrap_argv(
sandbox_policy_cwd,
command_cwd,
file_system_sandbox_policy,
network_mode,
mitm_ca_trust_bundle_path,
mitm_ca_cert_path,
)?;
let stderr = run_bwrap_in_child_capture_stderr(preflight_argv);
Ok(!is_proc_mount_failure(stderr.as_str()))
@@ -476,7 +476,7 @@ fn build_preflight_bwrap_argv(
command_cwd: &Path,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_mode: BwrapNetworkMode,
mitm_ca_trust_bundle_path: Option<&Path>,
mitm_ca_cert_path: Option<&Path>,
) -> CodexResult<crate::bwrap::BwrapArgs> {
let preflight_command = vec![resolve_true_command()];
build_bwrap_argv(
@@ -487,7 +487,7 @@ fn build_preflight_bwrap_argv(
BwrapOptions {
mount_proc: true,
network_mode,
mitm_ca_trust_bundle_path: mitm_ca_trust_bundle_path.map(Path::to_path_buf),
mitm_ca_cert_path: mitm_ca_cert_path.map(Path::to_path_buf),
..Default::default()
},
)

View File

@@ -37,8 +37,8 @@ mode = "full" # default when unset; use "limited" for read-only mode
# CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key).
# When MITM is active, spawned commands receive CA bundle env vars pointing at
# $CODEX_HOME/proxy/ca-bundle.pem so common HTTPS clients trust the managed CA.
# Linux bubblewrap sandboxes also overlay that bundle onto common system CA
# bundle paths inside the sandbox namespace.
# Linux bubblewrap sandboxes also append the managed CA cert to common system
# CA bundle paths inside the sandbox namespace.
# If false, local/private networking is rejected. Explicit allowlisting of local IP literals
# (or `localhost`) is required to permit them.

View File

@@ -125,6 +125,10 @@ fn managed_ca_paths() -> Result<(PathBuf, PathBuf)> {
))
}
pub(crate) fn managed_ca_cert_path() -> Result<PathBuf> {
managed_ca_paths().map(|(cert_path, _)| cert_path)
}
pub(crate) fn managed_ca_trust_bundle_path(env: &HashMap<String, String>) -> Result<PathBuf> {
let (cert_path, _) = managed_ca_paths()?;
let trust_bundle_path = cert_path

View File

@@ -301,20 +301,26 @@ struct NetworkProxyRuntimeSettings {
allow_local_binding: bool,
allow_unix_sockets: Arc<[String]>,
dangerously_allow_all_unix_sockets: bool,
mitm_ca_cert_path: Option<PathBuf>,
mitm_ca_trust_bundle_path: Option<PathBuf>,
}
impl NetworkProxyRuntimeSettings {
fn from_config(config: &config::NetworkProxyConfig) -> Result<Self> {
let mitm_ca_trust_bundle_path = config
.network
.mitm
.then(|| crate::certs::managed_ca_trust_bundle_path(&std::env::vars().collect()))
.transpose()?;
let (mitm_ca_cert_path, mitm_ca_trust_bundle_path) = if config.network.mitm {
let env = std::env::vars().collect();
(
Some(crate::certs::managed_ca_cert_path()?),
Some(crate::certs::managed_ca_trust_bundle_path(&env)?),
)
} else {
(None, None)
};
Ok(Self {
allow_local_binding: config.network.allow_local_binding,
allow_unix_sockets: config.network.allow_unix_sockets().into(),
dangerously_allow_all_unix_sockets: config.network.dangerously_allow_all_unix_sockets,
mitm_ca_cert_path,
mitm_ca_trust_bundle_path,
})
}
@@ -614,9 +620,9 @@ impl NetworkProxy {
self.runtime_settings().dangerously_allow_all_unix_sockets
}
/// Returns the managed CA bundle child sandboxes should trust while MITM is active.
pub fn mitm_ca_trust_bundle_path(&self) -> Option<PathBuf> {
self.runtime_settings().mitm_ca_trust_bundle_path
/// Returns the managed CA cert child sandboxes should add to native trust paths.
pub fn mitm_ca_cert_path(&self) -> Option<PathBuf> {
self.runtime_settings().mitm_ca_cert_path
}
pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {

View File

@@ -27,7 +27,7 @@ pub fn create_linux_sandbox_command_args_for_permission_profile(
sandbox_policy_cwd: &Path,
use_legacy_landlock: bool,
allow_network_for_proxy: bool,
mitm_ca_trust_bundle_path: Option<&Path>,
mitm_ca_cert_path: Option<&Path>,
) -> Vec<String> {
let permission_profile_json = serde_json::to_string(permission_profile)
.unwrap_or_else(|err| panic!("failed to serialize permission profile: {err}"));
@@ -54,12 +54,12 @@ pub fn create_linux_sandbox_command_args_for_permission_profile(
if allow_network_for_proxy {
linux_cmd.push("--allow-network-for-proxy".to_string());
}
if let Some(mitm_ca_trust_bundle_path) = mitm_ca_trust_bundle_path {
linux_cmd.push("--mitm-ca-trust-bundle".to_string());
if let Some(mitm_ca_cert_path) = mitm_ca_cert_path {
linux_cmd.push("--mitm-ca-cert".to_string());
linux_cmd.push(
mitm_ca_trust_bundle_path
mitm_ca_cert_path
.to_str()
.unwrap_or_else(|| panic!("MITM CA trust bundle path must be valid UTF-8"))
.unwrap_or_else(|| panic!("MITM CA cert path must be valid UTF-8"))
.to_string(),
);
}
@@ -77,7 +77,7 @@ fn create_linux_sandbox_command_args(
sandbox_policy_cwd: &Path,
use_legacy_landlock: bool,
allow_network_for_proxy: bool,
mitm_ca_trust_bundle_path: Option<&Path>,
mitm_ca_cert_path: Option<&Path>,
) -> Vec<String> {
let command_cwd = command_cwd
.to_str()
@@ -100,12 +100,12 @@ fn create_linux_sandbox_command_args(
if allow_network_for_proxy {
linux_cmd.push("--allow-network-for-proxy".to_string());
}
if let Some(mitm_ca_trust_bundle_path) = mitm_ca_trust_bundle_path {
linux_cmd.push("--mitm-ca-trust-bundle".to_string());
if let Some(mitm_ca_cert_path) = mitm_ca_cert_path {
linux_cmd.push("--mitm-ca-cert".to_string());
linux_cmd.push(
mitm_ca_trust_bundle_path
mitm_ca_cert_path
.to_str()
.unwrap_or_else(|| panic!("MITM CA trust bundle path must be valid UTF-8"))
.unwrap_or_else(|| panic!("MITM CA cert path must be valid UTF-8"))
.to_string(),
);
}

View File

@@ -13,7 +13,7 @@ fn legacy_landlock_flag_is_included_when_requested() {
cwd,
/*use_legacy_landlock*/ false,
/*allow_network_for_proxy*/ false,
/*mitm_ca_trust_bundle_path*/ None,
/*mitm_ca_cert_path*/ None,
);
assert_eq!(
default_bwrap.contains(&"--use-legacy-landlock".to_string()),
@@ -26,7 +26,7 @@ fn legacy_landlock_flag_is_included_when_requested() {
cwd,
/*use_legacy_landlock*/ true,
/*allow_network_for_proxy*/ false,
/*mitm_ca_trust_bundle_path*/ None,
/*mitm_ca_cert_path*/ None,
);
assert_eq!(
legacy_landlock.contains(&"--use-legacy-landlock".to_string()),
@@ -46,7 +46,7 @@ fn proxy_flag_is_included_when_requested() {
cwd,
/*use_legacy_landlock*/ true,
/*allow_network_for_proxy*/ true,
/*mitm_ca_trust_bundle_path*/ None,
/*mitm_ca_cert_path*/ None,
);
assert_eq!(
args.contains(&"--allow-network-for-proxy".to_string()),
@@ -68,7 +68,7 @@ fn permission_profile_flag_is_included() {
cwd,
/*use_legacy_landlock*/ true,
/*allow_network_for_proxy*/ false,
/*mitm_ca_trust_bundle_path*/ None,
/*mitm_ca_cert_path*/ None,
);
assert_eq!(
@@ -84,11 +84,11 @@ fn permission_profile_flag_is_included() {
}
#[test]
fn mitm_ca_trust_bundle_flag_is_included_when_requested() {
fn mitm_ca_cert_flag_is_included_when_requested() {
let command = vec!["/bin/true".to_string()];
let command_cwd = Path::new("/tmp/link");
let cwd = Path::new("/tmp");
let trust_bundle_path = Path::new("/tmp/ca-bundle.pem");
let cert_path = Path::new("/tmp/ca.pem");
let args = create_linux_sandbox_command_args(
command,
@@ -96,12 +96,13 @@ fn mitm_ca_trust_bundle_flag_is_included_when_requested() {
cwd,
/*use_legacy_landlock*/ false,
/*allow_network_for_proxy*/ true,
Some(trust_bundle_path),
Some(cert_path),
);
assert!(args.windows(2).any(|window| {
window[0] == "--mitm-ca-trust-bundle" && window[1] == "/tmp/ca-bundle.pem"
}));
assert!(
args.windows(2)
.any(|window| { window[0] == "--mitm-ca-cert" && window[1] == "/tmp/ca.pem" })
);
}
#[test]

View File

@@ -218,8 +218,7 @@ impl SandboxManager {
let exe = codex_linux_sandbox_exe
.ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?;
let allow_proxy_network = allow_network_for_proxy(enforce_managed_network);
let mitm_ca_trust_bundle_path =
network.and_then(NetworkProxy::mitm_ca_trust_bundle_path);
let mitm_ca_cert_path = network.and_then(NetworkProxy::mitm_ca_cert_path);
#[cfg(target_os = "linux")]
ensure_linux_bubblewrap_is_supported(
&effective_file_system_policy,
@@ -234,7 +233,7 @@ impl SandboxManager {
sandbox_policy_cwd,
use_legacy_landlock,
allow_proxy_network,
mitm_ca_trust_bundle_path.as_deref(),
mitm_ca_cert_path.as_deref(),
);
let mut full_command = Vec::with_capacity(1 + args.len());
full_command.push(os_string_to_command_component(exe.as_os_str().to_owned()));