diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index fd42fc92e04a..f20c01b906fa 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -80,6 +80,9 @@ use tracing::error; use tracing::info; use tracing::warn; +#[derive(Clone, Copy, Debug)] +struct ConnectMitmEnabled(bool); + pub async fn run_http_proxy( state: Arc, addr: SocketAddr, @@ -256,10 +259,18 @@ async fn http_connect_accept( return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); } }; + let host_has_mitm_hooks = match app_state.host_has_mitm_hooks(&host).await { + Ok(has_hooks) => has_hooks, + Err(err) => { + error!("failed to inspect MITM hooks for {host}: {err}"); + return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); + } + }; + let connect_needs_mitm = mode == NetworkMode::Limited || host_has_mitm_hooks; - 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. + if connect_needs_mitm && mitm_state.is_none() { + // CONNECT needs MITM whenever HTTPS policy depends on inner-request inspection, either for + // limited-mode method enforcement or for host-specific MITM hooks. emit_http_block_decision_audit_event( &app_state, BlockDecisionAuditEventArgs { @@ -286,7 +297,7 @@ async fn http_connect_accept( reason: REASON_MITM_REQUIRED.to_string(), client: client.clone(), method: Some("CONNECT".to_string()), - mode: Some(NetworkMode::Limited), + mode: Some(mode), protocol: "http-connect".to_string(), decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), @@ -295,14 +306,16 @@ async fn http_connect_accept( .await; let client = client.as_deref().unwrap_or_default(); warn!( - "CONNECT blocked; MITM required for read-only HTTPS in limited mode (client={client}, host={host}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)" + "CONNECT blocked; MITM required to enforce HTTPS policy (client={client}, host={host}, mode={mode:?}, hooked_host={host_has_mitm_hooks})" ); return Err(blocked_text_with_details(REASON_MITM_REQUIRED, &details)); } req.extensions_mut().insert(ProxyTarget(authority)); + req.extensions_mut() + .insert(ConnectMitmEnabled(connect_needs_mitm)); req.extensions_mut().insert(mode); - if let Some(mitm_state) = mitm_state { + if connect_needs_mitm && let Some(mitm_state) = mitm_state { req.extensions_mut().insert(mitm_state); } @@ -331,7 +344,10 @@ async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> { return Ok(()); }; - if mode == NetworkMode::Limited + if upgraded + .extensions() + .get::() + .is_some_and(|enabled| enabled.0) && upgraded .extensions() .get::>() @@ -1094,6 +1110,42 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + #[tokio::test] + async fn http_connect_accept_blocks_hooked_host_in_full_mode_without_mitm_state() { + let mut policy = NetworkProxySettings { + mitm: true, + mitm_hooks: vec![crate::mitm_hook::MitmHookConfig { + host: "api.github.com".to_string(), + matcher: crate::mitm_hook::MitmHookMatchConfig { + methods: vec!["POST".to_string()], + path_prefixes: vec!["/repos/openai/".to_string()], + ..crate::mitm_hook::MitmHookMatchConfig::default() + }, + actions: crate::mitm_hook::MitmHookActionsConfig::default(), + }], + ..Default::default() + }; + policy.set_allowed_domains(vec!["api.github.com".to_string()]); + let state = Arc::new(network_proxy_state_for_policy(policy)); + + let mut req = Request::builder() + .method(Method::CONNECT) + .uri("https://api.github.com:443") + .header("host", "api.github.com:443") + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(state); + + let response = http_connect_accept(/*policy_decider*/ None, req) + .await + .unwrap_err(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response.headers().get("x-proxy-error").unwrap(), + "blocked-by-mitm-required" + ); + } + #[tokio::test] async fn http_proxy_listener_accepts_plain_http1_connect_requests() { let target_listener = TokioTcpListener::bind((Ipv4Addr::LOCALHOST, 0)) diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index 7be700b1dffe..fdcb9d0aa235 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -1,7 +1,10 @@ use crate::certs::ManagedMitmCa; use crate::config::NetworkMode; +use crate::mitm_hook::HookEvaluation; +use crate::mitm_hook::MitmHookActions; use crate::policy::normalize_host; use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_MITM_HOOK_DENIED; use crate::responses::blocked_text_response; use crate::responses::text_response; use crate::runtime::HostBlockDecision; @@ -23,6 +26,7 @@ use rama_core::rt::Executor; use rama_core::service::service_fn; use rama_http::Body; use rama_http::BodyDataStream; +use rama_http::HeaderMap; use rama_http::HeaderValue; use rama_http::Request; use rama_http::Response; @@ -71,6 +75,13 @@ struct MitmRequestContext { mitm: Arc, } +enum MitmPolicyDecision { + Allow { + hook_actions: Option, + }, + Block(Response), +} + const MITM_INSPECT_BODIES: bool = false; const MITM_MAX_BODY_BYTES: usize = 4096; @@ -86,9 +97,10 @@ impl std::fmt::Debug for MitmState { impl MitmState { pub(crate) fn new(config: MitmUpstreamConfig) -> 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. + // MITM exists when HTTPS policy depends on the inner request: limited-mode method clamps + // and host-specific hooks both need visibility after CONNECT is established. We + // generate/load a local CA and issue per-host leaf certs so we can terminate TLS and + // apply policy. let ca = ManagedMitmCa::load_or_create()?; let upstream = if config.allow_upstream_proxy { @@ -200,9 +212,10 @@ async fn handle_mitm_request( } async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Result { - if let Some(response) = mitm_blocking_response(&req, &request_ctx.policy).await? { - return Ok(response); - } + let hook_actions = match evaluate_mitm_policy(&req, &request_ctx.policy).await? { + MitmPolicyDecision::Allow { hook_actions } => hook_actions, + MitmPolicyDecision::Block(response) => return Ok(response), + }; let target_host = request_ctx.policy.target_host.clone(); let target_port = request_ctx.policy.target_port; @@ -213,6 +226,7 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu let log_path = path_for_log(req.uri()); let (mut parts, body) = req.into_parts(); + apply_mitm_hook_actions(&mut parts.headers, hook_actions.as_ref()); let authority = authority_header_value(&target_host, target_port); parts.uri = build_https_uri(&authority, &path)?; parts @@ -247,12 +261,23 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu ) } +#[cfg_attr(not(test), allow(dead_code))] async fn mitm_blocking_response( req: &Request, policy: &MitmPolicyContext, ) -> Result> { + match evaluate_mitm_policy(req, policy).await? { + MitmPolicyDecision::Allow { .. } => Ok(None), + MitmPolicyDecision::Block(response) => Ok(Some(response)), + } +} + +async fn evaluate_mitm_policy( + req: &Request, + policy: &MitmPolicyContext, +) -> Result { if req.method().as_str() == "CONNECT" { - return Ok(Some(text_response( + return Ok(MitmPolicyDecision::Block(text_response( StatusCode::METHOD_NOT_ALLOWED, "CONNECT not supported inside MITM", ))); @@ -272,7 +297,7 @@ async fn mitm_blocking_response( "MITM host mismatch (target={}, request_host={normalized})", policy.target_host ); - return Ok(Some(text_response( + return Ok(MitmPolicyDecision::Block(text_response( StatusCode::BAD_REQUEST, "host mismatch", ))); @@ -307,9 +332,41 @@ async fn mitm_blocking_response( "MITM blocked local/private target after CONNECT (host={}, port={}, method={method}, path={log_path})", policy.target_host, policy.target_port ); - return Ok(Some(blocked_text_response(reason))); + return Ok(MitmPolicyDecision::Block(blocked_text_response(reason))); } + let hook_actions = match policy + .app_state + .evaluate_mitm_hook_request(&policy.target_host, req) + .await? + { + HookEvaluation::Matched { actions } => Some(actions), + HookEvaluation::HookedHostNoMatch => { + let _ = policy + .app_state + .record_blocked(BlockedRequest::new(BlockedRequestArgs { + host: policy.target_host.clone(), + reason: REASON_MITM_HOOK_DENIED.to_string(), + client: client.clone(), + method: Some(method.clone()), + mode: Some(policy.mode), + protocol: "https".to_string(), + decision: None, + source: None, + port: Some(policy.target_port), + })) + .await; + warn!( + "MITM blocked by hook policy (host={}, method={method}, mode={:?})", + policy.target_host, policy.mode + ); + return Ok(MitmPolicyDecision::Block(blocked_text_response( + REASON_MITM_HOOK_DENIED, + ))); + } + HookEvaluation::NoHooksForHost => None, + }; + if !policy.mode.allows_method(&method) { let _ = policy .app_state @@ -329,10 +386,25 @@ async fn mitm_blocking_response( "MITM blocked by method policy (host={}, method={method}, path={log_path}, mode={:?}, allowed_methods=GET, HEAD, OPTIONS)", policy.target_host, policy.mode ); - return Ok(Some(blocked_text_response(REASON_METHOD_NOT_ALLOWED))); + return Ok(MitmPolicyDecision::Block(blocked_text_response( + REASON_METHOD_NOT_ALLOWED, + ))); } - Ok(None) + Ok(MitmPolicyDecision::Allow { hook_actions }) +} + +fn apply_mitm_hook_actions(headers: &mut HeaderMap, actions: Option<&MitmHookActions>) { + let Some(actions) = actions else { + return; + }; + + for header_name in &actions.strip_request_headers { + headers.remove(header_name); + } + for injected_header in &actions.inject_request_headers { + headers.insert(injected_header.name.clone(), injected_header.value.clone()); + } } fn respond_with_inspection( diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index 862372075227..098eb745b1a9 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -2,13 +2,39 @@ use super::*; use crate::config::NetworkProxySettings; use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_MITM_HOOK_DENIED; use crate::reasons::REASON_NOT_ALLOWED_LOCAL; use crate::runtime::network_proxy_state_for_policy; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use rama_http::Body; +use rama_http::HeaderMap; +use rama_http::HeaderValue; use rama_http::Method; use rama_http::Request; use rama_http::StatusCode; +use rama_http::header::HeaderName; +use tempfile::NamedTempFile; + +fn github_write_hook() -> crate::mitm_hook::MitmHookConfig { + crate::mitm_hook::MitmHookConfig { + host: "api.github.com".to_string(), + matcher: crate::mitm_hook::MitmHookMatchConfig { + methods: vec!["POST".to_string(), "PUT".to_string()], + path_prefixes: vec!["/repos/openai/".to_string()], + ..crate::mitm_hook::MitmHookMatchConfig::default() + }, + actions: crate::mitm_hook::MitmHookActionsConfig { + strip_request_headers: vec!["authorization".to_string()], + inject_request_headers: vec![crate::mitm_hook::InjectedHeaderConfig { + name: "authorization".to_string(), + secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()), + secret_file: None, + prefix: Some("Bearer ".to_string()), + }], + }, + } +} fn policy_ctx( app_state: Arc, @@ -126,3 +152,169 @@ async fn mitm_policy_rechecks_local_private_target_after_connect() { assert_eq!(blocked[0].host, "10.0.0.1"); assert_eq!(blocked[0].port, Some(443)); } + +#[tokio::test] +async fn mitm_policy_allows_matching_hooked_write_in_full_mode() { + let secret_file = NamedTempFile::new().unwrap(); + std::fs::write(secret_file.path(), "ghp-secret\n").unwrap(); + let mut hook = github_write_hook(); + hook.actions.inject_request_headers[0].secret_env_var = None; + hook.actions.inject_request_headers[0].secret_file = + Some(secret_file.path().display().to_string()); + let mut network = NetworkProxySettings { + mitm: true, + mitm_hooks: vec![hook], + mode: NetworkMode::Full, + ..NetworkProxySettings::default() + }; + network.set_allowed_domains(vec!["api.github.com".to_string()]); + let app_state = Arc::new(network_proxy_state_for_policy(network)); + let ctx = policy_ctx( + app_state.clone(), + NetworkMode::Full, + "api.github.com", + /*target_port*/ 443, + ); + let req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues") + .header(HOST, "api.github.com") + .body(Body::empty()) + .unwrap(); + + let response = mitm_blocking_response(&req, &ctx).await.unwrap(); + + assert!( + response.is_none(), + "matching hook should be allowed in full mode" + ); + assert_eq!(app_state.blocked_snapshot().await.unwrap().len(), 0); +} + +#[tokio::test] +async fn mitm_policy_blocks_matching_hooked_write_in_limited_mode() { + let mut hook = github_write_hook(); + hook.actions.inject_request_headers.clear(); + let mut network = NetworkProxySettings { + mitm: true, + mitm_hooks: vec![hook], + mode: NetworkMode::Limited, + ..NetworkProxySettings::default() + }; + network.set_allowed_domains(vec!["api.github.com".to_string()]); + let app_state = Arc::new(network_proxy_state_for_policy(network)); + let ctx = policy_ctx( + app_state.clone(), + NetworkMode::Limited, + "api.github.com", + /*target_port*/ 443, + ); + let req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues") + .header(HOST, "api.github.com") + .body(Body::empty()) + .unwrap(); + + let response = mitm_blocking_response(&req, &ctx) + .await + .unwrap() + .expect("matching POST hook should still be blocked in limited mode"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response.headers().get("x-proxy-error").unwrap(), + "blocked-by-method-policy" + ); + + let blocked = app_state.drain_blocked().await.unwrap(); + assert_eq!(blocked.len(), 1); + assert_eq!(blocked[0].reason, REASON_METHOD_NOT_ALLOWED); + assert_eq!(blocked[0].method.as_deref(), Some("POST")); + assert_eq!(blocked[0].host, "api.github.com"); + assert_eq!(blocked[0].port, Some(443)); +} + +#[tokio::test] +async fn mitm_policy_blocks_hook_miss_for_hooked_host_and_records_telemetry_in_full_mode() { + let secret_file = NamedTempFile::new().unwrap(); + std::fs::write(secret_file.path(), "ghp-secret\n").unwrap(); + let mut hook = github_write_hook(); + hook.actions.inject_request_headers[0].secret_env_var = None; + hook.actions.inject_request_headers[0].secret_file = + Some(secret_file.path().display().to_string()); + let mut network = NetworkProxySettings { + mitm: true, + mitm_hooks: vec![hook], + mode: NetworkMode::Full, + ..NetworkProxySettings::default() + }; + network.set_allowed_domains(vec!["api.github.com".to_string()]); + let app_state = Arc::new(network_proxy_state_for_policy(network)); + let ctx = policy_ctx( + app_state.clone(), + NetworkMode::Full, + "api.github.com", + /*target_port*/ 443, + ); + let req = Request::builder() + .method(Method::GET) + .uri("/repos/openai/codex/issues?token=secret") + .header(HOST, "api.github.com") + .header("authorization", "Bearer user-supplied") + .body(Body::empty()) + .unwrap(); + + let response = mitm_blocking_response(&req, &ctx) + .await + .unwrap() + .expect("hook miss should be blocked"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response.headers().get("x-proxy-error").unwrap(), + "blocked-by-mitm-hook" + ); + + let blocked = app_state.drain_blocked().await.unwrap(); + assert_eq!(blocked.len(), 1); + assert_eq!(blocked[0].reason, REASON_MITM_HOOK_DENIED); + assert_eq!(blocked[0].method.as_deref(), Some("GET")); + assert_eq!(blocked[0].host, "api.github.com"); + assert_eq!(blocked[0].port, Some(443)); +} + +#[test] +fn apply_mitm_hook_actions_replaces_authorization_header() { + let mut headers = HeaderMap::new(); + headers.append( + HeaderName::from_static("authorization"), + HeaderValue::from_static("Bearer user-supplied"), + ); + headers.append( + HeaderName::from_static("x-request-id"), + HeaderValue::from_static("req_123"), + ); + + let actions = crate::mitm_hook::MitmHookActions { + strip_request_headers: vec![HeaderName::from_static("authorization")], + inject_request_headers: vec![crate::mitm_hook::ResolvedInjectedHeader { + name: HeaderName::from_static("authorization"), + value: HeaderValue::from_static("Bearer secret-token"), + source: crate::mitm_hook::SecretSource::File( + AbsolutePathBuf::try_from("/tmp/github-token").unwrap(), + ), + }], + }; + + apply_mitm_hook_actions(&mut headers, Some(&actions)); + + assert_eq!( + headers.get("authorization"), + Some(&HeaderValue::from_static("Bearer secret-token")) + ); + assert_eq!( + headers.get("x-request-id"), + Some(&HeaderValue::from_static("req_123")) + ); +} diff --git a/codex-rs/network-proxy/src/reasons.rs b/codex-rs/network-proxy/src/reasons.rs index b844eadf3d1e..67f570f12814 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_HOOK_DENIED: &str = "mitm_hook_denied"; 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"; diff --git a/codex-rs/network-proxy/src/responses.rs b/codex-rs/network-proxy/src/responses.rs index c0418c72b582..d2aeb990e8f9 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_HOOK_DENIED; use crate::reasons::REASON_MITM_REQUIRED; use crate::reasons::REASON_NOT_ALLOWED; use crate::reasons::REASON_NOT_ALLOWED_LOCAL; @@ -53,6 +54,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_HOOK_DENIED => "blocked-by-mitm-hook", REASON_MITM_REQUIRED => "blocked-by-mitm-required", _ => "blocked-by-policy", } @@ -64,6 +66,7 @@ pub fn blocked_message(reason: &str) -> &'static str { REASON_NOT_ALLOWED_LOCAL => "Sandbox policy blocks local/private network addresses.", REASON_DENIED => "Domain denied by the sandbox policy.", REASON_METHOD_NOT_ALLOWED => "Method not allowed in limited mode.", + REASON_MITM_HOOK_DENIED => "HTTPS request denied by MITM hook policy.", REASON_MITM_REQUIRED => "MITM required for limited HTTPS.", REASON_PROXY_DISABLED => "network proxy is disabled", _ => "Request blocked by network policy.", diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 11e3804baf20..60894d5d5b22 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -3,6 +3,9 @@ use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; use crate::config::ValidatedUnixSocketPath; use crate::mitm::MitmState; +use crate::mitm_hook::HookEvaluation; +use crate::mitm_hook::MitmHooksByHost; +use crate::mitm_hook::evaluate_mitm_hooks; use crate::policy::Host; use crate::policy::is_loopback_host; use crate::policy::is_non_public_ip; @@ -159,6 +162,7 @@ pub struct ConfigState { pub allow_set: GlobSet, pub deny_set: GlobSet, pub mitm: Option>, + pub mitm_hooks: MitmHooksByHost, pub constraints: NetworkProxyConstraints, pub blocked: VecDeque, pub blocked_total: u64, @@ -585,6 +589,22 @@ impl NetworkProxyState { Ok(guard.mitm.clone()) } + pub(crate) async fn evaluate_mitm_hook_request( + &self, + host: &str, + req: &rama_http::Request, + ) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(evaluate_mitm_hooks(&guard.mitm_hooks, host, req)) + } + + pub async fn host_has_mitm_hooks(&self, host: &str) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.mitm_hooks.contains_key(&normalize_host(host))) + } + pub async fn add_allowed_domain(&self, host: &str) -> Result<()> { self.update_domain_list(host, DomainListKind::Allow).await } @@ -846,9 +866,23 @@ pub(crate) fn network_proxy_state_for_policy( mut network: crate::config::NetworkProxySettings, ) -> NetworkProxyState { network.enabled = true; - network.mode = NetworkMode::Full; let config = NetworkProxyConfig { network }; - let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap(); + let state = ConfigState { + allow_set: crate::policy::compile_allowlist_globset( + &config.network.allowed_domains().unwrap_or_default(), + ) + .unwrap(), + blocked: VecDeque::new(), + blocked_total: 0, + config: config.clone(), + constraints: NetworkProxyConstraints::default(), + deny_set: crate::policy::compile_denylist_globset( + &config.network.denied_domains().unwrap_or_default(), + ) + .unwrap(), + mitm: None, + mitm_hooks: crate::mitm_hook::compile_mitm_hooks(&config).unwrap(), + }; NetworkProxyState::with_reloader(state, Arc::new(NoopReloader)) } diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index a1c430c7db8e..8369c4da80d5 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -12,6 +12,7 @@ use crate::network_policy::emit_block_decision_audit_event; 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_PROXY_DISABLED; use crate::responses::PolicyDecisionDetails; use crate::responses::blocked_message_with_policy; @@ -240,6 +241,51 @@ async fn handle_socks5_tcp( } } + match app_state.host_has_mitm_hooks(&host).await { + Ok(true) => { + emit_socks_block_decision_audit_event( + &app_state, + NetworkDecisionSource::ModeGuard, + REASON_MITM_REQUIRED, + NetworkProtocol::Socks5Tcp, + host.as_str(), + port, + client.as_deref(), + ); + let details = PolicyDecisionDetails { + decision: NetworkPolicyDecision::Deny, + reason: REASON_MITM_REQUIRED, + source: NetworkDecisionSource::ModeGuard, + protocol: NetworkProtocol::Socks5Tcp, + host: &host, + port, + }; + let _ = app_state + .record_blocked(BlockedRequest::new(BlockedRequestArgs { + host: host.clone(), + reason: REASON_MITM_REQUIRED.to_string(), + client: client.clone(), + method: None, + mode: Some(NetworkMode::Full), + protocol: "socks5".to_string(), + decision: Some(details.decision.as_str().to_string()), + source: Some(details.source.as_str().to_string()), + port: Some(port), + })) + .await; + let client = client.as_deref().unwrap_or_default(); + warn!( + "SOCKS blocked; MITM required to enforce HTTPS policy (client={client}, host={host}, mode=full)" + ); + return Err(policy_denied_error(REASON_MITM_REQUIRED, &details).into()); + } + Ok(false) => {} + Err(err) => { + error!("failed to inspect MITM hooks for {host}: {err}"); + return Err(io::Error::other("proxy error").into()); + } + } + let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { protocol: NetworkProtocol::Socks5Tcp, host: host.clone(), @@ -501,11 +547,14 @@ mod tests { use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; use crate::config::NetworkProxySettings; + use crate::mitm_hook::MitmHookConfig; + use crate::mitm_hook::MitmHookMatchConfig; use crate::network_policy::test_support::POLICY_DECISION_EVENT_NAME; use crate::network_policy::test_support::capture_events; use crate::network_policy::test_support::find_event_by_name; use crate::runtime::ConfigReloader; use crate::runtime::ConfigState; + use crate::runtime::network_proxy_state_for_policy; use crate::state::NetworkProxyConstraints; use crate::state::build_config_state; use async_trait::async_trait; @@ -589,6 +638,64 @@ mod tests { assert_eq!(event.field("client.address"), Some("unknown")); } + #[tokio::test(flavor = "current_thread")] + async fn handle_socks5_tcp_blocks_hooked_host_in_full_mode() { + let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { + enabled: true, + mode: NetworkMode::Full, + mitm: true, + mitm_hooks: vec![MitmHookConfig { + host: "api.github.com".to_string(), + matcher: MitmHookMatchConfig { + methods: vec!["GET".to_string()], + path_prefixes: vec!["/".to_string()], + ..MitmHookMatchConfig::default() + }, + ..MitmHookConfig::default() + }], + ..NetworkProxySettings::default() + })); + let mut request = + TcpRequest::new(HostWithPort::try_from("api.github.com:443").expect("valid authority")); + request.extensions_mut().insert(state.clone()); + + let (result, events) = capture_events(|| async { + handle_socks5_tcp( + request, + TargetCheckedTcpConnector::new(state.clone()), + /*policy_decider*/ None, + ) + .await + }) + .await; + assert!(result.is_err(), "hooked host should require MITM"); + + let blocked = state.drain_blocked().await.unwrap(); + assert_eq!(blocked.len(), 1); + assert_eq!(blocked[0].reason, REASON_MITM_REQUIRED); + assert_eq!(blocked[0].host, "api.github.com"); + assert_eq!(blocked[0].port, Some(443)); + assert_eq!(blocked[0].protocol, "socks5"); + + let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME) + .expect("expected policy decision event"); + assert_eq!(event.field("network.policy.scope"), Some("non_domain")); + assert_eq!(event.field("network.policy.decision"), Some("deny")); + assert_eq!(event.field("network.policy.source"), Some("mode_guard")); + assert_eq!( + event.field("network.policy.reason"), + Some(REASON_MITM_REQUIRED) + ); + assert_eq!( + event.field("network.transport.protocol"), + Some("socks5_tcp") + ); + assert_eq!(event.field("server.address"), Some("api.github.com")); + assert_eq!(event.field("server.port"), Some("443")); + assert_eq!(event.field("http.request.method"), Some("none")); + assert_eq!(event.field("client.address"), Some("unknown")); + } + #[tokio::test(flavor = "current_thread")] async fn inspect_socks5_udp_emits_block_decision_for_mode_guard_deny() { let state = state_for_settings(NetworkProxySettings { diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 67a10d3bf56a..32cdfab14996 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -5,6 +5,7 @@ use crate::config::NetworkUnixSocketPermissions; use crate::mitm::MitmState; use crate::mitm::MitmUpstreamConfig; use crate::mitm_hook::MitmHookConfig; +use crate::mitm_hook::compile_mitm_hooks; use crate::mitm_hook::validate_mitm_hook_config; use crate::policy::DomainPattern; use crate::policy::compile_allowlist_globset; @@ -71,6 +72,7 @@ pub fn build_config_state( .map_err(NetworkProxyConstraintError::into_anyhow)?; let deny_set = compile_denylist_globset(&denied_domains)?; let allow_set = compile_allowlist_globset(&allowed_domains)?; + let mitm_hooks = compile_mitm_hooks(&config)?; let mitm = if config.network.mitm { Some(Arc::new(MitmState::new(MitmUpstreamConfig { allow_upstream_proxy: config.network.allow_upstream_proxy, @@ -84,6 +86,7 @@ pub fn build_config_state( allow_set, deny_set, mitm, + mitm_hooks, constraints, blocked: std::collections::VecDeque::new(), blocked_total: 0,