From 14d16d2dd4041d9658e217d11242efcc1f10e1f8 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 3 Jun 2026 19:53:12 -0700 Subject: [PATCH 1/8] Fix Windows MITM CA bundle identity check --- codex-rs/network-proxy/Cargo.toml | 2 +- codex-rs/network-proxy/src/certs.rs | 37 +++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index fd9ced3568..21bd754d3e 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -52,4 +52,4 @@ security-framework = "3" [target.'cfg(windows)'.dependencies] schannel = "0.1" -windows-sys = { version = "0.52", features = ["Win32_Storage_FileSystem"] } +windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index 7c68e81550..7de461d664 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -41,8 +41,6 @@ use std::os::unix::fs::MetadataExt; #[cfg(windows)] use std::os::windows::ffi::OsStringExt; #[cfg(windows)] -use std::os::windows::fs::MetadataExt; -#[cfg(windows)] use std::os::windows::io::AsRawHandle; use std::path::Path; use std::path::PathBuf; @@ -308,7 +306,7 @@ where "CA bundle {} is not readable by child policy", opened_path.display() ); - validate_opened_file_path(&path, &opened_path, &metadata)?; + validate_opened_file_path(&path, &opened_path, &file, &metadata)?; let mut bytes = Vec::with_capacity(metadata.len() as usize); std::io::Read::by_ref(&mut file) @@ -524,10 +522,12 @@ fn opened_file_path(path: &Path, _file: &File) -> Result { fn validate_opened_file_path( path: &Path, opened_path: &Path, + file: &File, metadata: &fs::Metadata, ) -> Result<()> { #[cfg(unix)] { + let _ = file; let opened_path_metadata = fs::metadata(opened_path).with_context(|| { format!("failed to stat opened CA bundle {}", opened_path.display()) })?; @@ -543,12 +543,15 @@ fn validate_opened_file_path( { #[cfg(windows)] { - let opened_path_metadata = fs::metadata(opened_path).with_context(|| { - format!("failed to stat opened CA bundle {}", opened_path.display()) + let _ = metadata; + let opened_path_file = File::open(opened_path).with_context(|| { + format!( + "failed to reopen opened CA bundle {}", + opened_path.display() + ) })?; anyhow::ensure!( - metadata.volume_serial_number() == opened_path_metadata.volume_serial_number() - && metadata.file_index() == opened_path_metadata.file_index(), + windows_file_identity(&opened_path_file)? == windows_file_identity(file)?, "CA bundle {} changed before it could be validated", path.display() ); @@ -558,6 +561,7 @@ fn validate_opened_file_path( { let _ = path; let _ = opened_path; + let _ = file; let _ = metadata; } } @@ -565,6 +569,25 @@ fn validate_opened_file_path( Ok(()) } +#[cfg(windows)] +fn windows_file_identity(file: &File) -> Result<(u32, u64)> { + use windows_sys::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION; + use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandle; + + let mut file_information = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: `file` owns a live OS handle and `file_information` is writable. + let result = + unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut file_information) }; + anyhow::ensure!( + result != 0, + "failed to inspect opened CA bundle: {}", + std::io::Error::last_os_error() + ); + let file_index = u64::from(file_information.nFileIndexHigh) << 32 + | u64::from(file_information.nFileIndexLow); + Ok((file_information.dwVolumeSerialNumber, file_index)) +} + fn push_certificate_pem(bundle: &mut String, der: &[u8]) { bundle.push_str("-----BEGIN CERTIFICATE-----\n"); let encoded = base64::engine::general_purpose::STANDARD.encode(der); From fb6ce7fef9984fad3441c94242cef631fef17931 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 3 Jun 2026 19:58:12 -0700 Subject: [PATCH 2/8] Bound child CA directory scan --- codex-rs/network-proxy/src/certs.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index 7de461d664..780a13f745 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -116,6 +116,7 @@ const MANAGED_MITM_CA_CERT: &str = "ca.pem"; const MANAGED_MITM_CA_KEY: &str = "ca.key"; const MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX: &str = "ca-bundle"; const MAX_CUSTOM_CA_BUNDLE_BYTES: u64 = 4 * 1024 * 1024; +const MAX_CUSTOM_CA_DIR_ENTRIES: usize = 256; const SSL_CERT_FILE_ENV_KEY: &str = "SSL_CERT_FILE"; pub const SSL_CERT_DIR_ENV_KEY: &str = "SSL_CERT_DIR"; @@ -348,9 +349,14 @@ where ); let mut trust_bundle = String::new(); - for entry in fs::read_dir(&dir) + for (entry_index, entry) in fs::read_dir(&dir) .with_context(|| format!("failed to read CA directory {}", dir.display()))? + .enumerate() { + anyhow::ensure!( + entry_index < MAX_CUSTOM_CA_DIR_ENTRIES, + "CA directory exceeds {MAX_CUSTOM_CA_DIR_ENTRIES} entries" + ); let entry = entry .with_context(|| format!("failed to read CA directory entry in {}", dir.display()))?; let path = entry.path(); @@ -939,6 +945,22 @@ mod tests { ); } + #[test] + fn read_custom_ca_dir_rejects_too_many_entries() { + let dir = tempdir().unwrap(); + for entry_index in 0..=MAX_CUSTOM_CA_DIR_ENTRIES { + fs::write(dir.path().join(format!("ca-{entry_index}.pem")), "ca\n").unwrap(); + } + + let err = read_custom_ca_dir(dir.path(), |_| true).unwrap_err(); + assert!( + err.to_string().contains(&format!( + "CA directory exceeds {MAX_CUSTOM_CA_DIR_ENTRIES} entries" + )), + "unexpected error: {err:#}" + ); + } + #[cfg(unix)] #[test] fn validate_existing_ca_key_file_rejects_group_world_permissions() { From 130450a9fac4e2269cefd8d9c4f104ed316fcb7a Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 3 Jun 2026 22:15:30 -0700 Subject: [PATCH 3/8] Track startup MITM CA env --- codex-rs/network-proxy/src/certs.rs | 19 ++++++- codex-rs/network-proxy/src/lib.rs | 2 + codex-rs/network-proxy/src/proxy.rs | 86 ++++++++++++++++++++++++----- 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index f316e2c21f..f99ab36e27 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -101,12 +101,14 @@ const MANAGED_MITM_CA_DIR: &str = "proxy"; const MANAGED_MITM_CA_CERT: &str = "ca.pem"; const MANAGED_MITM_CA_KEY: &str = "ca.key"; const MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX: &str = "ca-bundle"; +const SSL_CERT_FILE_ENV_KEY: &str = "SSL_CERT_FILE"; +pub const SSL_CERT_DIR_ENV_KEY: &str = "SSL_CERT_DIR"; // Best-effort compatibility set for common child toolchains that accept a CA bundle path. // This is intentionally curated rather than pretending to cover every TLS client. pub const CUSTOM_CA_ENV_KEYS: [&str; 10] = [ "CODEX_CA_CERTIFICATE", - "SSL_CERT_FILE", + SSL_CERT_FILE_ENV_KEY, "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "NODE_EXTRA_CA_CERTS", @@ -122,6 +124,7 @@ pub const CUSTOM_CA_ENV_KEYS: [&str; 10] = [ pub(crate) struct ManagedMitmCaTrustBundle { pub(crate) path: PathBuf, pub(crate) startup_env_values: HashMap<&'static str, String>, + pub(crate) startup_cwd: PathBuf, } fn managed_ca_paths() -> Result<(PathBuf, PathBuf)> { @@ -146,8 +149,11 @@ fn managed_ca_trust_bundle_for_cert_path( cert_path: &Path, env: &HashMap<&'static str, String>, ) -> Result { + let startup_cwd = + std::env::current_dir().context("failed to resolve startup cwd for managed MITM CA")?; let startup_env_values = CUSTOM_CA_ENV_KEYS .into_iter() + .chain(std::iter::once(SSL_CERT_DIR_ENV_KEY)) .filter_map(|key| { env.get(key) .filter(|value| !value.is_empty()) @@ -160,6 +166,7 @@ fn managed_ca_trust_bundle_for_cert_path( Ok(ManagedMitmCaTrustBundle { path, startup_env_values, + startup_cwd, }) } @@ -512,12 +519,18 @@ mod tests { let dir = tempdir().unwrap(); let managed_ca_cert_path = dir.path().join("ca.pem"); fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); - let env = HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]); + let env = HashMap::from([ + ("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string()), + (SSL_CERT_DIR_ENV_KEY, "/tmp/startup-certs".to_string()), + ]); let trust_bundle = managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); assert_eq!( trust_bundle.startup_env_values, - HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]) + HashMap::from([ + ("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string()), + (SSL_CERT_DIR_ENV_KEY, "/tmp/startup-certs".to_string()), + ]) ); } diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index eb641b81d4..dfc9c07115 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -18,6 +18,7 @@ mod state; mod upstream; pub use certs::CUSTOM_CA_ENV_KEYS; +pub use certs::SSL_CERT_DIR_ENV_KEY; pub use certs::is_managed_mitm_ca_trust_bundle_path; pub use config::NetworkDomainPermission; pub use config::NetworkDomainPermissionEntry; @@ -46,6 +47,7 @@ pub use proxy::Args; #[cfg(target_os = "macos")] pub use proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; pub use proxy::DEFAULT_NO_PROXY_VALUE; +pub use proxy::MITM_CA_ENV_ACTIVE_ENV_KEY; pub use proxy::NO_PROXY_ENV_KEYS; pub use proxy::NetworkProxy; pub use proxy::NetworkProxyBuilder; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index c3685a4310..61b0639cf1 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -308,6 +308,7 @@ impl NetworkProxyRuntimeSettings { let mitm_ca_trust_bundle = if config.network.mitm { let env = crate::certs::CUSTOM_CA_ENV_KEYS .into_iter() + .chain(std::iter::once(crate::certs::SSL_CERT_DIR_ENV_KEY)) .filter_map(|key| std::env::var(key).ok().map(|value| (key, value))) .collect(); Some(crate::certs::managed_ca_trust_bundle(&env)?) @@ -376,6 +377,8 @@ pub const PROXY_URL_ENV_KEYS: &[&str] = &[ pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"]; pub const PROXY_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ACTIVE"; +pub const MITM_CA_ENV_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_MITM_CA_ENV_ACTIVE"; +const STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY: &str = "CODEX_NETWORK_PROXY_STARTUP_CA_ENV_KEYS_PRESENT"; pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING"; const ELECTRON_GET_USE_PROXY_ENV_KEY: &str = "ELECTRON_GET_USE_PROXY"; const NODE_USE_ENV_PROXY_ENV_KEY: &str = "NODE_USE_ENV_PROXY"; @@ -383,6 +386,8 @@ const NODE_USE_ENV_PROXY_ENV_KEY: &str = "NODE_USE_ENV_PROXY"; const GIT_SSH_COMMAND_ENV_KEY: &str = "GIT_SSH_COMMAND"; pub const PROXY_ENV_KEYS: &[&str] = &[ PROXY_ACTIVE_ENV_KEY, + MITM_CA_ENV_ACTIVE_ENV_KEY, + STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY, ALLOW_LOCAL_BINDING_ENV_KEY, ELECTRON_GET_USE_PROXY_ENV_KEY, NODE_USE_ENV_PROXY_ENV_KEY, @@ -571,27 +576,55 @@ fn apply_proxy_env_overrides( } if let Some(mitm_ca_trust_bundle) = mitm_ca_trust_bundle { + env.insert(MITM_CA_ENV_ACTIVE_ENV_KEY.to_string(), "1".to_string()); let managed_path = mitm_ca_trust_bundle.path.to_string_lossy().into_owned(); + let startup_ca_env_keys_present_in_child = ca_env_keys() + .filter(|&key| { + env.get(key) + .filter(|value| !value.is_empty()) + .is_some_and(|value| { + mitm_ca_trust_bundle.startup_env_values.get(key) == Some(value) + || (value == &managed_path && is_tracked_startup_ca_env_key(env, key)) + }) + }) + .collect::>(); + if startup_ca_env_keys_present_in_child.is_empty() { + env.remove(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY); + } else { + env.insert( + STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY.to_string(), + startup_ca_env_keys_present_in_child.join(","), + ); + } for key in crate::certs::CUSTOM_CA_ENV_KEYS { if env .get(key) .filter(|value| !value.is_empty()) .is_some_and(|value| { - value != &managed_path - && mitm_ca_trust_bundle.startup_env_values.get(key) != Some(value) + value != &managed_path && !startup_ca_env_keys_present_in_child.contains(&key) }) { - // TODO(winston): Materialize policy-checked per-child bundles for readable - // startup and command-scoped CA overrides. For now startup overrides are - // replaced with the default bundle and later command-scoped overrides are - // preserved, either of which can make intercepted TLS fail. continue; } env.insert(key.to_string(), managed_path.clone()); } + } else { + env.remove(MITM_CA_ENV_ACTIVE_ENV_KEY); + env.remove(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY); } } +fn ca_env_keys() -> impl Iterator { + crate::certs::CUSTOM_CA_ENV_KEYS + .into_iter() + .chain(std::iter::once(crate::certs::SSL_CERT_DIR_ENV_KEY)) +} + +fn is_tracked_startup_ca_env_key(env: &HashMap, key: &str) -> bool { + env.get(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY) + .is_some_and(|keys| keys.split(',').any(|tracked_key| tracked_key == key)) +} + impl NetworkProxy { pub fn builder() -> NetworkProxyBuilder { NetworkProxyBuilder::default() @@ -643,7 +676,7 @@ impl NetworkProxy { pub fn apply_to_env(&self, env: &mut HashMap) { let runtime_settings = self.runtime_settings(); // Enforce proxying for child processes. Proxy endpoint values are always rewritten; - // managed MITM CA vars preserve child-scoped overrides after proxy startup. + // managed MITM CA vars preserve command-scoped overrides after proxy startup. apply_proxy_env_overrides( env, self.http_addr, @@ -1110,6 +1143,7 @@ mod tests { let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle { path: mitm_ca_trust_bundle_path.to_path_buf(), startup_env_values: HashMap::new(), + startup_cwd: Path::new("/tmp").to_path_buf(), }; apply_proxy_env_overrides( &mut env, @@ -1129,18 +1163,18 @@ mod tests { } #[test] - fn apply_proxy_env_overrides_preserves_command_scoped_mitm_ca_override() { - let command_ca_bundle_path = "/tmp/command-ca.pem".to_string(); + fn apply_proxy_env_overrides_tracks_rewritten_startup_mitm_ca_override() { + let startup_ca_bundle_path = "/tmp/startup-ca.pem".to_string(); let mut env = HashMap::from([( "REQUESTS_CA_BUNDLE".to_string(), - command_ca_bundle_path.clone(), + startup_ca_bundle_path.clone(), )]); let mitm_ca_trust_bundle_path = Path::new("/tmp/codex-proxy/ca-bundle.pem"); let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle { path: mitm_ca_trust_bundle_path.to_path_buf(), - startup_env_values: HashMap::new(), + startup_env_values: HashMap::from([("REQUESTS_CA_BUNDLE", startup_ca_bundle_path)]), + startup_cwd: Path::new("/tmp").to_path_buf(), }; - apply_proxy_env_overrides( &mut env, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), @@ -1150,11 +1184,37 @@ mod tests { Some(&mitm_ca_trust_bundle), ); - assert_eq!(env.get("REQUESTS_CA_BUNDLE"), Some(&command_ca_bundle_path)); + assert_eq!( + env.get("REQUESTS_CA_BUNDLE"), + Some(&mitm_ca_trust_bundle_path.display().to_string()) + ); + assert_eq!( + env.get(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY), + Some(&"REQUESTS_CA_BUNDLE".to_string()) + ); assert_eq!( env.get("SSL_CERT_FILE"), Some(&mitm_ca_trust_bundle_path.display().to_string()) ); + + env.insert( + "REQUESTS_CA_BUNDLE".to_string(), + "/tmp/command-ca.pem".to_string(), + ); + apply_proxy_env_overrides( + &mut env, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), + /*socks_enabled*/ true, + /*allow_local_binding*/ false, + Some(&mitm_ca_trust_bundle), + ); + + assert_eq!( + env.get("REQUESTS_CA_BUNDLE"), + Some(&"/tmp/command-ca.pem".to_string()) + ); + assert_eq!(env.get(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY), None); } #[test] From f39f0f4300a5bf2ad78ed01dac419272c0073830 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 3 Jun 2026 22:19:17 -0700 Subject: [PATCH 4/8] Materialize child MITM CA bundles --- codex-rs/Cargo.lock | 2 + codex-rs/network-proxy/Cargo.toml | 2 + codex-rs/network-proxy/src/certs.rs | 429 ++++++++++++++++++- codex-rs/network-proxy/src/child_ca.rs | 203 +++++++++ codex-rs/network-proxy/src/child_ca_tests.rs | 161 +++++++ codex-rs/network-proxy/src/lib.rs | 1 + 6 files changed, 787 insertions(+), 11 deletions(-) create mode 100644 codex-rs/network-proxy/src/child_ca.rs create mode 100644 codex-rs/network-proxy/src/child_ca_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4abd55339b..eb6e599849 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3363,6 +3363,7 @@ dependencies = [ "codex-utils-home-dir", "codex-utils-rustls-provider", "globset", + "libc", "pretty_assertions", "rama-core", "rama-http", @@ -3384,6 +3385,7 @@ dependencies = [ "tokio", "tracing", "url", + "windows-sys 0.52.0", ] [[package]] diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index 15ae9284c8..21bd754d3e 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -44,6 +44,7 @@ pretty_assertions = { workspace = true } tempfile = { workspace = true } [target.'cfg(target_family = "unix")'.dependencies] +libc = { workspace = true } rama-unix = { version = "=0.3.0-alpha.4" } [target.'cfg(target_os = "macos")'.dependencies] @@ -51,3 +52,4 @@ security-framework = "3" [target.'cfg(windows)'.dependencies] schannel = "0.1" +windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index f99ab36e27..780a13f745 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -23,11 +23,25 @@ use rama_tls_rustls::server::TlsAcceptorData; use sha2::Digest as _; use sha2::Sha256; use std::collections::HashMap; +use std::ffi::OsStr; +#[cfg(windows)] +use std::ffi::OsString; use std::fs; use std::fs::File; use std::fs::OpenOptions; +use std::io::Read; use std::io::Write; use std::net::IpAddr; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::os::fd::AsRawFd; +#[cfg(target_os = "macos")] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(windows)] +use std::os::windows::ffi::OsStringExt; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; use std::path::Path; use std::path::PathBuf; use std::time::SystemTime; @@ -101,6 +115,8 @@ const MANAGED_MITM_CA_DIR: &str = "proxy"; const MANAGED_MITM_CA_CERT: &str = "ca.pem"; const MANAGED_MITM_CA_KEY: &str = "ca.key"; const MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX: &str = "ca-bundle"; +const MAX_CUSTOM_CA_BUNDLE_BYTES: u64 = 4 * 1024 * 1024; +const MAX_CUSTOM_CA_DIR_ENTRIES: usize = 256; const SSL_CERT_FILE_ENV_KEY: &str = "SSL_CERT_FILE"; pub const SSL_CERT_DIR_ENV_KEY: &str = "SSL_CERT_DIR"; @@ -191,18 +207,23 @@ fn is_current_generated_trust_bundle_path(path: &Path, managed_ca_cert_path: &Pa let Some(proxy_dir) = managed_ca_cert_path.parent() else { return false; }; - let Some(file_name) = path.file_name().and_then(|file_name| file_name.to_str()) else { - return false; - }; - if path.parent() != Some(proxy_dir) - || !file_name.starts_with(MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX) - || !file_name.ends_with(".pem") - { + if !matches_generated_trust_bundle_path(path, proxy_dir) { return false; } let Ok(trust_bundle) = fs::read(path) else { return false; }; + let expected_hash = format!("{:x}", Sha256::digest(&trust_bundle)); + if path + .file_stem() + .and_then(OsStr::to_str) + .and_then(|file_stem| { + file_stem.strip_prefix(&format!("{MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX}-")) + }) + .is_none_or(|hash| hash != expected_hash) + { + return false; + } let Ok(managed_ca_cert) = fs::read(managed_ca_cert_path) else { return false; }; @@ -227,6 +248,133 @@ fn persist_managed_ca_trust_bundle( let proxy_dir = managed_ca_cert_path .parent() .ok_or_else(|| anyhow!("managed MITM CA cert path is missing a parent"))?; + persist_ca_trust_bundle(proxy_dir, trust_bundle) +} + +pub(crate) fn materialize_ca_trust_bundle_with_custom_ca( + managed_ca_trust_bundle: &ManagedMitmCaTrustBundle, + custom_ca_bundle: &str, +) -> Result { + let proxy_dir = managed_ca_trust_bundle + .path + .parent() + .ok_or_else(|| anyhow!("managed MITM CA trust bundle path is missing a parent"))?; + anyhow::ensure!( + custom_ca_bundle.len() as u64 <= MAX_CUSTOM_CA_BUNDLE_BYTES, + "custom CA bundle exceeds {MAX_CUSTOM_CA_BUNDLE_BYTES} bytes" + ); + + let mut trust_bundle = String::new(); + append_pem_contents(&mut trust_bundle, custom_ca_bundle); + append_pem_file(&mut trust_bundle, &managed_ca_trust_bundle.path)?; + persist_ca_trust_bundle(proxy_dir, &trust_bundle) +} + +pub(crate) fn read_custom_ca_bundle(path: &Path, can_read_path: F) -> Result +where + F: Fn(&Path) -> bool, +{ + anyhow::ensure!( + can_read_path(path), + "CA bundle {} is not readable by child policy", + path.display() + ); + let path = path + .canonicalize() + .with_context(|| format!("failed to resolve CA bundle {}", path.display()))?; + anyhow::ensure!( + can_read_path(&path), + "CA bundle {} is not readable by child policy", + path.display() + ); + let mut file = open_readonly_without_following_symlink(&path)?; + let metadata = file + .metadata() + .with_context(|| format!("failed to stat CA bundle {}", path.display()))?; + anyhow::ensure!( + metadata.is_file(), + "CA bundle {} must be a regular file", + path.display() + ); + anyhow::ensure!( + metadata.len() <= MAX_CUSTOM_CA_BUNDLE_BYTES, + "CA bundle {} exceeds {MAX_CUSTOM_CA_BUNDLE_BYTES} bytes", + path.display() + ); + let opened_path = opened_file_path(&path, &file)?; + anyhow::ensure!( + can_read_path(&opened_path), + "CA bundle {} is not readable by child policy", + opened_path.display() + ); + validate_opened_file_path(&path, &opened_path, &file, &metadata)?; + + let mut bytes = Vec::with_capacity(metadata.len() as usize); + std::io::Read::by_ref(&mut file) + .take(MAX_CUSTOM_CA_BUNDLE_BYTES + 1) + .read_to_end(&mut bytes) + .with_context(|| format!("failed to read CA bundle {}", path.display()))?; + anyhow::ensure!( + bytes.len() as u64 <= MAX_CUSTOM_CA_BUNDLE_BYTES, + "CA bundle {} exceeds {MAX_CUSTOM_CA_BUNDLE_BYTES} bytes", + path.display() + ); + String::from_utf8(bytes) + .with_context(|| format!("CA bundle {} must be valid UTF-8", path.display())) +} + +pub(crate) fn read_custom_ca_dir(dir: &Path, can_read_path: F) -> Result +where + F: Fn(&Path) -> bool, +{ + anyhow::ensure!( + can_read_path(dir), + "CA directory {} is not readable by child policy", + dir.display() + ); + let dir = dir + .canonicalize() + .with_context(|| format!("failed to resolve CA directory {}", dir.display()))?; + anyhow::ensure!( + can_read_path(&dir), + "CA directory {} is not readable by child policy", + dir.display() + ); + anyhow::ensure!( + dir.metadata() + .with_context(|| format!("failed to stat CA directory {}", dir.display()))? + .is_dir(), + "CA directory {} must be a directory", + dir.display() + ); + + let mut trust_bundle = String::new(); + for (entry_index, entry) in fs::read_dir(&dir) + .with_context(|| format!("failed to read CA directory {}", dir.display()))? + .enumerate() + { + anyhow::ensure!( + entry_index < MAX_CUSTOM_CA_DIR_ENTRIES, + "CA directory exceeds {MAX_CUSTOM_CA_DIR_ENTRIES} entries" + ); + let entry = entry + .with_context(|| format!("failed to read CA directory entry in {}", dir.display()))?; + let path = entry.path(); + match read_custom_ca_bundle(&path, &can_read_path) { + Ok(contents) => append_bounded_pem_contents(&mut trust_bundle, &contents)?, + Err(err) => { + warn!( + ca_bundle_path = %path.display(), + "failed to read CA directory entry; skipping it: {err}" + ); + } + } + } + + Ok(trust_bundle) +} + +fn persist_ca_trust_bundle(proxy_dir: &Path, trust_bundle: &str) -> Result { fs::create_dir_all(proxy_dir) .with_context(|| format!("failed to create {}", proxy_dir.display()))?; let hash = Sha256::digest(trust_bundle.as_bytes()); @@ -247,19 +395,205 @@ fn persist_managed_ca_trust_bundle( Ok(trust_bundle_path) } +fn matches_generated_trust_bundle_path(path: &Path, proxy_dir: &Path) -> bool { + let Some(file_name) = path.file_name().and_then(|file_name| file_name.to_str()) else { + return false; + }; + path.parent() == Some(proxy_dir) + && file_name.starts_with(&format!("{MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX}-")) + && file_name.ends_with(".pem") +} + fn append_pem_file(bundle: &mut String, path: &Path) -> Result<()> { - if !bundle.ends_with('\n') { - bundle.push('\n'); - } let pem = fs::read_to_string(path) .with_context(|| format!("failed to read CA bundle {}", path.display()))?; - bundle.push_str(&pem); + append_pem_contents(bundle, &pem); + Ok(()) +} + +pub(crate) fn append_pem_contents(bundle: &mut String, pem: &str) { + if !bundle.is_empty() && !bundle.ends_with('\n') { + bundle.push('\n'); + } + bundle.push_str(pem); if !bundle.ends_with('\n') { bundle.push('\n'); } +} + +pub(crate) fn append_bounded_pem_contents(bundle: &mut String, pem: &str) -> Result<()> { + let separator_len = usize::from(!bundle.is_empty() && !bundle.ends_with('\n')); + let trailing_newline_len = usize::from(!pem.ends_with('\n')); + anyhow::ensure!( + (bundle.len() + separator_len + pem.len() + trailing_newline_len) as u64 + <= MAX_CUSTOM_CA_BUNDLE_BYTES, + "CA directory exceeds {MAX_CUSTOM_CA_BUNDLE_BYTES} bytes" + ); + append_pem_contents(bundle, pem); Ok(()) } +fn open_readonly_without_following_symlink(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + options + .open(path) + .with_context(|| format!("failed to open CA bundle {}", path.display())) +} + +fn opened_file_path(path: &Path, _file: &File) -> Result { + #[cfg(target_os = "linux")] + { + let opened_path = fs::read_link(format!("/proc/self/fd/{}", _file.as_raw_fd())) + .with_context(|| format!("failed to resolve opened CA bundle {}", path.display()))?; + opened_path + .canonicalize() + .with_context(|| format!("failed to canonicalize opened CA bundle {}", path.display())) + } + + #[cfg(target_os = "macos")] + { + let mut opened_path = vec![0_u8; libc::PATH_MAX as usize]; + // SAFETY: fcntl writes at most PATH_MAX bytes into the provided writable buffer. + let result = + unsafe { libc::fcntl(_file.as_raw_fd(), libc::F_GETPATH, opened_path.as_mut_ptr()) }; + anyhow::ensure!( + result != -1, + "failed to resolve opened CA bundle {}: {}", + path.display(), + std::io::Error::last_os_error() + ); + let opened_path_len = opened_path + .iter() + .position(|byte| *byte == 0) + .unwrap_or(opened_path.len()); + PathBuf::from(OsStr::from_bytes(&opened_path[..opened_path_len])) + .canonicalize() + .with_context(|| format!("failed to canonicalize opened CA bundle {}", path.display())) + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + #[cfg(windows)] + { + use windows_sys::Win32::Storage::FileSystem::FILE_NAME_NORMALIZED; + use windows_sys::Win32::Storage::FileSystem::GetFinalPathNameByHandleW; + use windows_sys::Win32::Storage::FileSystem::VOLUME_NAME_DOS; + + let mut opened_path = vec![0_u16; 512]; + loop { + // SAFETY: `_file` owns a live OS handle and `opened_path` is writable for its + // declared capacity. + let length = unsafe { + GetFinalPathNameByHandleW( + _file.as_raw_handle() as _, + opened_path.as_mut_ptr(), + opened_path.len() as u32, + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS, + ) + }; + anyhow::ensure!( + length != 0, + "failed to resolve opened CA bundle {}: {}", + path.display(), + std::io::Error::last_os_error() + ); + if length < opened_path.len() as u32 { + opened_path.truncate(length as usize); + break; + } + opened_path.resize(length as usize + 1, 0); + } + PathBuf::from(OsString::from_wide(&opened_path)) + .canonicalize() + .with_context(|| { + format!("failed to canonicalize opened CA bundle {}", path.display()) + }) + } + + #[cfg(not(windows))] + { + path.canonicalize() + .with_context(|| format!("failed to resolve CA bundle {}", path.display())) + } + } +} + +fn validate_opened_file_path( + path: &Path, + opened_path: &Path, + file: &File, + metadata: &fs::Metadata, +) -> Result<()> { + #[cfg(unix)] + { + let _ = file; + let opened_path_metadata = fs::metadata(opened_path).with_context(|| { + format!("failed to stat opened CA bundle {}", opened_path.display()) + })?; + anyhow::ensure!( + metadata.dev() == opened_path_metadata.dev() + && metadata.ino() == opened_path_metadata.ino(), + "CA bundle {} changed before it could be validated", + path.display() + ); + } + + #[cfg(not(unix))] + { + #[cfg(windows)] + { + let _ = metadata; + let opened_path_file = File::open(opened_path).with_context(|| { + format!( + "failed to reopen opened CA bundle {}", + opened_path.display() + ) + })?; + anyhow::ensure!( + windows_file_identity(&opened_path_file)? == windows_file_identity(file)?, + "CA bundle {} changed before it could be validated", + path.display() + ); + } + + #[cfg(not(windows))] + { + let _ = path; + let _ = opened_path; + let _ = file; + let _ = metadata; + } + } + + Ok(()) +} + +#[cfg(windows)] +fn windows_file_identity(file: &File) -> Result<(u32, u64)> { + use windows_sys::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION; + use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandle; + + let mut file_information = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: `file` owns a live OS handle and `file_information` is writable. + let result = + unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut file_information) }; + anyhow::ensure!( + result != 0, + "failed to inspect opened CA bundle: {}", + std::io::Error::last_os_error() + ); + let file_index = u64::from(file_information.nFileIndexHigh) << 32 + | u64::from(file_information.nFileIndexLow); + Ok((file_information.dwVolumeSerialNumber, file_index)) +} + fn push_certificate_pem(bundle: &mut String, der: &[u8]) { bundle.push_str("-----BEGIN CERTIFICATE-----\n"); let encoded = base64::engine::general_purpose::STANDARD.encode(der); @@ -514,6 +848,19 @@ mod tests { )); } + #[test] + fn current_generated_trust_bundle_path_rejects_hash_mismatch() { + let dir = tempdir().unwrap(); + let managed_ca_cert_path = dir.path().join("ca.pem"); + let trust_bundle_path = dir.path().join("ca-bundle-123.pem"); + fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); + fs::write(&trust_bundle_path, "custom ca\nmanaged ca\n").unwrap(); + assert!(!is_current_generated_trust_bundle_path( + &trust_bundle_path, + &managed_ca_cert_path, + )); + } + #[test] fn managed_ca_trust_bundle_records_startup_ca_env_values() { let dir = tempdir().unwrap(); @@ -554,6 +901,66 @@ mod tests { assert!(baseline_bundle.contains("managed ca")); } + #[test] + fn read_custom_ca_bundle_rejects_non_regular_file() { + let dir = tempdir().unwrap(); + + let err = read_custom_ca_bundle(dir.path(), |_| true).unwrap_err(); + + assert!( + err.to_string().contains("must be a regular file") + || err.to_string().contains("failed to open CA bundle"), + "unexpected error: {err:#}" + ); + } + + #[cfg(unix)] + #[test] + fn read_custom_ca_bundle_reads_readable_symlink() { + let dir = tempdir().unwrap(); + let ca_bundle_path = dir.path().join("ca.pem"); + let symlink_path = dir.path().join("ca-link.pem"); + fs::write(&ca_bundle_path, "custom ca\n").unwrap(); + std::os::unix::fs::symlink(&ca_bundle_path, &symlink_path).unwrap(); + + assert_eq!( + read_custom_ca_bundle(&symlink_path, |_| true).unwrap(), + "custom ca\n" + ); + } + + #[cfg(windows)] + #[test] + fn opened_file_path_uses_windows_handle_target() { + let dir = tempdir().unwrap(); + let checked_path = dir.path().join("checked.pem"); + let opened_path = dir.path().join("opened.pem"); + fs::write(&checked_path, "checked ca\n").unwrap(); + fs::write(&opened_path, "opened ca\n").unwrap(); + let file = File::open(&opened_path).unwrap(); + + assert_eq!( + opened_file_path(&checked_path, &file).unwrap(), + opened_path.canonicalize().unwrap() + ); + } + + #[test] + fn read_custom_ca_dir_rejects_too_many_entries() { + let dir = tempdir().unwrap(); + for entry_index in 0..=MAX_CUSTOM_CA_DIR_ENTRIES { + fs::write(dir.path().join(format!("ca-{entry_index}.pem")), "ca\n").unwrap(); + } + + let err = read_custom_ca_dir(dir.path(), |_| true).unwrap_err(); + assert!( + err.to_string().contains(&format!( + "CA directory exceeds {MAX_CUSTOM_CA_DIR_ENTRIES} entries" + )), + "unexpected error: {err:#}" + ); + } + #[cfg(unix)] #[test] fn validate_existing_ca_key_file_rejects_group_world_permissions() { diff --git a/codex-rs/network-proxy/src/child_ca.rs b/codex-rs/network-proxy/src/child_ca.rs new file mode 100644 index 0000000000..c0f4e267c3 --- /dev/null +++ b/codex-rs/network-proxy/src/child_ca.rs @@ -0,0 +1,203 @@ +use crate::certs::ManagedMitmCaTrustBundle; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::path::Path; +use tracing::warn; + +pub(crate) fn prepare_mitm_ca_trust_bundle_env( + mitm_ca_trust_bundle: &ManagedMitmCaTrustBundle, + env: &mut HashMap, + cwd: &Path, + startup_ca_env_keys_present_in_child: &[&'static str], + can_read_path: F, +) -> Vec +where + F: Fn(&Path) -> bool, +{ + let ssl_cert_dir_contents = read_child_ca_dir_contents( + mitm_ca_trust_bundle, + env, + cwd, + startup_ca_env_keys_present_in_child, + &can_read_path, + ); + // Fold SSL_CERT_DIR into SSL_CERT_FILE so children cannot consult an + // unmaterialized CA directory after preparation. + env.remove(crate::certs::SSL_CERT_DIR_ENV_KEY); + let mut materialized_ca_trust_bundle_paths = Vec::new(); + for key in crate::certs::CUSTOM_CA_ENV_KEYS { + let Some(value) = env.get(key).filter(|value| !value.is_empty()) else { + continue; + }; + let mut custom_ca_bundle = read_child_ca_bundle_contents( + mitm_ca_trust_bundle, + key, + value, + cwd, + startup_ca_env_keys_present_in_child, + &can_read_path, + ) + .unwrap_or_default(); + if key == "SSL_CERT_FILE" + && let Some(ssl_cert_dir_contents) = ssl_cert_dir_contents.as_deref() + && let Err(err) = crate::certs::append_bounded_pem_contents( + &mut custom_ca_bundle, + ssl_cert_dir_contents, + ) + { + warn!( + ca_env_key = key, + "failed to combine child MITM CA bundle; leaving current value unchanged: {err}" + ); + continue; + } + if custom_ca_bundle.is_empty() { + continue; + } + + match crate::certs::materialize_ca_trust_bundle_with_custom_ca( + mitm_ca_trust_bundle, + &custom_ca_bundle, + ) { + Ok(path) => { + env.insert(key.to_string(), path.to_string_lossy().into_owned()); + materialized_ca_trust_bundle_paths.push(path); + } + Err(err) => { + warn!( + ca_env_key = key, + "failed to materialize child MITM CA bundle; leaving current value unchanged: {err}" + ); + } + } + } + + managed_mitm_ca_trust_bundle_paths_for_env( + mitm_ca_trust_bundle, + env, + &materialized_ca_trust_bundle_paths, + ) +} + +fn resolve_ca_bundle_path(path: &str, cwd: &Path) -> std::path::PathBuf { + let path = Path::new(path); + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } +} + +fn read_child_ca_bundle_contents( + mitm_ca_trust_bundle: &ManagedMitmCaTrustBundle, + key: &'static str, + value: &str, + cwd: &Path, + startup_ca_env_keys_present_in_child: &[&'static str], + can_read_path: &F, +) -> Option +where + F: Fn(&Path) -> bool, +{ + let value_path = Path::new(value); + let custom_ca_bundle_path = if startup_ca_env_keys_present_in_child.contains(&key) { + let startup_value = mitm_ca_trust_bundle.startup_env_values.get(key)?; + resolve_ca_bundle_path(startup_value, &mitm_ca_trust_bundle.startup_cwd) + } else if value_path == mitm_ca_trust_bundle.path { + return None; + } else { + resolve_ca_bundle_path(value, cwd) + }; + match crate::certs::read_custom_ca_bundle(&custom_ca_bundle_path, can_read_path) { + Ok(contents) => Some(contents), + Err(err) => { + warn!( + ca_env_key = key, + ca_bundle_path = %custom_ca_bundle_path.display(), + "failed to read child MITM CA bundle; leaving current value unchanged: {err}" + ); + None + } + } +} + +fn read_child_ca_dir_contents( + mitm_ca_trust_bundle: &ManagedMitmCaTrustBundle, + env: &HashMap, + cwd: &Path, + startup_ca_env_keys_present_in_child: &[&'static str], + can_read_path: &F, +) -> Option +where + F: Fn(&Path) -> bool, +{ + let value = env + .get(crate::certs::SSL_CERT_DIR_ENV_KEY) + .filter(|value| !value.is_empty())?; + let ca_dir_cwd = + if startup_ca_env_keys_present_in_child.contains(&crate::certs::SSL_CERT_DIR_ENV_KEY) { + &mitm_ca_trust_bundle.startup_cwd + } else { + cwd + }; + let mut trust_bundle = String::new(); + for ca_dir_path in std::env::split_paths(value).map(|path| { + if path.is_absolute() { + path + } else { + ca_dir_cwd.join(path) + } + }) { + match crate::certs::read_custom_ca_dir(&ca_dir_path, can_read_path) { + Ok(contents) if !contents.is_empty() => { + if let Err(err) = + crate::certs::append_bounded_pem_contents(&mut trust_bundle, &contents) + { + warn!( + ca_bundle_path = %ca_dir_path.display(), + "failed to combine child MITM CA directories; ignoring SSL_CERT_DIR override: {err}" + ); + return None; + } + } + Ok(_) => {} + Err(err) => { + warn!( + ca_bundle_path = %ca_dir_path.display(), + "failed to read child MITM CA directory; skipping it: {err}" + ); + } + } + } + if trust_bundle.is_empty() { + None + } else { + Some(trust_bundle) + } +} + +fn managed_mitm_ca_trust_bundle_paths_for_env( + mitm_ca_trust_bundle: &ManagedMitmCaTrustBundle, + env: &HashMap, + materialized_ca_trust_bundle_paths: &[std::path::PathBuf], +) -> Vec { + let mut paths = crate::certs::CUSTOM_CA_ENV_KEYS + .into_iter() + .filter_map(|key| env.get(key)) + .map(Path::new) + .filter(|path| { + *path == mitm_ca_trust_bundle.path + || materialized_ca_trust_bundle_paths + .iter() + .any(|materialized_path| path == materialized_path) + }) + .filter_map(|path| AbsolutePathBuf::from_absolute_path(path).ok()) + .collect::>(); + paths.sort(); + paths.dedup(); + paths +} + +#[cfg(test)] +#[path = "child_ca_tests.rs"] +mod tests; diff --git a/codex-rs/network-proxy/src/child_ca_tests.rs b/codex-rs/network-proxy/src/child_ca_tests.rs new file mode 100644 index 0000000000..685d674e07 --- /dev/null +++ b/codex-rs/network-proxy/src/child_ca_tests.rs @@ -0,0 +1,161 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use tempfile::tempdir; + +const REQUESTS_CA_BUNDLE_ENV_KEY: &str = "REQUESTS_CA_BUNDLE"; + +fn test_mitm_ca_trust_bundle( + dir: &tempfile::TempDir, + startup_env_values: HashMap<&'static str, String>, +) -> ManagedMitmCaTrustBundle { + let path = dir.path().join("ca-bundle.pem"); + fs::write(&path, "managed ca\n").unwrap(); + ManagedMitmCaTrustBundle { + path, + startup_env_values, + startup_cwd: dir.path().to_path_buf(), + } +} + +fn requests_ca_bundle_env(value: impl Into) -> HashMap { + HashMap::from([(REQUESTS_CA_BUNDLE_ENV_KEY.to_string(), value.into())]) +} + +fn requests_ca_bundle_contents(env: &HashMap) -> String { + fs::read_to_string(Path::new( + env.get(REQUESTS_CA_BUNDLE_ENV_KEY) + .expect("REQUESTS_CA_BUNDLE should be set"), + )) + .unwrap() +} + +fn ssl_cert_dir_env( + dir: &tempfile::TempDir, + contents: [String; 2], +) -> (HashMap, ManagedMitmCaTrustBundle) { + let ssl_cert_dir_paths = [dir.path().join("certs-a"), dir.path().join("certs-b")]; + for (path, contents) in ssl_cert_dir_paths.iter().zip(contents) { + fs::create_dir(path).unwrap(); + fs::write(path.join("ordinary-ca.pem"), contents).unwrap(); + } + let mitm_ca_trust_bundle_path = dir.path().join("ca-bundle.pem"); + fs::write(&mitm_ca_trust_bundle_path, "managed ca\n").unwrap(); + let ssl_cert_dir = std::env::join_paths(["certs-a", "certs-b"]).unwrap(); + let ssl_cert_dir = ssl_cert_dir.to_string_lossy().into_owned(); + ( + HashMap::from([ + ( + "SSL_CERT_FILE".to_string(), + mitm_ca_trust_bundle_path.display().to_string(), + ), + ( + crate::certs::SSL_CERT_DIR_ENV_KEY.to_string(), + ssl_cert_dir.clone(), + ), + ]), + ManagedMitmCaTrustBundle { + path: mitm_ca_trust_bundle_path, + startup_env_values: HashMap::from([(crate::certs::SSL_CERT_DIR_ENV_KEY, ssl_cert_dir)]), + startup_cwd: dir.path().to_path_buf(), + }, + ) +} + +#[test] +fn materializes_readable_startup_ca_override() { + let dir = tempdir().unwrap(); + let startup_ca_bundle_path = dir.path().join("startup-ca.pem"); + let command_cwd = dir.path().join("command-cwd"); + fs::create_dir(&command_cwd).unwrap(); + fs::write(&startup_ca_bundle_path, "startup ca\n").unwrap(); + let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle( + &dir, + HashMap::from([(REQUESTS_CA_BUNDLE_ENV_KEY, "startup-ca.pem".to_string())]), + ); + let mut env = requests_ca_bundle_env("startup-ca.pem"); + + let bundle_paths = prepare_mitm_ca_trust_bundle_env( + &mitm_ca_trust_bundle, + &mut env, + &command_cwd, + &[REQUESTS_CA_BUNDLE_ENV_KEY], + |_| true, + ); + + assert_eq!( + requests_ca_bundle_contents(&env), + "startup ca\nmanaged ca\n" + ); + assert_eq!(bundle_paths.len(), 1); +} + +#[test] +fn materializes_readable_ssl_cert_dir() { + let dir = tempdir().unwrap(); + let (mut env, mitm_ca_trust_bundle) = + ssl_cert_dir_env(&dir, ["dir ca a\n".to_string(), "dir ca b\n".to_string()]); + + prepare_mitm_ca_trust_bundle_env( + &mitm_ca_trust_bundle, + &mut env, + dir.path(), + &[crate::certs::SSL_CERT_DIR_ENV_KEY], + |_| true, + ); + + let ssl_cert_file_path = Path::new( + env.get("SSL_CERT_FILE") + .expect("SSL_CERT_FILE should be set"), + ); + assert_eq!( + fs::read_to_string(ssl_cert_file_path).unwrap(), + "dir ca a\ndir ca b\nmanaged ca\n" + ); + assert_eq!(env.get(crate::certs::SSL_CERT_DIR_ENV_KEY), None); +} + +#[test] +fn bounds_aggregate_ssl_cert_dir_contents() { + let dir = tempdir().unwrap(); + let oversized_dir_contents = "a".repeat(2_200_000); + let (mut env, mitm_ca_trust_bundle) = ssl_cert_dir_env( + &dir, + [oversized_dir_contents.clone(), oversized_dir_contents], + ); + + prepare_mitm_ca_trust_bundle_env( + &mitm_ca_trust_bundle, + &mut env, + dir.path(), + &[crate::certs::SSL_CERT_DIR_ENV_KEY], + |_| true, + ); + + assert_eq!( + env.get("SSL_CERT_FILE"), + Some(&mitm_ca_trust_bundle.path.display().to_string()) + ); + assert_eq!(env.get(crate::certs::SSL_CERT_DIR_ENV_KEY), None); +} + +#[test] +fn preserves_unreadable_command_scoped_override() { + let dir = tempdir().unwrap(); + let command_ca_bundle_path = dir.path().join("command-ca.pem"); + fs::write(&command_ca_bundle_path, "command ca\n").unwrap(); + let mut env = requests_ca_bundle_env("command-ca.pem"); + let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle(&dir, HashMap::new()); + + let bundle_paths = + prepare_mitm_ca_trust_bundle_env(&mitm_ca_trust_bundle, &mut env, dir.path(), &[], |_| { + false + }); + + assert_eq!( + env.get(REQUESTS_CA_BUNDLE_ENV_KEY), + Some(&"command-ca.pem".to_string()) + ); + assert!(bundle_paths.is_empty()); +} diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index dfc9c07115..caf7165510 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -1,6 +1,7 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] mod certs; +mod child_ca; mod config; mod connect_policy; mod http_proxy; From f4f971624e37563bae19676633c597baa4a3bb2c Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 3 Jun 2026 22:20:01 -0700 Subject: [PATCH 5/8] Trim child CA tests --- codex-rs/network-proxy/src/child_ca_tests.rs | 57 -------------------- 1 file changed, 57 deletions(-) diff --git a/codex-rs/network-proxy/src/child_ca_tests.rs b/codex-rs/network-proxy/src/child_ca_tests.rs index 4fb38c548a..685d674e07 100644 --- a/codex-rs/network-proxy/src/child_ca_tests.rs +++ b/codex-rs/network-proxy/src/child_ca_tests.rs @@ -91,43 +91,6 @@ fn materializes_readable_startup_ca_override() { assert_eq!(bundle_paths.len(), 1); } -#[test] -fn does_not_restore_filtered_startup_override() { - let dir = tempdir().unwrap(); - let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle( - &dir, - HashMap::from([(REQUESTS_CA_BUNDLE_ENV_KEY, "startup-ca.pem".to_string())]), - ); - let mut env = requests_ca_bundle_env(mitm_ca_trust_bundle.path.display().to_string()); - - let bundle_paths = - prepare_mitm_ca_trust_bundle_env(&mitm_ca_trust_bundle, &mut env, dir.path(), &[], |_| { - true - }); - - assert_eq!( - env.get(REQUESTS_CA_BUNDLE_ENV_KEY), - Some(&mitm_ca_trust_bundle.path.display().to_string()) - ); - assert_eq!(bundle_paths.len(), 1); -} - -#[test] -fn materializes_readable_command_scoped_override() { - let dir = tempdir().unwrap(); - let command_ca_bundle_path = dir.path().join("command-ca.pem"); - fs::write(&command_ca_bundle_path, "command ca\n").unwrap(); - let mut env = requests_ca_bundle_env("command-ca.pem"); - let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle(&dir, HashMap::new()); - - prepare_mitm_ca_trust_bundle_env(&mitm_ca_trust_bundle, &mut env, dir.path(), &[], |_| true); - - assert_eq!( - requests_ca_bundle_contents(&env), - "command ca\nmanaged ca\n" - ); -} - #[test] fn materializes_readable_ssl_cert_dir() { let dir = tempdir().unwrap(); @@ -196,23 +159,3 @@ fn preserves_unreadable_command_scoped_override() { ); assert!(bundle_paths.is_empty()); } - -#[test] -fn does_not_whitelist_existing_generated_bundle_override() { - let dir = tempdir().unwrap(); - let generated_ca_bundle_path = dir.path().join("ca-bundle-handcrafted.pem"); - fs::write(&generated_ca_bundle_path, "extra ca\nmanaged ca\n").unwrap(); - let mut env = requests_ca_bundle_env(generated_ca_bundle_path.display().to_string()); - let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle(&dir, HashMap::new()); - - let bundle_paths = - prepare_mitm_ca_trust_bundle_env(&mitm_ca_trust_bundle, &mut env, dir.path(), &[], |_| { - false - }); - - assert_eq!( - env.get(REQUESTS_CA_BUNDLE_ENV_KEY), - Some(&generated_ca_bundle_path.display().to_string()) - ); - assert!(bundle_paths.is_empty()); -} From 0d6f4047c516a057b32a6048fa7b2b0ca0c42e0e Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 3 Jun 2026 22:29:55 -0700 Subject: [PATCH 6/8] Fix Windows CA handle metadata init --- codex-rs/network-proxy/src/certs.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index 780a13f745..d3ee637a72 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -580,7 +580,8 @@ fn windows_file_identity(file: &File) -> Result<(u32, u64)> { use windows_sys::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION; use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandle; - let mut file_information = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: Win32 fills this plain-old-data output struct before we read it. + let mut file_information = unsafe { std::mem::zeroed::() }; // SAFETY: `file` owns a live OS handle and `file_information` is writable. let result = unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut file_information) }; From 750e73e047522f9abd7bdbf078205bff1a0c8753 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Thu, 4 Jun 2026 03:43:04 -0700 Subject: [PATCH 7/8] Preserve startup CA overrides in MITM bundle --- codex-rs/network-proxy/src/certs.rs | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index f316e2c21f..f7348feabf 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -23,6 +23,7 @@ use rama_tls_rustls::server::TlsAcceptorData; use sha2::Digest as _; use sha2::Sha256; use std::collections::HashMap; +use std::collections::HashSet; use std::fs; use std::fs::File; use std::fs::OpenOptions; @@ -154,7 +155,7 @@ fn managed_ca_trust_bundle_for_cert_path( .map(|value| (key, value.clone())) }) .collect(); - let trust_bundle = build_managed_ca_trust_bundle(cert_path)?; + let trust_bundle = build_managed_ca_trust_bundle(cert_path, &startup_env_values)?; let path = persist_managed_ca_trust_bundle(cert_path, &trust_bundle)?; Ok(ManagedMitmCaTrustBundle { @@ -163,7 +164,10 @@ fn managed_ca_trust_bundle_for_cert_path( }) } -fn build_managed_ca_trust_bundle(managed_ca_cert_path: &Path) -> Result { +fn build_managed_ca_trust_bundle( + managed_ca_cert_path: &Path, + startup_env_values: &HashMap<&'static str, String>, +) -> Result { let mut trust_bundle = String::new(); let rustls_native_certs::CertificateResult { certs, errors, .. } = crate::native_certs::load_platform_native_certs(); @@ -176,6 +180,16 @@ fn build_managed_ca_trust_bundle(managed_ca_cert_path: &Path) -> Result for cert in certs { push_certificate_pem(&mut trust_bundle, cert.as_ref()); } + let mut appended_startup_paths = HashSet::new(); + for path in CUSTOM_CA_ENV_KEYS + .into_iter() + .filter_map(|key| startup_env_values.get(key)) + .map(Path::new) + { + if path != managed_ca_cert_path && appended_startup_paths.insert(path) { + append_pem_file(&mut trust_bundle, path)?; + } + } append_pem_file(&mut trust_bundle, managed_ca_cert_path)?; Ok(trust_bundle) } @@ -511,18 +525,21 @@ mod tests { fn managed_ca_trust_bundle_records_startup_ca_env_values() { let dir = tempdir().unwrap(); let managed_ca_cert_path = dir.path().join("ca.pem"); + let startup_ca_bundle_path = dir.path().join("startup-ca.pem"); fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); - let env = HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]); + fs::write(&startup_ca_bundle_path, "startup ca\n").unwrap(); + let startup_ca_bundle_path = startup_ca_bundle_path.display().to_string(); + let env = HashMap::from([("SSL_CERT_FILE", startup_ca_bundle_path.clone())]); let trust_bundle = managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); assert_eq!( trust_bundle.startup_env_values, - HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]) + HashMap::from([("SSL_CERT_FILE", startup_ca_bundle_path)]) ); } #[test] - fn managed_ca_trust_bundle_does_not_append_startup_ca_override_to_baseline() { + fn managed_ca_trust_bundle_appends_startup_ca_override_to_baseline() { let dir = tempdir().unwrap(); let managed_ca_cert_path = dir.path().join("ca.pem"); let startup_ca_bundle_path = dir.path().join("startup-ca.pem"); @@ -537,7 +554,7 @@ mod tests { managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); let baseline_bundle = fs::read_to_string(trust_bundle.path).unwrap(); - assert!(!baseline_bundle.contains("startup ca")); + assert!(baseline_bundle.contains("startup ca")); assert!(baseline_bundle.contains("managed ca")); } From 3d8d0102dddcf74b4af8c822b7dfe1d60a8eeef8 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Thu, 4 Jun 2026 03:50:37 -0700 Subject: [PATCH 8/8] Expose child MITM CA env preparation --- codex-rs/network-proxy/src/proxy.rs | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 61b0639cf1..c8c7fe5194 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -13,6 +13,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::net::SocketAddr; use std::net::TcpListener as StdTcpListener; +use std::path::Path; use std::sync::Arc; use std::sync::Mutex; use std::sync::RwLock; @@ -687,6 +688,43 @@ impl NetworkProxy { ); } + /// Rewrites readable child-selected CA bundles into immutable managed MITM bundles. + pub fn prepare_child_env( + &self, + env: &mut HashMap, + cwd: &Path, + can_read_path: F, + ) -> Vec + where + F: Fn(&Path) -> bool, + { + let runtime_settings = self.runtime_settings(); + apply_proxy_env_overrides( + env, + self.http_addr, + self.socks_addr, + self.socks_enabled, + runtime_settings.allow_local_binding, + runtime_settings.mitm_ca_trust_bundle.as_ref(), + ); + let startup_ca_env_keys_present_in_child = ca_env_keys() + .filter(|&key| is_tracked_startup_ca_env_key(env, key)) + .collect::>(); + env.remove(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY); + runtime_settings.mitm_ca_trust_bundle.as_ref().map_or_else( + Vec::new, + |mitm_ca_trust_bundle| { + crate::child_ca::prepare_mitm_ca_trust_bundle_env( + mitm_ca_trust_bundle, + env, + cwd, + &startup_ca_env_keys_present_in_child, + can_read_path, + ) + }, + ) + } + pub async fn replace_config_state(&self, new_state: ConfigState) -> Result<()> { let current_cfg = self.state.current_cfg().await?; anyhow::ensure!(