diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 49e3fc9440..83ba4685b7 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -670,6 +670,27 @@ } ] }, + "InjectedHeaderConfigSchema": { + "properties": { + "name": { + "default": "", + "type": "string" + }, + "prefix": { + "default": null, + "type": "string" + }, + "secret_env_var": { + "default": null, + "type": "string" + }, + "secret_file": { + "default": null, + "type": "string" + } + }, + "type": "object" + }, "MemoriesToml": { "additionalProperties": false, "description": "Memories settings loaded from config.toml.", @@ -724,6 +745,101 @@ }, "type": "object" }, + "MitmHookActionsConfigSchema": { + "properties": { + "inject_request_headers": { + "default": [], + "items": { + "$ref": "#/definitions/InjectedHeaderConfigSchema" + }, + "type": "array" + }, + "strip_request_headers": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "MitmHookConfigSchema": { + "properties": { + "actions": { + "allOf": [ + { + "$ref": "#/definitions/MitmHookActionsConfigSchema" + } + ], + "default": { + "inject_request_headers": [], + "strip_request_headers": [] + } + }, + "host": { + "default": "", + "type": "string" + }, + "match": { + "allOf": [ + { + "$ref": "#/definitions/MitmHookMatchConfigSchema" + } + ], + "default": { + "body": null, + "headers": {}, + "methods": [], + "path_prefixes": [], + "query": {} + } + } + }, + "type": "object" + }, + "MitmHookMatchConfigSchema": { + "properties": { + "body": { + "default": null + }, + "headers": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default": {}, + "type": "object" + }, + "methods": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "path_prefixes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "query": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default": {}, + "type": "object" + } + }, + "type": "object" + }, "ModelAvailabilityNuxConfig": { "additionalProperties": { "format": "uint32", @@ -869,6 +985,15 @@ "enabled": { "type": "boolean" }, + "mitm": { + "type": "boolean" + }, + "mitm_hooks": { + "items": { + "$ref": "#/definitions/MitmHookConfigSchema" + }, + "type": "array" + }, "mode": { "$ref": "#/definitions/NetworkModeSchema" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 9fdd756724..a851a17e01 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -13,6 +13,11 @@ use crate::config_loader::RequirementSource; use crate::features::Feature; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; +use codex_network_proxy::InjectedHeaderConfig; +use codex_network_proxy::MitmHookActionsConfig; +use codex_network_proxy::MitmHookConfig; +use codex_network_proxy::MitmHookMatchConfig; +use codex_network_proxy::NetworkMode; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -217,7 +222,27 @@ enabled = true proxy_url = "http://127.0.0.1:43128" enable_socks5 = false allow_upstream_proxy = false +mode = "full" +mitm = true allowed_domains = ["openai.com"] + +[[permissions.workspace.network.mitm_hooks]] +host = "api.github.com" + +[permissions.workspace.network.mitm_hooks.match] +methods = ["POST", "PUT"] +path_prefixes = ["/repos/openai/"] + +[permissions.workspace.network.mitm_hooks.match.headers] +"x-github-api-version" = ["2022-11-28"] + +[permissions.workspace.network.mitm_hooks.actions] +strip_request_headers = ["authorization"] + +[[permissions.workspace.network.mitm_hooks.actions.inject_request_headers]] +name = "authorization" +secret_env_var = "CODEX_GITHUB_TOKEN" +prefix = "Bearer " "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles"); @@ -253,11 +278,34 @@ allowed_domains = ["openai.com"] allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: None, dangerously_allow_all_unix_sockets: None, - mode: None, + mode: Some(NetworkMode::Full), allowed_domains: Some(vec!["openai.com".to_string()]), denied_domains: None, allow_unix_sockets: None, allow_local_binding: None, + mitm: Some(true), + mitm_hooks: Some(vec![MitmHookConfig { + host: "api.github.com".to_string(), + matcher: MitmHookMatchConfig { + methods: vec!["POST".to_string(), "PUT".to_string()], + path_prefixes: vec!["/repos/openai/".to_string()], + query: BTreeMap::new(), + headers: BTreeMap::from([( + "x-github-api-version".to_string(), + vec!["2022-11-28".to_string()], + )]), + body: None, + }, + actions: MitmHookActionsConfig { + strip_request_headers: vec!["authorization".to_string()], + inject_request_headers: vec![InjectedHeaderConfig { + name: "authorization".to_string(), + secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()), + secret_file: None, + prefix: Some("Bearer ".to_string()), + }], + }, + }]), }), }, )]), @@ -265,6 +313,40 @@ allowed_domains = ["openai.com"] ); } +#[test] +fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() { + let network = NetworkToml { + enabled: Some(true), + mode: Some(NetworkMode::Full), + mitm: Some(true), + mitm_hooks: Some(vec![MitmHookConfig { + host: "api.github.com".to_string(), + matcher: MitmHookMatchConfig { + methods: vec!["POST".to_string()], + path_prefixes: vec!["/repos/openai/".to_string()], + ..MitmHookMatchConfig::default() + }, + actions: MitmHookActionsConfig { + strip_request_headers: vec!["authorization".to_string()], + inject_request_headers: vec![InjectedHeaderConfig { + name: "authorization".to_string(), + secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()), + secret_file: None, + prefix: Some("Bearer ".to_string()), + }], + }, + }]), + ..NetworkToml::default() + }; + + let config = network.to_network_proxy_config(); + + assert!(config.network.enabled); + assert_eq!(config.network.mode, NetworkMode::Full); + assert!(config.network.mitm); + assert_eq!(config.network.mitm_hooks, network.mitm_hooks.unwrap()); +} + #[test] fn permissions_profiles_network_populates_runtime_network_proxy_spec() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index b931c0f415..6e6c702329 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -5,6 +5,7 @@ use std::path::Component; use std::path::Path; use std::path::PathBuf; +use codex_network_proxy::MitmHookConfig; use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; use codex_protocol::permissions::FileSystemAccessMode; @@ -73,6 +74,9 @@ pub struct NetworkToml { pub denied_domains: Option>, pub allow_unix_sockets: Option>, pub allow_local_binding: Option, + pub mitm: Option, + #[schemars(with = "Option>")] + pub mitm_hooks: Option>, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -82,6 +86,46 @@ enum NetworkModeSchema { Full, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(default)] +struct MitmHookConfigSchema { + pub host: String, + #[serde(rename = "match", default)] + pub matcher: MitmHookMatchConfigSchema, + #[serde(default)] + pub actions: MitmHookActionsConfigSchema, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(default)] +struct MitmHookMatchConfigSchema { + pub methods: Vec, + pub path_prefixes: Vec, + pub query: BTreeMap>, + pub headers: BTreeMap>, + pub body: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(default)] +struct MitmHookActionsConfigSchema { + pub strip_request_headers: Vec, + pub inject_request_headers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(default)] +struct InjectedHeaderConfigSchema { + pub name: String, + pub secret_env_var: Option, + pub secret_file: Option, + pub prefix: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(transparent)] +struct MitmHookBodyConfigSchema(pub serde_json::Value); + impl NetworkToml { pub(crate) fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) { if let Some(enabled) = self.enabled { @@ -126,6 +170,12 @@ impl NetworkToml { if let Some(allow_local_binding) = self.allow_local_binding { config.network.allow_local_binding = allow_local_binding; } + if let Some(mitm) = self.mitm { + config.network.mitm = mitm; + } + if let Some(mitm_hooks) = self.mitm_hooks.as_ref() { + config.network.mitm_hooks = mitm_hooks.clone(); + } } pub(crate) fn to_network_proxy_config(&self) -> NetworkProxyConfig { diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 1c8244f70b..9e25921631 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -319,7 +319,16 @@ mod tests { default_permissions = "workspace" [permissions.workspace.network] +mode = "full" +mitm = true allowed_domains = ["lower.example.com"] + +[[permissions.workspace.network.mitm_hooks]] +host = "lower.example.com" + +[permissions.workspace.network.mitm_hooks.match] +methods = ["POST"] +path_prefixes = ["/repos/openai/"] "#, ) .expect("lower layer should parse"); @@ -328,7 +337,16 @@ allowed_domains = ["lower.example.com"] default_permissions = "workspace" [permissions.workspace.network] +mode = "full" +mitm = true allowed_domains = ["higher.example.com"] + +[[permissions.workspace.network.mitm_hooks]] +host = "api.github.com" + +[permissions.workspace.network.mitm_hooks.match] +methods = ["PUT"] +path_prefixes = ["/repos/openai/"] "#, ) .expect("higher layer should parse"); @@ -346,6 +364,11 @@ allowed_domains = ["higher.example.com"] .expect("higher layer should apply"); assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]); + assert_eq!(config.network.mode, codex_network_proxy::NetworkMode::Full); + assert!(config.network.mitm); + assert_eq!(config.network.mitm_hooks.len(), 1); + assert_eq!(config.network.mitm_hooks[0].host, "api.github.com"); + assert_eq!(config.network.mitm_hooks[0].matcher.methods, vec!["PUT"]); } #[test] diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 6fb689490a..1c1b2ebc07 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -5,7 +5,8 @@ - an HTTP proxy (default `127.0.0.1:3128`) - a SOCKS5 proxy (default `127.0.0.1:8081`, enabled by default) -It enforces an allow/deny policy and a "limited" mode intended for read-only network access. +It enforces an allow/deny policy, a "limited" mode intended for read-only network access, and +host-specific HTTPS MITM hooks for request matching and header injection. ## Quickstart @@ -32,8 +33,9 @@ allow_upstream_proxy = true # By default, non-loopback binds are clamped to loopback for safety. # If you want to expose these listeners beyond localhost, you must opt in explicitly. dangerously_allow_non_loopback_proxy = false -mode = "full" # default when unset; use "limited" for read-only mode -# When true, HTTPS CONNECT can be terminated so limited-mode method policy still applies. +mode = "full" # default when unset; hooks can still clamp specific HTTPS hosts +# When true, HTTPS CONNECT can be terminated so limited-mode policy and host-specific MITM hooks +# can inspect inner requests. mitm = false # CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key). @@ -54,6 +56,27 @@ allow_unix_sockets = ["/tmp/example.sock"] # DANGEROUS (macOS-only): bypasses unix socket allowlisting and permits any # absolute socket path from `x-unix-socket`. dangerously_allow_all_unix_sockets = false + +[[permissions.workspace.network.mitm_hooks]] +host = "api.github.com" + +[permissions.workspace.network.mitm_hooks.match] +methods = ["POST", "PUT"] +path_prefixes = ["/repos/openai/"] + +[permissions.workspace.network.mitm_hooks.match.headers] +"x-github-api-version" = ["2022-11-28"] + +[permissions.workspace.network.mitm_hooks.actions] +strip_request_headers = ["authorization"] + +[[permissions.workspace.network.mitm_hooks.actions.inject_request_headers]] +name = "authorization" +secret_env_var = "CODEX_GITHUB_TOKEN" +prefix = "Bearer " + +# `match.body` is reserved for a future release. Current hooks match only on +# method/path/query/headers and can mutate outbound request headers. ``` ### 2) Run the proxy @@ -86,12 +109,15 @@ When a request is blocked, the proxy responds with `403` and includes: - `x-proxy-error`: one of: - `blocked-by-allowlist` - `blocked-by-denylist` + - `blocked-by-mitm-hook` - `blocked-by-method-policy` - `blocked-by-policy` In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed. HTTPS `CONNECT` requests require -MITM to enforce limited-mode method policy; otherwise they are blocked. SOCKS5 remains blocked in -limited mode. +MITM to enforce limited-mode method policy; otherwise they are blocked. Separately, hosts covered +by `mitm_hooks` are authoritative even when `mode = "full"`: hooks are evaluated in order, the +first match wins, and if no hook matches the inner HTTPS request is denied with +`blocked-by-mitm-hook`. SOCKS5 remains blocked in limited mode. Websocket clients typically tunnel `wss://` through HTTPS `CONNECT`; those CONNECT targets still go through the same host allowlist/denylist checks. @@ -195,9 +221,12 @@ what it can reasonably guarantee. and common private/link-local ranges. Explicit allowlisting of local IP literals (or `localhost`) is required to permit them; hostnames that resolve to local/private IPs are still blocked even if allowlisted (best-effort DNS lookup). -- Limited mode enforcement: - - only `GET`, `HEAD`, and `OPTIONS` are allowed - - HTTPS `CONNECT` remains a tunnel; limited-mode method enforcement does not apply to HTTPS +- HTTPS enforcement: + - in limited mode, only `GET`, `HEAD`, and `OPTIONS` are allowed + - when `mitm = true`, HTTPS inner requests can be matched by `mitm_hooks` and have request + headers rewritten before forwarding + - hooked hosts are authoritative even in `mode = "full"`: if a host has `mitm_hooks` and none + match, the request is denied - Listener safety defaults: - the HTTP proxy listener clamps non-loopback binds unless explicitly enabled via `dangerously_allow_non_loopback_proxy` diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index e00ec1944a..36d0fad64b 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -10,6 +10,8 @@ use std::path::Path; use tracing::warn; use url::Url; +use crate::mitm_hook::MitmHookConfig; + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct NetworkProxyConfig { #[serde(default)] @@ -43,6 +45,8 @@ pub struct NetworkProxySettings { pub allow_local_binding: bool, #[serde(default)] pub mitm: bool, + #[serde(default)] + pub mitm_hooks: Vec, } impl Default for NetworkProxySettings { @@ -62,6 +66,7 @@ impl Default for NetworkProxySettings { allow_unix_sockets: Vec::new(), allow_local_binding: false, mitm: false, + mitm_hooks: Vec::new(), } } } @@ -73,8 +78,8 @@ pub enum NetworkMode { /// blocked unless MITM is enabled so the proxy can enforce method policy on inner requests. /// SOCKS5 remains blocked in limited mode. Limited, - /// Full network access: all HTTP methods are allowed, and HTTPS CONNECTs are tunneled without - /// MITM interception. + /// Full network access: all HTTP methods are allowed. HTTPS CONNECTs are tunneled directly + /// unless MITM is needed for host-specific inner-request hooks. #[default] Full, } @@ -376,6 +381,7 @@ mod tests { allow_unix_sockets: Vec::new(), allow_local_binding: false, mitm: false, + mitm_hooks: Vec::new(), } ); } diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index 4f88d25383..d8b0faf0ec 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -79,6 +79,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, @@ -249,10 +252,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 { @@ -279,7 +290,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()), @@ -288,14 +299,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); } @@ -324,7 +337,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::>() @@ -1024,6 +1040,40 @@ 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 policy = NetworkProxySettings { + allowed_domains: vec!["api.github.com".to_string()], + 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() + }; + 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(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(flavor = "current_thread")] async fn http_plain_proxy_blocks_unix_socket_when_method_not_allowed() { let state = Arc::new(network_proxy_state_for_policy( diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 1093a14aa4..236329d8fd 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -4,6 +4,7 @@ mod certs; mod config; mod http_proxy; mod mitm; +mod mitm_hook; mod network_policy; mod policy; mod proxy; @@ -17,6 +18,11 @@ mod upstream; pub use config::NetworkMode; pub use config::NetworkProxyConfig; pub use config::host_and_port_from_network_addr; +pub use mitm_hook::InjectedHeaderConfig; +pub use mitm_hook::MitmHookActionsConfig; +pub use mitm_hook::MitmHookBodyConfig; +pub use mitm_hook::MitmHookConfig; +pub use mitm_hook::MitmHookMatchConfig; pub use network_policy::NetworkDecision; pub use network_policy::NetworkDecisionSource; pub use network_policy::NetworkPolicyDecider; diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index 8f20afaa3d..efdee16767 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; @@ -66,6 +70,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; @@ -81,9 +92,10 @@ impl std::fmt::Debug for MitmState { impl MitmState { pub(crate) fn new(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. + // 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 allow_upstream_proxy { @@ -195,9 +207,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; @@ -208,6 +221,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 @@ -242,12 +256,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", ))); @@ -267,7 +292,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", ))); @@ -302,7 +327,43 @@ 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))); + } + + match policy + .app_state + .evaluate_mitm_hook_request(&policy.target_host, req) + .await? + { + HookEvaluation::Matched { actions } => { + return Ok(MitmPolicyDecision::Allow { + hook_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 => {} } if !policy.mode.allows_method(&method) { @@ -324,10 +385,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: None }) +} + +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_hook.rs b/codex-rs/network-proxy/src/mitm_hook.rs new file mode 100644 index 0000000000..976defb658 --- /dev/null +++ b/codex-rs/network-proxy/src/mitm_hook.rs @@ -0,0 +1,770 @@ +use crate::config::NetworkProxyConfig; +use crate::policy::normalize_host; +use anyhow::Context as _; +use anyhow::Result; +use anyhow::anyhow; +use codex_utils_absolute_path::AbsolutePathBuf; +use rama_http::HeaderValue; +use rama_http::Request; +use rama_http::header::HeaderName; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::path::Path; +use url::form_urlencoded; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(default)] +pub struct MitmHookConfig { + pub host: String, + #[serde(rename = "match", default)] + pub matcher: MitmHookMatchConfig, + #[serde(default)] + pub actions: MitmHookActionsConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(default)] +pub struct MitmHookMatchConfig { + pub methods: Vec, + pub path_prefixes: Vec, + pub query: BTreeMap>, + pub headers: BTreeMap>, + pub body: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(default)] +pub struct MitmHookActionsConfig { + pub strip_request_headers: Vec, + pub inject_request_headers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(default)] +pub struct InjectedHeaderConfig { + pub name: String, + pub secret_env_var: Option, + pub secret_file: Option, + pub prefix: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(transparent)] +pub struct MitmHookBodyConfig(pub serde_json::Value); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MitmHook { + pub host: String, + pub matcher: MitmHookMatcher, + pub actions: MitmHookActions, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MitmHookMatcher { + pub methods: Vec, + pub path_prefixes: Vec, + pub query: Vec, + pub headers: Vec, + pub body: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QueryConstraint { + pub name: String, + pub allowed_values: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HeaderConstraint { + pub name: HeaderName, + pub allowed_values: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MitmHookActions { + pub strip_request_headers: Vec, + pub inject_request_headers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedInjectedHeader { + pub name: HeaderName, + pub value: HeaderValue, + pub source: SecretSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SecretSource { + EnvVar(String), + File(AbsolutePathBuf), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MitmHookBodyMatcher { + pub raw: serde_json::Value, +} + +pub type MitmHooksByHost = BTreeMap>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookEvaluation { + NoHooksForHost, + Matched { actions: MitmHookActions }, + HookedHostNoMatch, +} + +pub(crate) fn validate_mitm_hook_config(config: &NetworkProxyConfig) -> Result<()> { + let hooks = &config.network.mitm_hooks; + if hooks.is_empty() { + return Ok(()); + } + + if !config.network.mitm { + return Err(anyhow!("network.mitm_hooks requires network.mitm = true")); + } + + for (hook_index, hook) in hooks.iter().enumerate() { + let host = normalize_hook_host(&hook.host) + .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].host"))?; + + let methods = normalize_methods(&hook.matcher.methods) + .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.methods"))?; + if methods.is_empty() { + return Err(anyhow!( + "network.mitm_hooks[{hook_index}].match.methods must not be empty" + )); + } + + let path_prefixes = + normalize_path_prefixes(&hook.matcher.path_prefixes).with_context(|| { + format!("invalid network.mitm_hooks[{hook_index}].match.path_prefixes") + })?; + if path_prefixes.is_empty() { + return Err(anyhow!( + "network.mitm_hooks[{hook_index}].match.path_prefixes must not be empty" + )); + } + + if let Some(body) = hook.matcher.body.as_ref() { + let _ = body; + return Err(anyhow!( + "network.mitm_hooks[{hook_index}].match.body is reserved for a future release and is not yet supported" + )); + } + + validate_query_constraints(&hook.matcher.query) + .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.query"))?; + validate_header_constraints(&hook.matcher.headers) + .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.headers"))?; + validate_strip_request_headers(&hook.actions.strip_request_headers).with_context(|| { + format!("invalid network.mitm_hooks[{hook_index}].actions.strip_request_headers") + })?; + validate_injected_headers(&hook.actions.inject_request_headers).with_context(|| { + format!("invalid network.mitm_hooks[{hook_index}].actions.inject_request_headers") + })?; + + if host.is_empty() { + return Err(anyhow!( + "network.mitm_hooks[{hook_index}].host must not be empty" + )); + } + } + + Ok(()) +} + +pub(crate) fn compile_mitm_hooks(config: &NetworkProxyConfig) -> Result { + compile_mitm_hooks_with_resolvers( + config, + |name| env::var(name).ok(), + |path| { + let value = fs::read_to_string(path.as_path()).with_context(|| { + format!("failed to read secret file {}", path.as_path().display()) + })?; + Ok(value.trim().to_string()) + }, + ) +} + +pub(crate) fn evaluate_mitm_hooks( + hooks_by_host: &MitmHooksByHost, + host: &str, + req: &Request, +) -> HookEvaluation { + let normalized_host = normalize_host(host); + let Some(hooks) = hooks_by_host.get(&normalized_host) else { + return HookEvaluation::NoHooksForHost; + }; + + for hook in hooks { + if hook_matches(hook, req) { + return HookEvaluation::Matched { + actions: hook.actions.clone(), + }; + } + } + + HookEvaluation::HookedHostNoMatch +} + +fn compile_mitm_hooks_with_resolvers( + config: &NetworkProxyConfig, + resolve_env_var: EnvFn, + read_secret_file: FileFn, +) -> Result +where + EnvFn: Fn(&str) -> Option, + FileFn: Fn(&AbsolutePathBuf) -> Result, +{ + validate_mitm_hook_config(config)?; + + let mut hooks_by_host = MitmHooksByHost::new(); + for hook in &config.network.mitm_hooks { + let host = normalize_hook_host(&hook.host)?; + let methods = normalize_methods(&hook.matcher.methods)?; + let path_prefixes = normalize_path_prefixes(&hook.matcher.path_prefixes)?; + let query = hook + .matcher + .query + .iter() + .map(|(name, values)| { + Ok(QueryConstraint { + name: normalize_query_name(name)?, + allowed_values: values.clone(), + }) + }) + .collect::>>()?; + let headers = hook + .matcher + .headers + .iter() + .map(|(name, values)| { + Ok(HeaderConstraint { + name: parse_header_name(name)?, + allowed_values: values.clone(), + }) + }) + .collect::>>()?; + let strip_request_headers = hook + .actions + .strip_request_headers + .iter() + .map(|name| parse_header_name(name)) + .collect::>>()?; + let inject_request_headers = hook + .actions + .inject_request_headers + .iter() + .map(|header| { + compile_injected_header(header, &resolve_env_var, &read_secret_file) + .with_context(|| format!("failed to compile injected header {}", header.name)) + }) + .collect::>>()?; + + hooks_by_host + .entry(host.clone()) + .or_default() + .push(MitmHook { + host, + matcher: MitmHookMatcher { + methods, + path_prefixes, + query, + headers, + body: None, + }, + actions: MitmHookActions { + strip_request_headers, + inject_request_headers, + }, + }); + } + + Ok(hooks_by_host) +} + +fn compile_injected_header( + header: &InjectedHeaderConfig, + resolve_env_var: &EnvFn, + read_secret_file: &FileFn, +) -> Result +where + EnvFn: Fn(&str) -> Option, + FileFn: Fn(&AbsolutePathBuf) -> Result, +{ + let name = parse_header_name(&header.name)?; + let (secret, source) = match ( + header.secret_env_var.as_deref(), + header.secret_file.as_deref(), + ) { + (Some(env_var), None) => { + let value = resolve_env_var(env_var) + .ok_or_else(|| anyhow!("missing required environment variable {env_var}"))?; + (value, SecretSource::EnvVar(env_var.to_string())) + } + (None, Some(secret_file)) => { + let path = parse_secret_file(secret_file)?; + let value = read_secret_file(&path)?; + (value, SecretSource::File(path)) + } + _ => { + return Err(anyhow!( + "expected exactly one of secret_env_var or secret_file" + )); + } + }; + + let prefix = header.prefix.clone().unwrap_or_default(); + let value = HeaderValue::from_str(&format!("{prefix}{secret}")) + .with_context(|| format!("invalid value for injected header {}", header.name))?; + + Ok(ResolvedInjectedHeader { + name, + value, + source, + }) +} + +fn hook_matches(hook: &MitmHook, req: &Request) -> bool { + let method = req.method().as_str().to_ascii_uppercase(); + if !hook + .matcher + .methods + .iter() + .any(|allowed| allowed == &method) + { + return false; + } + + let path = req.uri().path(); + if !hook + .matcher + .path_prefixes + .iter() + .any(|prefix| path.starts_with(prefix)) + { + return false; + } + + if !query_matches(&hook.matcher.query, req) { + return false; + } + + headers_match(&hook.matcher.headers, req) +} + +fn query_matches(query_constraints: &[QueryConstraint], req: &Request) -> bool { + if query_constraints.is_empty() { + return true; + } + + let actual_query = req.uri().query().unwrap_or_default(); + let mut actual_values: BTreeMap> = BTreeMap::new(); + for (name, value) in form_urlencoded::parse(actual_query.as_bytes()) { + actual_values + .entry(name.into_owned()) + .or_default() + .push(value.into_owned()); + } + + query_constraints.iter().all(|constraint| { + actual_values.get(&constraint.name).is_some_and(|actual| { + actual.iter().any(|candidate| { + constraint + .allowed_values + .iter() + .any(|allowed| allowed == candidate) + }) + }) + }) +} + +fn headers_match(header_constraints: &[HeaderConstraint], req: &Request) -> bool { + header_constraints.iter().all(|constraint| { + let actual = req.headers().get_all(&constraint.name); + if actual.iter().next().is_none() { + return false; + } + if constraint.allowed_values.is_empty() { + return true; + } + + actual.iter().any(|value| { + value.to_str().ok().is_some_and(|candidate| { + constraint + .allowed_values + .iter() + .any(|allowed| allowed == candidate) + }) + }) + }) +} + +fn normalize_hook_host(host: &str) -> Result { + let normalized = normalize_host(host); + if normalized.is_empty() { + return Err(anyhow!("host must not be empty")); + } + if normalized.contains('*') { + return Err(anyhow!( + "MITM hook hosts must be exact hosts and cannot contain wildcards" + )); + } + Ok(normalized) +} + +fn normalize_methods(methods: &[String]) -> Result> { + methods + .iter() + .map(|method| { + let normalized = method.trim().to_ascii_uppercase(); + if normalized.is_empty() { + return Err(anyhow!("methods must not contain empty entries")); + } + Ok(normalized) + }) + .collect() +} + +fn normalize_path_prefixes(path_prefixes: &[String]) -> Result> { + path_prefixes + .iter() + .map(|prefix| { + if prefix.is_empty() { + return Err(anyhow!("path_prefixes must not contain empty entries")); + } + Ok(prefix.clone()) + }) + .collect() +} + +fn validate_query_constraints(query: &BTreeMap>) -> Result<()> { + for (name, values) in query { + let normalized = normalize_query_name(name)?; + if normalized.is_empty() { + return Err(anyhow!("query keys must not be empty")); + } + if values.is_empty() { + return Err(anyhow!( + "query key {name:?} must list at least one allowed value" + )); + } + } + Ok(()) +} + +fn normalize_query_name(name: &str) -> Result { + if name.is_empty() { + return Err(anyhow!("query keys must not be empty")); + } + Ok(name.to_string()) +} + +fn validate_header_constraints(headers: &BTreeMap>) -> Result<()> { + for name in headers.keys() { + let _ = parse_header_name(name)?; + } + Ok(()) +} + +fn validate_strip_request_headers(header_names: &[String]) -> Result<()> { + for name in header_names { + let _ = parse_header_name(name)?; + } + Ok(()) +} + +fn validate_injected_headers(headers: &[InjectedHeaderConfig]) -> Result<()> { + for header in headers { + let _ = parse_header_name(&header.name)?; + match ( + header.secret_env_var.as_deref(), + header.secret_file.as_deref(), + ) { + (Some(secret_env_var), None) => { + if secret_env_var.trim().is_empty() { + return Err(anyhow!("secret_env_var must not be empty")); + } + } + (None, Some(secret_file)) => { + let _ = parse_secret_file(secret_file)?; + } + _ => { + return Err(anyhow!( + "expected exactly one of secret_env_var or secret_file" + )); + } + } + } + Ok(()) +} + +fn parse_header_name(name: &str) -> Result { + HeaderName::from_bytes(name.as_bytes()) + .map_err(|err| anyhow!("invalid header name {name:?}: {err}")) +} + +fn parse_secret_file(path: &str) -> Result { + if path.trim().is_empty() { + return Err(anyhow!("secret_file must not be empty")); + } + let path = Path::new(path); + if !path.is_absolute() { + return Err(anyhow!("secret_file must be an absolute path: {path:?}")); + } + AbsolutePathBuf::from_absolute_path(path) + .with_context(|| format!("secret_file must be an absolute path: {path:?}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::NetworkMode; + use crate::config::NetworkProxySettings; + use pretty_assertions::assert_eq; + use rama_http::Body; + use rama_http::Method; + use tempfile::NamedTempFile; + + fn base_config() -> NetworkProxyConfig { + NetworkProxyConfig { + network: NetworkProxySettings { + mitm: true, + mode: NetworkMode::Limited, + ..NetworkProxySettings::default() + }, + } + } + + fn github_hook() -> MitmHookConfig { + MitmHookConfig { + host: "api.github.com".to_string(), + matcher: MitmHookMatchConfig { + methods: vec!["POST".to_string(), "PUT".to_string()], + path_prefixes: vec!["/repos/openai/".to_string()], + ..MitmHookMatchConfig::default() + }, + actions: MitmHookActionsConfig { + strip_request_headers: vec!["authorization".to_string()], + inject_request_headers: vec![InjectedHeaderConfig { + name: "authorization".to_string(), + secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()), + secret_file: None, + prefix: Some("Bearer ".to_string()), + }], + }, + } + } + + #[test] + fn validate_requires_mitm_for_hooks() { + let mut config = base_config(); + config.network.mitm = false; + config.network.mitm_hooks = vec![github_hook()]; + + let err = validate_mitm_hook_config(&config).expect_err("hooks require mitm"); + assert!( + err.to_string() + .contains("network.mitm_hooks requires network.mitm = true") + ); + } + + #[test] + fn validate_allows_hooks_in_full_mode() { + let mut config = base_config(); + config.network.mode = NetworkMode::Full; + config.network.mitm_hooks = vec![github_hook()]; + + validate_mitm_hook_config(&config).expect("hooks should be allowed in full mode"); + } + + #[test] + fn validate_rejects_body_matchers_for_now() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.matcher.body = Some(MitmHookBodyConfig(serde_json::json!({ + "repository": "openai/codex" + }))); + config.network.mitm_hooks = vec![hook]; + + let err = validate_mitm_hook_config(&config).expect_err("body matchers are reserved"); + assert!(err.to_string().contains("match.body is reserved")); + } + + #[test] + fn validate_rejects_relative_secret_file() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.actions.inject_request_headers[0].secret_env_var = None; + hook.actions.inject_request_headers[0].secret_file = Some("token.txt".to_string()); + config.network.mitm_hooks = vec![hook]; + + let err = validate_mitm_hook_config(&config).expect_err("secret file must be absolute"); + assert!(format!("{err:#}").contains("secret_file must be an absolute path")); + } + + #[test] + fn validate_rejects_dual_secret_sources() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.actions.inject_request_headers[0].secret_file = Some("/tmp/github-token".to_string()); + config.network.mitm_hooks = vec![hook]; + + let err = validate_mitm_hook_config(&config).expect_err("dual secret sources invalid"); + assert!(format!("{err:#}").contains("exactly one of secret_env_var or secret_file")); + } + + #[test] + fn compile_resolves_env_backed_injected_headers() { + let mut config = base_config(); + config.network.mitm_hooks = vec![github_hook()]; + + let hooks = compile_mitm_hooks_with_resolvers( + &config, + |name| (name == "CODEX_GITHUB_TOKEN").then(|| "ghp-secret".to_string()), + |_| Err(anyhow!("unexpected file lookup")), + ) + .unwrap(); + + let compiled = hooks.get("api.github.com").unwrap(); + assert_eq!(compiled.len(), 1); + assert_eq!( + compiled[0].actions.inject_request_headers[0].source, + SecretSource::EnvVar("CODEX_GITHUB_TOKEN".to_string()) + ); + assert_eq!( + compiled[0].actions.inject_request_headers[0].value, + HeaderValue::from_static("Bearer ghp-secret") + ); + } + + #[test] + fn compile_resolves_file_backed_injected_headers() { + let secret_file = NamedTempFile::new().unwrap(); + std::fs::write(secret_file.path(), "ghp-file-secret\n").unwrap(); + + let mut config = base_config(); + let mut hook = github_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()); + config.network.mitm_hooks = vec![hook]; + + let hooks = compile_mitm_hooks(&config).unwrap(); + let compiled = hooks.get("api.github.com").unwrap(); + assert_eq!( + compiled[0].actions.inject_request_headers[0].value, + HeaderValue::from_static("Bearer ghp-file-secret") + ); + } + + #[test] + fn evaluate_returns_first_matching_hook() { + let mut config = base_config(); + let mut first = github_hook(); + first.matcher.path_prefixes = vec!["/repos/openai/".to_string()]; + let mut second = github_hook(); + second.actions.inject_request_headers[0].prefix = Some("Token ".to_string()); + config.network.mitm_hooks = vec![first, second]; + + let hooks = compile_mitm_hooks_with_resolvers( + &config, + |_| Some("abc".to_string()), + |_| Err(anyhow!("unexpected file lookup")), + ) + .unwrap(); + let req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues") + .header("x-trace", "1") + .body(Body::empty()) + .unwrap(); + + let evaluation = evaluate_mitm_hooks(&hooks, "api.github.com", &req); + let HookEvaluation::Matched { actions } = evaluation else { + panic!("expected a matching hook"); + }; + + assert_eq!( + actions.inject_request_headers[0].value, + HeaderValue::from_static("Bearer abc") + ); + } + + #[test] + fn evaluate_matches_query_and_header_constraints() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.matcher.query = BTreeMap::from([( + "state".to_string(), + vec!["open".to_string(), "triage".to_string()], + )]); + hook.matcher.headers = BTreeMap::from([( + "x-github-api-version".to_string(), + vec!["2022-11-28".to_string()], + )]); + config.network.mitm_hooks = vec![hook]; + + let hooks = compile_mitm_hooks_with_resolvers( + &config, + |_| Some("abc".to_string()), + |_| Err(anyhow!("unexpected file lookup")), + ) + .unwrap(); + let req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues?state=open&per_page=10") + .header("x-github-api-version", "2022-11-28") + .body(Body::empty()) + .unwrap(); + + assert_eq!( + evaluate_mitm_hooks(&hooks, "api.github.com", &req), + HookEvaluation::Matched { + actions: hooks.get("api.github.com").unwrap()[0].actions.clone(), + } + ); + } + + #[test] + fn evaluate_returns_hooked_host_no_match_when_query_constraint_fails() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.matcher.query = BTreeMap::from([("state".to_string(), vec!["open".to_string()])]); + config.network.mitm_hooks = vec![hook]; + + let hooks = compile_mitm_hooks_with_resolvers( + &config, + |_| Some("abc".to_string()), + |_| Err(anyhow!("unexpected file lookup")), + ) + .unwrap(); + let req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues?state=closed") + .body(Body::empty()) + .unwrap(); + + assert_eq!( + evaluate_mitm_hooks(&hooks, "api.github.com", &req), + HookEvaluation::HookedHostNoMatch + ); + } + + #[test] + fn evaluate_returns_no_hooks_for_unconfigured_host() { + let req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues") + .body(Body::empty()) + .unwrap(); + + assert_eq!( + evaluate_mitm_hooks(&MitmHooksByHost::new(), "api.github.com", &req), + HookEvaluation::NoHooksForHost + ); + } +} diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index d2f7984a70..a542276b40 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, @@ -108,3 +134,113 @@ 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 app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { + allowed_domains: vec!["api.github.com".to_string()], + mitm: true, + mitm_hooks: vec![hook], + mode: NetworkMode::Full, + ..NetworkProxySettings::default() + })); + let ctx = policy_ctx(app_state.clone(), NetworkMode::Full, "api.github.com", 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 bypass method clamp" + ); + assert_eq!(app_state.blocked_snapshot().await.unwrap().len(), 0); +} + +#[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 app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { + allowed_domains: vec!["api.github.com".to_string()], + mitm: true, + mitm_hooks: vec![hook], + mode: NetworkMode::Full, + ..NetworkProxySettings::default() + })); + let ctx = policy_ctx(app_state.clone(), NetworkMode::Full, "api.github.com", 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 b844eadf3d..67f570f128 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 fa002195f4..2e08a4c3c5 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; @@ -52,6 +53,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", } @@ -69,7 +71,12 @@ 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.", + REASON_MITM_HOOK_DENIED => { + "Codex blocked this request: MITM hook policy denied the HTTPS request." + } + REASON_MITM_REQUIRED => { + "Codex blocked this request: MITM required to enforce HTTPS policy." + } _ => "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 d043106fa7..8d1c2b6783 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -2,6 +2,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; @@ -156,6 +159,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, @@ -558,6 +562,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 } @@ -787,9 +807,17 @@ 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_globset(&config.network.allowed_domains).unwrap(), + blocked: VecDeque::new(), + blocked_total: 0, + config: config.clone(), + constraints: NetworkProxyConstraints::default(), + deny_set: crate::policy::compile_globset(&config.network.denied_domains).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/state.rs b/codex-rs/network-proxy/src/state.rs index 612e6c5b5c..935f6f48ab 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -1,6 +1,9 @@ use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; use crate::mitm::MitmState; +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_globset; use crate::policy::is_global_wildcard_domain_pattern; @@ -52,6 +55,10 @@ pub struct PartialNetworkConfig { pub allow_unix_sockets: Option>, #[serde(default)] pub allow_local_binding: Option, + #[serde(default)] + pub mitm: Option, + #[serde(default)] + pub mitm_hooks: Option>, } pub fn build_config_state( @@ -65,6 +72,7 @@ pub fn build_config_state( .map_err(NetworkProxyConstraintError::into_anyhow)?; let deny_set = compile_globset(&config.network.denied_domains)?; let allow_set = compile_globset(&config.network.allowed_domains)?; + let mitm_hooks = compile_mitm_hooks(&config)?; let mitm = if config.network.mitm { Some(Arc::new(MitmState::new( config.network.allow_upstream_proxy, @@ -77,6 +85,7 @@ pub fn build_config_state( allow_set, deny_set, mitm, + mitm_hooks, constraints, blocked: std::collections::VecDeque::new(), blocked_total: 0, @@ -107,6 +116,7 @@ pub fn validate_policy_against_constraints( } let enabled = config.network.enabled; + validate_mitm_hook_config(config).map_err(invalid_mitm_hook_configuration)?; validate_domain_patterns("network.allowed_domains", &config.network.allowed_domains)?; validate_domain_patterns("network.denied_domains", &config.network.denied_domains)?; if let Some(max_enabled) = constraints.enabled { @@ -364,6 +374,14 @@ pub fn validate_policy_against_constraints( Ok(()) } +fn invalid_mitm_hook_configuration(err: anyhow::Error) -> NetworkProxyConstraintError { + NetworkProxyConstraintError::InvalidValue { + field_name: "network.mitm_hooks", + candidate: err.to_string(), + allowed: "valid MITM hook configuration".to_string(), + } +} + fn validate_domain_patterns( field_name: &'static str, patterns: &[String],