From a4b65c57953e355301453dcd2a9fcc9bb55c8bdc Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Thu, 22 Jan 2026 22:50:45 -0800 Subject: [PATCH] Add MITM support to network proxy --- codex-rs/network-proxy/Cargo.toml | 3 + codex-rs/network-proxy/src/config.rs | 42 ++ codex-rs/network-proxy/src/http_proxy.rs | 73 ++- codex-rs/network-proxy/src/init.rs | 18 + codex-rs/network-proxy/src/lib.rs | 1 + codex-rs/network-proxy/src/mitm.rs | 611 +++++++++++++++++++++++ codex-rs/network-proxy/src/reasons.rs | 1 + codex-rs/network-proxy/src/responses.rs | 3 + codex-rs/network-proxy/src/runtime.rs | 8 + codex-rs/network-proxy/src/state.rs | 28 +- 10 files changed, 770 insertions(+), 18 deletions(-) create mode 100644 codex-rs/network-proxy/src/init.rs create mode 100644 codex-rs/network-proxy/src/mitm.rs diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index cf6df55036..072401ed1a 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -17,6 +17,7 @@ async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-utils-absolute-path = { workspace = true } globset = { workspace = true } +rcgen-rama = { package = "rcgen", version = "0.14", default-features = false, features = ["pem", "x509-parser", "ring"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } @@ -30,7 +31,9 @@ rama-http-backend = { version = "=0.3.0-alpha.4", features = ["tls"] } 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-boring = { version = "=0.3.0-alpha.4", features = ["http"] } rama-tls-rustls = { version = "=0.3.0-alpha.4", features = ["http"] } +rama-utils = { version = "=0.3.0-alpha.4" } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index e4ef202a45..fbc6c94abd 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -5,6 +5,7 @@ use serde::Deserialize; use serde::Serialize; use std::net::IpAddr; use std::net::SocketAddr; +use std::path::PathBuf; use tracing::warn; use url::Url; @@ -44,6 +45,8 @@ pub struct NetworkProxySettings { pub allow_unix_sockets: Vec, #[serde(default)] pub allow_local_binding: bool, + #[serde(default)] + pub mitm: MitmConfig, } impl Default for NetworkProxySettings { @@ -63,6 +66,7 @@ impl Default for NetworkProxySettings { denied_domains: Vec::new(), allow_unix_sockets: Vec::new(), allow_local_binding: false, + mitm: MitmConfig::default(), } } } @@ -88,6 +92,32 @@ impl NetworkMode { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MitmConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub inspect: bool, + #[serde(default = "default_mitm_max_body_bytes")] + pub max_body_bytes: usize, + #[serde(default = "default_ca_cert_path")] + pub ca_cert_path: PathBuf, + #[serde(default = "default_ca_key_path")] + pub ca_key_path: PathBuf, +} + +impl Default for MitmConfig { + fn default() -> Self { + Self { + enabled: false, + inspect: false, + max_body_bytes: default_mitm_max_body_bytes(), + ca_cert_path: default_ca_cert_path(), + ca_key_path: default_ca_key_path(), + } + } +} + fn default_proxy_url() -> String { "http://127.0.0.1:3128".to_string() } @@ -100,6 +130,18 @@ fn default_socks_url() -> String { "http://127.0.0.1:8081".to_string() } +fn default_ca_cert_path() -> PathBuf { + PathBuf::from("network_proxy/mitm/ca.pem") +} + +fn default_ca_key_path() -> PathBuf { + PathBuf::from("network_proxy/mitm/ca.key") +} + +fn default_mitm_max_body_bytes() -> usize { + 4096 +} + /// Clamp non-loopback bind addresses to loopback unless explicitly allowed. fn clamp_non_loopback(addr: SocketAddr, allow_non_loopback: bool, name: &str) -> SocketAddr { if addr.ip().is_loopback() { diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index e0105ae2ba..1d33e73eec 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -1,4 +1,5 @@ use crate::config::NetworkMode; +use crate::mitm; use crate::network_policy::NetworkDecision; use crate::network_policy::NetworkDecisionSource; use crate::network_policy::NetworkPolicyDecider; @@ -9,6 +10,7 @@ use crate::network_policy::NetworkProtocol; use crate::network_policy::evaluate_host_policy; use crate::policy::normalize_host; use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_MITM_REQUIRED; use crate::reasons::REASON_NOT_ALLOWED; use crate::reasons::REASON_PROXY_DISABLED; use crate::responses::PolicyDecisionDetails; @@ -208,35 +210,47 @@ async fn http_connect_accept( .await .map_err(|err| internal_error("failed to read network mode", err))?; - if mode == NetworkMode::Limited { - let details = PolicyDecisionDetails { - decision: NetworkPolicyDecision::Deny, - reason: REASON_METHOD_NOT_ALLOWED, - source: NetworkDecisionSource::ModeGuard, - protocol: NetworkProtocol::HttpsConnect, - host: &host, - port: authority.port, - }; + let mitm_state = match app_state.mitm_state().await { + Ok(state) => state, + Err(err) => { + error!("failed to load MITM state: {err}"); + return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); + } + }; + + if mode == NetworkMode::Limited && mitm_state.is_none() { + // Limited mode is designed to be read-only. Without MITM, a CONNECT tunnel would hide the + // inner HTTP method/headers from the proxy, effectively bypassing method policy. let _ = app_state .record_blocked(BlockedRequest::new(BlockedRequestArgs { host: host.clone(), - reason: REASON_METHOD_NOT_ALLOWED.to_string(), + reason: REASON_MITM_REQUIRED.to_string(), client: client.clone(), method: Some("CONNECT".to_string()), mode: Some(NetworkMode::Limited), protocol: "http-connect".to_string(), })) .await; + let details = PolicyDecisionDetails { + decision: NetworkPolicyDecision::Deny, + reason: REASON_MITM_REQUIRED, + source: NetworkDecisionSource::ModeGuard, + protocol: NetworkProtocol::HttpsConnect, + host: &host, + port: authority.port, + }; let client = client.as_deref().unwrap_or_default(); - warn!("CONNECT blocked by method policy (client={client}, host={host}, mode=limited)"); - return Err(blocked_text_with_details( - REASON_METHOD_NOT_ALLOWED, - &details, - )); + warn!( + "CONNECT blocked; MITM required for read-only HTTPS in limited mode (client={client}, host={host}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)" + ); + return Err(blocked_text_with_details(REASON_MITM_REQUIRED, &details)); } req.extensions_mut().insert(ProxyTarget(authority)); req.extensions_mut().insert(mode); + if let Some(mitm_state) = mitm_state { + req.extensions_mut().insert(mitm_state); + } Ok(( Response::builder() @@ -248,9 +262,34 @@ async fn http_connect_accept( } async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> { - if upgraded.extensions().get::().is_none() { + let mode = upgraded + .extensions() + .get::() + .copied() + .unwrap_or(NetworkMode::Full); + + let Some(target) = upgraded + .extensions() + .get::() + .map(|t| t.0.clone()) + else { warn!("CONNECT missing proxy target"); return Ok(()); + }; + + if mode == NetworkMode::Limited + && upgraded + .extensions() + .get::>() + .is_some() + { + let host = normalize_host(&target.host.to_string()); + let port = target.port; + info!("CONNECT MITM enabled (host={host}, port={port}, mode={mode:?})"); + if let Err(err) = mitm::mitm_tunnel(upgraded).await { + warn!("MITM tunnel error: {err}"); + } + return Ok(()); } let allow_upstream_proxy = match upgraded @@ -718,7 +757,7 @@ mod tests { assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!( response.headers().get("x-proxy-error").unwrap(), - "blocked-by-method-policy" + "blocked-by-mitm-required" ); } } diff --git a/codex-rs/network-proxy/src/init.rs b/codex-rs/network-proxy/src/init.rs new file mode 100644 index 0000000000..5d0c6ef9f7 --- /dev/null +++ b/codex-rs/network-proxy/src/init.rs @@ -0,0 +1,18 @@ +use anyhow::Context; +use anyhow::Result; +use codex_core::config::find_codex_home; +use std::fs; +use tracing::info; + +pub fn run_init() -> Result<()> { + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let root = codex_home.join("network_proxy"); + let mitm_dir = root.join("mitm"); + + fs::create_dir_all(&root).with_context(|| format!("failed to create {}", root.display()))?; + fs::create_dir_all(&mitm_dir) + .with_context(|| format!("failed to create {}", mitm_dir.display()))?; + + info!("ensured {}", mitm_dir.display()); + Ok(()) +} diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index a974ce13b7..9a6772c6c3 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -3,6 +3,7 @@ mod admin; mod config; mod http_proxy; +mod mitm; mod network_policy; mod policy; mod proxy; diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs new file mode 100644 index 0000000000..0c90d0c474 --- /dev/null +++ b/codex-rs/network-proxy/src/mitm.rs @@ -0,0 +1,611 @@ +use crate::config::MitmConfig; +use crate::config::NetworkMode; +use crate::policy::normalize_host; +use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::responses::blocked_text_response; +use crate::responses::text_response; +use crate::state::BlockedRequest; +use crate::state::NetworkProxyState; +use crate::upstream::UpstreamClient; +use anyhow::Context as _; +use anyhow::Result; +use anyhow::anyhow; +use rama_core::Layer; +use rama_core::Service; +use rama_core::bytes::Bytes; +use rama_core::error::BoxError; +use rama_core::extensions::ExtensionsRef; +use rama_core::futures::stream::Stream; +use rama_core::rt::Executor; +use rama_core::service::service_fn; +use rama_http::Body; +use rama_http::BodyDataStream; +use rama_http::HeaderValue; +use rama_http::Request; +use rama_http::Response; +use rama_http::StatusCode; +use rama_http::Uri; +use rama_http::header::HOST; +use rama_http::layer::remove_header::RemoveRequestHeaderLayer; +use rama_http::layer::remove_header::RemoveResponseHeaderLayer; +use rama_http_backend::server::HttpServer; +use rama_http_backend::server::layer::upgrade::Upgraded; +use rama_net::proxy::ProxyTarget; +use rama_net::stream::SocketInfo; +use rama_net::tls::ApplicationProtocol; +use rama_net::tls::DataEncoding; +use rama_net::tls::server::ServerAuth; +use rama_net::tls::server::ServerAuthData; +use rama_net::tls::server::ServerConfig; +use rama_tls_boring::server::TlsAcceptorData; +use rama_tls_boring::server::TlsAcceptorLayer; +use rama_utils::str::NonEmptyStr; +use std::fs; +use std::fs::File; +use std::fs::OpenOptions; +use std::io::Write; +use std::net::IpAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::task::Context as TaskContext; +use std::task::Poll; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; +use tracing::info; +use tracing::warn; + +use rcgen_rama::BasicConstraints; +use rcgen_rama::CertificateParams; +use rcgen_rama::DistinguishedName; +use rcgen_rama::DnType; +use rcgen_rama::ExtendedKeyUsagePurpose; +use rcgen_rama::IsCa; +use rcgen_rama::Issuer; +use rcgen_rama::KeyPair; +use rcgen_rama::KeyUsagePurpose; +use rcgen_rama::SanType; + +pub struct MitmState { + issuer: Issuer<'static, KeyPair>, + upstream: UpstreamClient, + inspect: bool, + max_body_bytes: usize, +} + +impl std::fmt::Debug for MitmState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Avoid dumping internal state (CA material, connectors, etc.) to logs. + f.debug_struct("MitmState") + .field("inspect", &self.inspect) + .field("max_body_bytes", &self.max_body_bytes) + .finish_non_exhaustive() + } +} + +impl MitmState { + pub fn new(cfg: &MitmConfig, allow_upstream_proxy: bool) -> Result { + // MITM exists to make limited-mode HTTPS enforceable: once CONNECT is established, plain + // proxying would lose visibility into the inner HTTP request. We generate/load a local CA + // and issue per-host leaf certs so we can terminate TLS and apply policy. + let (ca_cert_pem, ca_key_pem) = load_or_create_ca(cfg)?; + let ca_key = KeyPair::from_pem(&ca_key_pem).context("failed to parse CA key")?; + let issuer: Issuer<'static, KeyPair> = + Issuer::from_ca_cert_pem(&ca_cert_pem, ca_key).context("failed to parse CA cert")?; + + let upstream = if allow_upstream_proxy { + UpstreamClient::from_env_proxy() + } else { + UpstreamClient::direct() + }; + + Ok(Self { + issuer, + upstream, + inspect: cfg.inspect, + max_body_bytes: cfg.max_body_bytes, + }) + } + + fn tls_acceptor_data_for_host(&self, host: &str) -> Result { + let (cert_pem, key_pem) = issue_host_certificate_pem(host, &self.issuer)?; + let cert_chain = DataEncoding::Pem( + NonEmptyStr::try_from(cert_pem.as_str()).context("failed to encode host cert PEM")?, + ); + let private_key = DataEncoding::Pem( + NonEmptyStr::try_from(key_pem.as_str()).context("failed to encode host key PEM")?, + ); + let auth = ServerAuthData { + private_key, + cert_chain, + ocsp: None, + }; + + let mut server_config = ServerConfig::new(ServerAuth::Single(auth)); + server_config.application_layer_protocol_negotiation = Some(vec![ + ApplicationProtocol::HTTP_2, + ApplicationProtocol::HTTP_11, + ]); + + TlsAcceptorData::try_from(server_config).context("failed to build boring acceptor config") + } + + pub fn inspect_enabled(&self) -> bool { + self.inspect + } + + pub fn max_body_bytes(&self) -> usize { + self.max_body_bytes + } +} + +pub async fn mitm_tunnel(upgraded: Upgraded) -> Result<()> { + let state = upgraded + .extensions() + .get::>() + .cloned() + .context("missing MITM state")?; + let target = upgraded + .extensions() + .get::() + .context("missing proxy target")? + .0 + .clone(); + let host = normalize_host(&target.host.to_string()); + let acceptor_data = state.tls_acceptor_data_for_host(&host)?; + + let executor = upgraded + .extensions() + .get::() + .cloned() + .unwrap_or_default(); + + let http_service = HttpServer::auto(executor).service( + ( + RemoveResponseHeaderLayer::hop_by_hop(), + RemoveRequestHeaderLayer::hop_by_hop(), + ) + .into_layer(service_fn(handle_mitm_request)), + ); + + let https_service = TlsAcceptorLayer::new(acceptor_data) + .with_store_client_hello(true) + .into_layer(http_service); + + https_service + .serve(upgraded) + .await + .map_err(|err| anyhow!("MITM serve error: {err}"))?; + Ok(()) +} + +async fn handle_mitm_request(req: Request) -> Result { + let response = match forward_request(req).await { + Ok(resp) => resp, + Err(err) => { + warn!("MITM upstream request failed: {err}"); + text_response(StatusCode::BAD_GATEWAY, "mitm upstream error") + } + }; + Ok(response) +} + +async fn forward_request(req: Request) -> Result { + let target = req + .extensions() + .get::() + .context("missing proxy target")? + .0 + .clone(); + + let target_host = normalize_host(&target.host.to_string()); + let target_port = target.port; + let mode = req + .extensions() + .get::() + .copied() + .unwrap_or(NetworkMode::Full); + let mitm = req + .extensions() + .get::>() + .cloned() + .context("missing MITM state")?; + let app_state = req + .extensions() + .get::>() + .cloned() + .context("missing app state")?; + + if req.method().as_str() == "CONNECT" { + return Ok(text_response( + StatusCode::METHOD_NOT_ALLOWED, + "CONNECT not supported inside MITM", + )); + } + + let method = req.method().as_str().to_string(); + let path = path_and_query(req.uri()); + let client = req + .extensions() + .get::() + .map(|info| info.peer_addr().to_string()); + + if let Some(request_host) = extract_request_host(&req) { + let normalized = normalize_host(&request_host); + if !normalized.is_empty() && normalized != target_host { + warn!("MITM host mismatch (target={target_host}, request_host={normalized})"); + return Ok(text_response(StatusCode::BAD_REQUEST, "host mismatch")); + } + } + + if !mode.allows_method(&method) { + let _ = app_state + .record_blocked(BlockedRequest::new( + target_host.clone(), + REASON_METHOD_NOT_ALLOWED.to_string(), + client.clone(), + Some(method.clone()), + Some(mode), + "https".to_string(), + )) + .await; + warn!( + "MITM blocked by method policy (host={target_host}, method={method}, path={path}, mode={mode:?}, allowed_methods=GET, HEAD, OPTIONS)" + ); + return Ok(blocked_text_response(REASON_METHOD_NOT_ALLOWED)); + } + + let (mut parts, body) = req.into_parts(); + let authority = authority_header_value(&target_host, target_port); + parts.uri = build_https_uri(&authority, &path)?; + parts + .headers + .insert(HOST, HeaderValue::from_str(&authority)?); + + let inspect = mitm.inspect_enabled(); + let max_body_bytes = mitm.max_body_bytes(); + let body = if inspect { + inspect_body( + body, + max_body_bytes, + RequestLogContext { + host: authority.clone(), + method: method.clone(), + path: path.clone(), + }, + ) + } else { + body + }; + + let upstream_req = Request::from_parts(parts, body); + let upstream_resp = mitm.upstream.serve(upstream_req).await?; + respond_with_inspection( + upstream_resp, + inspect, + max_body_bytes, + &method, + &path, + &authority, + ) +} + +fn respond_with_inspection( + resp: Response, + inspect: bool, + max_body_bytes: usize, + method: &str, + path: &str, + authority: &str, +) -> Result { + if !inspect { + return Ok(resp); + } + + let (parts, body) = resp.into_parts(); + let body = inspect_body( + body, + max_body_bytes, + ResponseLogContext { + host: authority.to_string(), + method: method.to_string(), + path: path.to_string(), + status: parts.status, + }, + ); + Ok(Response::from_parts(parts, body)) +} + +fn inspect_body( + body: Body, + max_body_bytes: usize, + ctx: T, +) -> Body { + Body::from_stream(InspectStream { + inner: Box::pin(body.into_data_stream()), + ctx: Some(Box::new(ctx)), + len: 0, + max_body_bytes, + }) +} + +struct InspectStream { + inner: Pin>, + ctx: Option>, + len: usize, + max_body_bytes: usize, +} + +impl Stream for InspectStream { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + let this = self.get_mut(); + match this.inner.as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(bytes))) => { + this.len = this.len.saturating_add(bytes.len()); + Poll::Ready(Some(Ok(bytes))) + } + Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))), + Poll::Ready(None) => { + if let Some(ctx) = this.ctx.take() { + ctx.log(this.len, this.len > this.max_body_bytes); + } + Poll::Ready(None) + } + Poll::Pending => Poll::Pending, + } + } +} + +struct RequestLogContext { + host: String, + method: String, + path: String, +} + +struct ResponseLogContext { + host: String, + method: String, + path: String, + status: StatusCode, +} + +trait BodyLoggable { + fn log(self, len: usize, truncated: bool); +} + +impl BodyLoggable for RequestLogContext { + fn log(self, len: usize, truncated: bool) { + let host = self.host; + let method = self.method; + let path = self.path; + info!( + "MITM inspected request body (host={host}, method={method}, path={path}, body_len={len}, truncated={truncated})" + ); + } +} + +impl BodyLoggable for ResponseLogContext { + fn log(self, len: usize, truncated: bool) { + let host = self.host; + let method = self.method; + let path = self.path; + let status = self.status; + info!( + "MITM inspected response body (host={host}, method={method}, path={path}, status={status}, body_len={len}, truncated={truncated})" + ); + } +} + +fn extract_request_host(req: &Request) -> Option { + req.headers() + .get(HOST) + .and_then(|v| v.to_str().ok()) + .map(ToString::to_string) + .or_else(|| req.uri().authority().map(|a| a.as_str().to_string())) +} + +fn authority_header_value(host: &str, port: u16) -> String { + // Host header / URI authority formatting. + if host.contains(':') { + if port == 443 { + format!("[{host}]") + } else { + format!("[{host}]:{port}") + } + } else if port == 443 { + host.to_string() + } else { + format!("{host}:{port}") + } +} + +fn build_https_uri(authority: &str, path: &str) -> Result { + let target = format!("https://{authority}{path}"); + Ok(target.parse()?) +} + +fn path_and_query(uri: &Uri) -> String { + uri.path_and_query() + .map(rama_http::uri::PathAndQuery::as_str) + .unwrap_or("/") + .to_string() +} + +fn issue_host_certificate_pem( + host: &str, + issuer: &Issuer<'_, KeyPair>, +) -> Result<(String, String)> { + let mut params = if let Ok(ip) = host.parse::() { + let mut params = CertificateParams::new(Vec::new()) + .map_err(|err| anyhow!("failed to create cert params: {err}"))?; + params.subject_alt_names.push(SanType::IpAddress(ip)); + params + } else { + CertificateParams::new(vec![host.to_string()]) + .map_err(|err| anyhow!("failed to create cert params: {err}"))? + }; + + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + + let key_pair = KeyPair::generate_for(&rcgen_rama::PKCS_ECDSA_P256_SHA256) + .map_err(|err| anyhow!("failed to generate host key pair: {err}"))?; + let cert = params + .signed_by(&key_pair, issuer) + .map_err(|err| anyhow!("failed to sign host cert: {err}"))?; + + Ok((cert.pem(), key_pair.serialize_pem())) +} + +fn load_or_create_ca(cfg: &MitmConfig) -> Result<(String, String)> { + let cert_path = &cfg.ca_cert_path; + let key_path = &cfg.ca_key_path; + + if cert_path.exists() || key_path.exists() { + if !cert_path.exists() || !key_path.exists() { + return Err(anyhow!("both ca_cert_path and ca_key_path must exist")); + } + let cert_pem = fs::read_to_string(cert_path) + .with_context(|| format!("failed to read CA cert {}", cert_path.display()))?; + let key_pem = fs::read_to_string(key_path) + .with_context(|| format!("failed to read CA key {}", key_path.display()))?; + return Ok((cert_pem, key_pem)); + } + + if let Some(parent) = cert_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + if let Some(parent) = key_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + + let (cert_pem, key_pem) = generate_ca()?; + // The CA key is a high-value secret. Create it atomically with restrictive permissions. + // The cert can be world-readable, but we still write it atomically to avoid partial writes. + // + // We intentionally use create-new semantics: if a key already exists, we should not overwrite + // it silently (that would invalidate previously-trusted cert chains). + write_atomic_create_new(key_path, key_pem.as_bytes(), 0o600) + .with_context(|| format!("failed to persist CA key {}", key_path.display()))?; + if let Err(err) = write_atomic_create_new(cert_path, cert_pem.as_bytes(), 0o644) + .with_context(|| format!("failed to persist CA cert {}", cert_path.display())) + { + // Avoid leaving a partially-created CA around (cert missing) if the second write fails. + let _ = fs::remove_file(key_path); + return Err(err); + } + let cert_path = cert_path.display(); + let key_path = key_path.display(); + info!("generated MITM CA (cert_path={cert_path}, key_path={key_path})"); + Ok((cert_pem, key_pem)) +} + +fn generate_ca() -> Result<(String, String)> { + let mut params = CertificateParams::default(); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "network_proxy MITM CA"); + params.distinguished_name = dn; + + let key_pair = KeyPair::generate_for(&rcgen_rama::PKCS_ECDSA_P256_SHA256) + .map_err(|err| anyhow!("failed to generate CA key pair: {err}"))?; + let cert = params + .self_signed(&key_pair) + .map_err(|err| anyhow!("failed to generate CA cert: {err}"))?; + Ok((cert.pem(), key_pair.serialize_pem())) +} + +fn write_atomic_create_new(path: &std::path::Path, contents: &[u8], mode: u32) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("missing parent directory"))?; + + 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); + + // Create the final file using "create-new" semantics (no overwrite). `rename` on Unix can + // overwrite existing files, so prefer a hard-link, which fails if the destination exists. + match fs::hard_link(&tmp_path, path) { + Ok(()) => { + fs::remove_file(&tmp_path) + .with_context(|| format!("failed to remove {}", tmp_path.display()))?; + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + let _ = fs::remove_file(&tmp_path); + return Err(anyhow!( + "refusing to overwrite existing file {}", + path.display() + )); + } + Err(_) => { + // Best-effort fallback for environments where hard links are not supported. + // This is still subject to a TOCTOU race, but the typical case is a private per-user + // config directory, where other users cannot create files anyway. + if path.exists() { + let _ = fs::remove_file(&tmp_path); + return Err(anyhow!( + "refusing to overwrite existing file {}", + path.display() + )); + } + fs::rename(&tmp_path, path).with_context(|| { + format!( + "failed to rename {} -> {}", + tmp_path.display(), + path.display() + ) + })?; + } + } + + // Best-effort durability: ensure the directory entry is persisted too. + 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 open_create_new_with_mode(path: &std::path::Path, mode: u32) -> Result { + use std::os::unix::fs::OpenOptionsExt; + + OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .open(path) + .with_context(|| format!("failed to create {}", path.display())) +} + +#[cfg(not(unix))] +fn open_create_new_with_mode(path: &std::path::Path, _mode: u32) -> Result { + OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("failed to create {}", path.display())) +} diff --git a/codex-rs/network-proxy/src/reasons.rs b/codex-rs/network-proxy/src/reasons.rs index 99f7f742d0..cb31db36a4 100644 --- a/codex-rs/network-proxy/src/reasons.rs +++ b/codex-rs/network-proxy/src/reasons.rs @@ -1,5 +1,6 @@ pub(crate) const REASON_DENIED: &str = "denied"; pub(crate) const REASON_METHOD_NOT_ALLOWED: &str = "method_not_allowed"; +pub(crate) const REASON_MITM_REQUIRED: &str = "mitm_required"; pub(crate) const REASON_NOT_ALLOWED: &str = "not_allowed"; pub(crate) const REASON_NOT_ALLOWED_LOCAL: &str = "not_allowed_local"; pub(crate) const REASON_POLICY_DENIED: &str = "policy_denied"; diff --git a/codex-rs/network-proxy/src/responses.rs b/codex-rs/network-proxy/src/responses.rs index 354d4bbaf2..29ae7d0d1a 100644 --- a/codex-rs/network-proxy/src/responses.rs +++ b/codex-rs/network-proxy/src/responses.rs @@ -3,6 +3,7 @@ use crate::network_policy::NetworkPolicyDecision; use crate::network_policy::NetworkProtocol; use crate::reasons::REASON_DENIED; use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_MITM_REQUIRED; use crate::reasons::REASON_NOT_ALLOWED; use crate::reasons::REASON_NOT_ALLOWED_LOCAL; use rama_http::Body; @@ -64,6 +65,7 @@ pub fn blocked_header_value(reason: &str) -> &'static str { REASON_NOT_ALLOWED | REASON_NOT_ALLOWED_LOCAL => "blocked-by-allowlist", REASON_DENIED => "blocked-by-denylist", REASON_METHOD_NOT_ALLOWED => "blocked-by-method-policy", + REASON_MITM_REQUIRED => "blocked-by-mitm-required", _ => "blocked-by-policy", } } @@ -78,6 +80,7 @@ pub fn blocked_message(reason: &str) -> &'static str { REASON_METHOD_NOT_ALLOWED => { "Codex blocked this request: method not allowed in limited mode." } + REASON_MITM_REQUIRED => "Codex blocked this request: MITM required for limited HTTPS.", _ => "Codex blocked this request by network policy.", } } diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index a1ab347575..1d4a7e6c90 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -1,5 +1,6 @@ use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; +use crate::mitm::MitmState; use crate::policy::Host; use crate::policy::is_loopback_host; use crate::policy::is_non_public_ip; @@ -111,6 +112,7 @@ pub struct ConfigState { pub config: NetworkProxyConfig, pub allow_set: GlobSet, pub deny_set: GlobSet, + pub mitm: Option>, pub constraints: NetworkProxyConstraints, pub cfg_path: PathBuf, pub blocked: VecDeque, @@ -373,6 +375,12 @@ impl NetworkProxyState { } } + pub async fn mitm_state(&self) -> Result>> { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.mitm.clone()) + } + async fn reload_if_needed(&self) -> Result<()> { match self.reloader.maybe_reload().await? { None => Ok(()), diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 99009e76a4..015b918480 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -1,11 +1,14 @@ use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; +use crate::mitm::MitmState; use crate::policy::DomainPattern; use crate::policy::compile_globset; use crate::runtime::ConfigState; use serde::Deserialize; use std::collections::HashSet; +use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; pub use crate::runtime::BlockedRequest; pub use crate::runtime::BlockedRequestArgs; @@ -50,22 +53,45 @@ pub struct PartialNetworkConfig { } pub fn build_config_state( - config: NetworkProxyConfig, + mut config: NetworkProxyConfig, constraints: NetworkProxyConstraints, cfg_path: PathBuf, ) -> anyhow::Result { + resolve_mitm_paths(&mut config, &cfg_path); let deny_set = compile_globset(&config.network.denied_domains)?; let allow_set = compile_globset(&config.network.allowed_domains)?; + let mitm = if config.network.mitm.enabled { + Some(Arc::new(MitmState::new( + &config.network.mitm, + config.network.allow_upstream_proxy, + )?)) + } else { + None + }; Ok(ConfigState { config, allow_set, deny_set, + mitm, constraints, cfg_path, blocked: std::collections::VecDeque::new(), }) } +fn resolve_mitm_paths(config: &mut NetworkProxyConfig, cfg_path: &Path) { + let mitm = &mut config.network.mitm; + let Some(config_dir) = cfg_path.parent() else { + return; + }; + if mitm.ca_cert_path.is_relative() { + mitm.ca_cert_path = config_dir.join(&mitm.ca_cert_path); + } + if mitm.ca_key_path.is_relative() { + mitm.ca_key_path = config_dir.join(&mitm.ca_key_path); + } +} + pub fn validate_policy_against_constraints( config: &NetworkProxyConfig, constraints: &NetworkProxyConstraints,