diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9eecd0eec8cd..ce7d66db04d1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3304,6 +3304,7 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "chrono", "clap", "codex-utils-absolute-path", @@ -3319,6 +3320,7 @@ dependencies = [ "rama-tcp", "rama-tls-rustls", "rama-unix", + "rustls-native-certs", "serde", "serde_json", "tempfile", diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 18c276f07d72..00c14317223e 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -1491,10 +1491,12 @@ fn json_object_to_env_toml_table( object: &serde_json::Map, ) -> toml::map::Map { let mut table = toml::map::Map::new(); - for (key, value) in object { - if let Some(value) = json_env_value_to_string(value) { - table.insert(key.clone(), TomlValue::String(value)); - } + for (key, value) in object + .iter() + .filter_map(|(key, value)| json_env_value_to_string(value).map(|value| (key, value))) + .collect::>() + { + table.insert(key.clone(), TomlValue::String(value)); } table } diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index d3a19a41ca84..e097269f1a25 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -15,6 +15,7 @@ workspace = true [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +base64 = { workspace = true } clap = { workspace = true, features = ["derive"] } chrono = { workspace = true } codex-utils-absolute-path = { workspace = true } @@ -35,6 +36,7 @@ rama-net = { version = "=0.3.0-alpha.4", features = ["http", "tls"] } rama-socks5 = { version = "=0.3.0-alpha.4" } rama-tcp = { version = "=0.3.0-alpha.4", features = ["http"] } rama-tls-rustls = { version = "=0.3.0-alpha.4", features = ["http"] } +rustls-native-certs = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 8c2117b4dc3c..3f531cefa4fc 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -35,6 +35,8 @@ dangerously_allow_non_loopback_proxy = false mode = "full" # default when unset; use "limited" for read-only mode # HTTPS MITM is enabled automatically when `mode = "limited"` or when MITM hooks are configured. # 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. # If false, local/private networking is rejected. Explicit allowlisting of local IP literals # (or `localhost`) is required to permit them. diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index 2699d0036569..63dcb9762ba0 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -1,6 +1,7 @@ use anyhow::Context as _; use anyhow::Result; use anyhow::anyhow; +use base64::Engine as _; use codex_utils_home_dir::find_codex_home; use rama_net::tls::ApplicationProtocol; use rama_tls_rustls::dep::pki_types::CertificateDer; @@ -19,6 +20,7 @@ use rama_tls_rustls::dep::rcgen::PKCS_ECDSA_P256_SHA256; use rama_tls_rustls::dep::rcgen::SanType; use rama_tls_rustls::dep::rustls; use rama_tls_rustls::server::TlsAcceptorData; +use std::collections::HashMap; use std::fs; use std::fs::File; use std::fs::OpenOptions; @@ -29,6 +31,7 @@ use std::path::PathBuf; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tracing::info; +use tracing::warn; pub(super) struct ManagedMitmCa { issuer: Issuer<'static, KeyPair>, @@ -95,6 +98,20 @@ fn issue_host_certificate_pem( 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: &str = "ca-bundle.pem"; + +const CUSTOM_CA_ENV_KEYS: &[&str] = &[ + "CODEX_CA_CERTIFICATE", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + "GIT_SSL_CAINFO", + "PIP_CERT", + "BUNDLE_SSL_CA_CERT", + "npm_config_cafile", + "NPM_CONFIG_CAFILE", +]; fn managed_ca_paths() -> Result<(PathBuf, PathBuf)> { let codex_home = @@ -106,6 +123,85 @@ fn managed_ca_paths() -> Result<(PathBuf, PathBuf)> { )) } +pub(crate) fn managed_ca_trust_bundle_path(env: &HashMap) -> Result { + let (cert_path, _) = managed_ca_paths()?; + let trust_bundle_path = cert_path + .parent() + .ok_or_else(|| anyhow!("managed MITM CA cert path is missing a parent"))? + .join(MANAGED_MITM_CA_TRUST_BUNDLE); + let trust_bundle = build_managed_ca_trust_bundle(&cert_path, env)?; + write_atomic_replace( + &trust_bundle_path, + trust_bundle.as_bytes(), + /*mode*/ 0o644, + ) + .with_context(|| { + format!( + "failed to persist managed MITM CA trust bundle {}", + trust_bundle_path.display() + ) + })?; + Ok(trust_bundle_path) +} + +fn build_managed_ca_trust_bundle( + managed_ca_cert_path: &Path, + env: &HashMap, +) -> Result { + let mut trust_bundle = String::new(); + let rustls_native_certs::CertificateResult { certs, errors, .. } = + rustls_native_certs::load_native_certs(); + if !errors.is_empty() { + warn!( + native_root_error_count = errors.len(), + "encountered errors while loading native root certificates for MITM trust bundle" + ); + } + for cert in certs { + push_certificate_pem(&mut trust_bundle, cert.as_ref()); + } + + let mut custom_ca_paths = Vec::new(); + for key in CUSTOM_CA_ENV_KEYS { + let Some(path) = env.get(*key).filter(|path| !path.is_empty()) else { + continue; + }; + let path = PathBuf::from(path); + if path == managed_ca_cert_path || custom_ca_paths.contains(&path) { + continue; + } + custom_ca_paths.push(path); + } + for path in custom_ca_paths { + append_pem_file(&mut trust_bundle, &path)?; + } + append_pem_file(&mut trust_bundle, managed_ca_cert_path)?; + Ok(trust_bundle) +} + +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); + if !bundle.ends_with('\n') { + bundle.push('\n'); + } + Ok(()) +} + +fn push_certificate_pem(bundle: &mut String, der: &[u8]) { + bundle.push_str("-----BEGIN CERTIFICATE-----\n"); + let encoded = base64::engine::general_purpose::STANDARD.encode(der); + for chunk in encoded.as_bytes().chunks(64) { + bundle.push_str(&String::from_utf8_lossy(chunk)); + bundle.push('\n'); + } + bundle.push_str("-----END CERTIFICATE-----\n"); +} + fn load_or_create_ca() -> Result<(String, String)> { let (cert_path, key_path) = managed_ca_paths()?; @@ -238,6 +334,55 @@ fn write_atomic_create_new(path: &Path, contents: &[u8], mode: u32) -> Result<() Ok(()) } +fn write_atomic_replace(path: &Path, contents: &[u8], mode: u32) -> Result<()> { + if fs::read(path).ok().as_deref() == Some(contents) { + return Ok(()); + } + + let parent = path + .parent() + .ok_or_else(|| anyhow!("missing parent directory"))?; + fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; + if fs::symlink_metadata(path) + .ok() + .is_some_and(|metadata| metadata.file_type().is_symlink()) + { + return Err(anyhow!("refusing to overwrite symlink {}", path.display())); + } + + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let pid = std::process::id(); + let file_name = path.file_name().unwrap_or_default().to_string_lossy(); + let tmp_path = parent.join(format!(".{file_name}.tmp.{pid}.{nanos}")); + + let mut file = open_create_new_with_mode(&tmp_path, mode)?; + file.write_all(contents) + .with_context(|| format!("failed to write {}", tmp_path.display()))?; + file.sync_all() + .with_context(|| format!("failed to fsync {}", tmp_path.display()))?; + drop(file); + + #[cfg(windows)] + if path.exists() { + fs::remove_file(path).with_context(|| format!("failed to remove {}", path.display()))?; + } + fs::rename(&tmp_path, path).with_context(|| { + format!( + "failed to rename {} -> {}", + tmp_path.display(), + path.display() + ) + })?; + + let dir = File::open(parent).with_context(|| format!("failed to open {}", parent.display()))?; + dir.sync_all() + .with_context(|| format!("failed to fsync {}", parent.display()))?; + Ok(()) +} + #[cfg(unix)] fn validate_existing_ca_key_file(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 2b76b27b8f63..9634298ff2c8 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -12,6 +12,8 @@ use clap::Parser; use std::collections::HashMap; use std::net::SocketAddr; use std::net::TcpListener as StdTcpListener; +use std::path::Path; +use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use std::sync::RwLock; @@ -223,7 +225,7 @@ impl NetworkProxyBuilder { socks_enabled: current_cfg.network.enable_socks5, runtime_settings: Arc::new(RwLock::new(NetworkProxyRuntimeSettings::from_config( ¤t_cfg, - ))), + )?)), reserved_listeners, policy_decider: self.policy_decider, }) @@ -299,15 +301,22 @@ struct NetworkProxyRuntimeSettings { allow_local_binding: bool, allow_unix_sockets: Arc<[String]>, dangerously_allow_all_unix_sockets: bool, + mitm_ca_trust_bundle_path: Option, } impl NetworkProxyRuntimeSettings { - fn from_config(config: &config::NetworkProxyConfig) -> Self { - Self { + fn from_config(config: &config::NetworkProxyConfig) -> Result { + let mitm_ca_trust_bundle_path = config + .network + .mitm + .then(|| crate::certs::managed_ca_trust_bundle_path(&std::env::vars().collect())) + .transpose()?; + 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_trust_bundle_path, + }) } } @@ -403,6 +412,16 @@ pub const PROXY_ENV_KEYS: &[&str] = &[ "all_proxy", "FTP_PROXY", "ftp_proxy", + "CODEX_CA_CERTIFICATE", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + "GIT_SSL_CAINFO", + "PIP_CERT", + "BUNDLE_SSL_CA_CERT", + "npm_config_cafile", + "NPM_CONFIG_CAFILE", ]; #[cfg(target_os = "macos")] @@ -475,6 +494,7 @@ fn apply_proxy_env_overrides( socks_addr: SocketAddr, socks_enabled: bool, allow_local_binding: bool, + mitm_ca_trust_bundle_path: Option<&Path>, ) { let http_proxy_url = format!("http://{http_addr}"); let socks_proxy_url = format!("socks5h://{socks_addr}"); @@ -552,6 +572,26 @@ fn apply_proxy_env_overrides( } } } + + if let Some(mitm_ca_trust_bundle_path) = mitm_ca_trust_bundle_path { + let mitm_ca_trust_bundle_path = mitm_ca_trust_bundle_path.to_string_lossy(); + set_env_keys( + env, + &[ + "CODEX_CA_CERTIFICATE", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + "GIT_SSL_CAINFO", + "PIP_CERT", + "BUNDLE_SSL_CA_CERT", + "npm_config_cafile", + "NPM_CONFIG_CAFILE", + ], + &mitm_ca_trust_bundle_path, + ); + } } impl NetworkProxy { @@ -592,7 +632,7 @@ impl NetworkProxy { } pub fn apply_to_env(&self, env: &mut HashMap) { - let allow_local_binding = self.allow_local_binding(); + let runtime_settings = self.runtime_settings(); // Enforce proxying for child processes. We intentionally override existing values so // command-level environment cannot bypass the managed proxy endpoint. apply_proxy_env_overrides( @@ -600,7 +640,8 @@ impl NetworkProxy { self.http_addr, self.socks_addr, self.socks_enabled, - allow_local_binding, + runtime_settings.allow_local_binding, + runtime_settings.mitm_ca_trust_bundle_path.as_deref(), ); } @@ -627,7 +668,7 @@ impl NetworkProxy { "cannot update network.enable_socks5_udp on a running proxy" ); - let settings = NetworkProxyRuntimeSettings::from_config(&new_state.config); + let settings = NetworkProxyRuntimeSettings::from_config(&new_state.config)?; self.state.replace_config_state(new_state).await?; let mut guard = self .runtime_settings @@ -975,6 +1016,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), /*socks_enabled*/ true, /*allow_local_binding*/ false, + /*mitm_ca_trust_bundle_path*/ None, ); assert_eq!( @@ -1037,6 +1079,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), /*socks_enabled*/ true, /*allow_local_binding*/ false, + /*mitm_ca_trust_bundle_path*/ None, ); for key in env.keys() { @@ -1049,6 +1092,38 @@ mod tests { } } + #[test] + fn apply_proxy_env_overrides_sets_mitm_ca_trust_bundle_vars() { + let mut env = HashMap::new(); + let mitm_ca_trust_bundle_path = Path::new("/tmp/codex-proxy/ca-bundle.pem"); + 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_path), + ); + + for key in [ + "CODEX_CA_CERTIFICATE", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + "GIT_SSL_CAINFO", + "PIP_CERT", + "BUNDLE_SSL_CA_CERT", + "npm_config_cafile", + "NPM_CONFIG_CAFILE", + ] { + assert_eq!( + env.get(key), + Some(&mitm_ca_trust_bundle_path.display().to_string()) + ); + } + } + #[test] fn apply_proxy_env_overrides_uses_http_for_all_proxy_without_socks() { let mut env = HashMap::new(); @@ -1058,6 +1133,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), /*socks_enabled*/ false, /*allow_local_binding*/ true, + /*mitm_ca_trust_bundle_path*/ None, ); assert_eq!( @@ -1076,6 +1152,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), /*socks_enabled*/ true, /*allow_local_binding*/ false, + /*mitm_ca_trust_bundle_path*/ None, ); assert_eq!( @@ -1124,6 +1201,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), /*socks_enabled*/ true, /*allow_local_binding*/ false, + /*mitm_ca_trust_bundle_path*/ None, ); assert_eq!( @@ -1146,6 +1224,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081), /*socks_enabled*/ true, /*allow_local_binding*/ false, + /*mitm_ca_trust_bundle_path*/ None, ); assert_eq!( @@ -1169,6 +1248,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081), /*socks_enabled*/ true, /*allow_local_binding*/ false, + /*mitm_ca_trust_bundle_path*/ None, ); assert_eq!( diff --git a/codex-rs/network-proxy/src/upstream.rs b/codex-rs/network-proxy/src/upstream.rs index 72b7290f1290..3437b0d32deb 100644 --- a/codex-rs/network-proxy/src/upstream.rs +++ b/codex-rs/network-proxy/src/upstream.rs @@ -1,5 +1,6 @@ use crate::connect_policy::TargetCheckedTcpConnector; use crate::state::NetworkProxyState; +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use rama_core::Layer; use rama_core::Service; use rama_core::error::BoxError; @@ -225,6 +226,7 @@ fn build_http_connector( EstablishedClientConnection, Request>, BoxError, > { + ensure_rustls_crypto_provider(); let proxy = HttpProxyConnectorLayer::optional().into_layer(transport); let tls_config = TlsConnectorDataBuilder::new() .with_alpn_protocols_http_auto()