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..fd9ced3568 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_Storage_FileSystem"] } diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index f316e2c21f..fb2f6fd532 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -23,11 +23,27 @@ 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::fs::MetadataExt; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; use std::path::Path; use std::path::PathBuf; use std::time::SystemTime; @@ -101,12 +117,15 @@ 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 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 +141,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 +166,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 +183,7 @@ fn managed_ca_trust_bundle_for_cert_path( Ok(ManagedMitmCaTrustBundle { path, startup_env_values, + startup_cwd, }) } @@ -184,18 +208,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; }; @@ -220,6 +249,128 @@ 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, &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 in fs::read_dir(&dir) + .with_context(|| format!("failed to read CA directory {}", dir.display()))? + { + 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()); @@ -240,16 +391,177 @@ 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'); } +} + +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, + metadata: &fs::Metadata, +) -> Result<()> { + #[cfg(unix)] + { + 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 opened_path_metadata = fs::metadata(opened_path).with_context(|| { + format!("failed to stat 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(), + "CA bundle {} changed before it could be validated", + path.display() + ); + } + + #[cfg(not(windows))] + { + let _ = path; + let _ = opened_path; + let _ = metadata; + } + } + Ok(()) } @@ -507,17 +819,36 @@ 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(); 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()), + ]) ); } @@ -541,6 +872,50 @@ 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() + ); + } + #[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..66f5c54f3d --- /dev/null +++ b/codex-rs/network-proxy/src/child_ca.rs @@ -0,0 +1,187 @@ +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() + { + crate::certs::append_pem_contents(&mut custom_ca_bundle, ssl_cert_dir_contents); + } + 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() => { + crate::certs::append_pem_contents(&mut trust_bundle, &contents); + } + 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..de1ae98b50 --- /dev/null +++ b/codex-rs/network-proxy/src/child_ca_tests.rs @@ -0,0 +1,186 @@ +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() +} + +#[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 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(); + 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(["dir ca a\n", "dir ca b\n"]) { + 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 mut env = 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.to_string_lossy().into_owned(), + ), + ]); + let mitm_ca_trust_bundle = ManagedMitmCaTrustBundle { + path: mitm_ca_trust_bundle_path, + startup_env_values: HashMap::from([( + crate::certs::SSL_CERT_DIR_ENV_KEY, + ssl_cert_dir.to_string_lossy().into_owned(), + )]), + startup_cwd: dir.path().to_path_buf(), + }; + + 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 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()); +} + +#[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()); +} diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index eb641b81d4..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; @@ -18,6 +19,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 +48,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..df49774b06 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; @@ -308,6 +309,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 +378,7 @@ 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"; 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,7 @@ 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, ALLOW_LOCAL_BINDING_ENV_KEY, ELECTRON_GET_USE_PROXY_ENV_KEY, NODE_USE_ENV_PROXY_ENV_KEY, @@ -571,24 +575,23 @@ 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(); 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) - }) + .is_some_and(|value| value != &managed_path) { - // 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. + // Child-scoped overrides, including inherited startup values, need the + // effective filesystem policy before we can combine them with the managed CA + // bundle, so leave them for prepare_child_env(). continue; } env.insert(key.to_string(), managed_path.clone()); } + } else { + env.remove(MITM_CA_ENV_ACTIVE_ENV_KEY); } } @@ -629,17 +632,6 @@ impl NetworkProxy { self.runtime_settings().dangerously_allow_all_unix_sockets } - /// Returns the generated MITM CA bundle path child sandboxes should expose to TLS clients. - pub fn managed_mitm_ca_trust_bundle_path(&self) -> Option { - self.runtime_settings() - .mitm_ca_trust_bundle - .and_then(|bundle| { - AbsolutePathBuf::from_absolute_path(bundle.path) - .map_err(|err| warn!("managed MITM CA trust bundle path is invalid: {err}")) - .ok() - }) - } - 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; @@ -654,6 +646,51 @@ 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(); + let startup_ca_env_keys_present_in_child = runtime_settings + .mitm_ca_trust_bundle + .as_ref() + .map_or_else(Vec::new, |mitm_ca_trust_bundle| { + mitm_ca_trust_bundle + .startup_env_values + .iter() + .filter_map(|(&key, startup_value)| { + (env.get(key) == Some(startup_value)).then_some(key) + }) + .collect() + }); + 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(), + ); + 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!( @@ -1110,6 +1147,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 +1167,21 @@ 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_preserves_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.clone(), + )]), + startup_cwd: Path::new("/tmp").to_path_buf(), }; - apply_proxy_env_overrides( &mut env, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), @@ -1150,7 +1191,7 @@ 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(&startup_ca_bundle_path)); assert_eq!( env.get("SSL_CERT_FILE"), Some(&mitm_ca_trust_bundle_path.display().to_string())