From 28ce16562d71adcb0ceb07801178d8ff7189049f Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Thu, 14 May 2026 12:34:42 -0700 Subject: [PATCH] Route credentialed traffic through MITM proxy --- .../core/src/config/network_proxy_spec.rs | 31 ++++ .../src/config/network_proxy_spec_tests.rs | 33 +++++ codex-rs/core/src/credentialed_routes.rs | 136 +++++++++++++++++- codex-rs/core/src/session/mod.rs | 21 ++- codex-rs/core/src/session/session.rs | 10 +- codex-rs/core/src/session/tests.rs | 10 +- codex-rs/core/src/state/service.rs | 1 + codex-rs/network-proxy/src/lib.rs | 1 + codex-rs/network-proxy/src/mitm.rs | 46 +++++- codex-rs/network-proxy/src/mitm_hook.rs | 28 ++++ codex-rs/network-proxy/src/mitm_tests.rs | 21 +++ 11 files changed, 320 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 631a826ac7..8cb0c5e6fa 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -177,6 +177,37 @@ impl NetworkProxySpec { Ok(spec) } + pub(crate) fn with_credentialed_routes( + &self, + credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig, + ) -> std::io::Result { + let mut spec = self.clone(); + let credentialed_route_hooks = credentialed_routes.mitm_hooks(); + let mut allowed_domains = spec.config.network.allowed_domains().unwrap_or_default(); + for hook in &credentialed_route_hooks { + if !allowed_domains + .iter() + .any(|allowed_domain| normalize_host(allowed_domain) == normalize_host(&hook.host)) + { + allowed_domains.push(hook.host.clone()); + } + } + spec.config.network.set_allowed_domains(allowed_domains); + let mut mitm_hooks = credentialed_route_hooks; + mitm_hooks.extend(spec.config.network.mitm_hooks); + spec.config.network.mitm_hooks = mitm_hooks; + spec.config.network.mitm = spec.config.network.mode + == codex_network_proxy::NetworkMode::Limited + || !spec.config.network.mitm_hooks.is_empty(); + validate_policy_against_constraints(&spec.config, &spec.constraints).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("network proxy constraints are invalid: {err}"), + ) + })?; + Ok(spec) + } + pub(crate) async fn apply_to_started_proxy( &self, started_proxy: &StartedNetworkProxy, diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index 14b7c1c330..c0be217b36 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -1,4 +1,6 @@ use super::*; +use codex_backend_client::CredentialRouteAuthType; +use codex_backend_client::ResolvedCredentialRoute; use codex_config::NetworkDomainPermissionToml; use codex_config::NetworkDomainPermissionsToml; use codex_network_proxy::NetworkDomainPermission; @@ -80,6 +82,37 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true)); } +#[test] +fn credentialed_routes_add_runtime_allowlist_and_mitm_hooks() { + let spec = NetworkProxySpec::from_config_and_constraints( + NetworkProxyConfig::default(), + None, + &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + ) + .expect("config should load"); + let credentialed_routes = crate::credentialed_routes::CredentialedRoutesSessionConfig { + routes: vec![ResolvedCredentialRoute { + connector_id: "connector_123".to_string(), + link_id: "link_123".to_string(), + auth_type: CredentialRouteAuthType::OAuth, + base_url: "https://api.example.com/v1".to_string(), + }], + proxy_url: Some("https://chatgpt.com/backend-api/wham/credential_routes/proxy".to_string()), + }; + + let spec = spec + .with_credentialed_routes(&credentialed_routes) + .expect("credentialed routes should fit unconstrained config"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec!["api.example.com".to_string()]) + ); + assert!(spec.config.network.mitm); + assert_eq!(spec.config.network.mitm_hooks.len(), 1); + assert_eq!(spec.config.network.mitm_hooks[0].host, "api.example.com"); +} + #[test] fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() { let mut config = NetworkProxyConfig::default(); diff --git a/codex-rs/core/src/credentialed_routes.rs b/codex-rs/core/src/credentialed_routes.rs index cf6ee30b76..70e07de492 100644 --- a/codex-rs/core/src/credentialed_routes.rs +++ b/codex-rs/core/src/credentialed_routes.rs @@ -1,22 +1,33 @@ use codex_backend_client::Client as BackendClient; use codex_backend_client::ResolvedCredentialRoute; use codex_login::CodexAuth; +use codex_network_proxy::CredentialedRouteProxyActionConfig; +use codex_network_proxy::MitmHookActionsConfig; +use codex_network_proxy::MitmHookConfig; +use codex_network_proxy::MitmHookMatchConfig; use tracing::debug; use tracing::warn; +use url::Url; + +#[derive(Debug, Clone, Default)] +pub(crate) struct CredentialedRoutesSessionConfig { + pub(crate) routes: Vec, + pub(crate) proxy_url: Option, +} pub(crate) async fn load_for_session( chatgpt_base_url: &str, auth: Option<&CodexAuth>, -) -> Vec { +) -> CredentialedRoutesSessionConfig { let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else { - return Vec::new(); + return CredentialedRoutesSessionConfig::default(); }; let client = match BackendClient::from_auth(chatgpt_base_url.to_string(), auth) { Ok(client) => client, Err(err) => { warn!(error = %err, "failed to initialize credentialed routes client"); - return Vec::new(); + return CredentialedRoutesSessionConfig::default(); } }; @@ -26,11 +37,126 @@ pub(crate) async fn load_for_session( credentialed_routes = response.routes.len(), "loaded credentialed routes for session" ); - response.routes + CredentialedRoutesSessionConfig { + routes: response.routes, + proxy_url: Some(client.credential_routes_proxy_url()), + } } Err(err) => { warn!(error = %err, "failed to load credentialed routes for session"); - Vec::new() + CredentialedRoutesSessionConfig::default() } } } + +impl CredentialedRoutesSessionConfig { + pub(crate) fn mitm_hooks(&self) -> Vec { + let Some(proxy_url) = self.proxy_url.as_ref() else { + return Vec::new(); + }; + + self.routes + .iter() + .filter_map(|route| match route_mitm_hook(route, proxy_url) { + Ok(hook) => Some(hook), + Err(err) => { + warn!( + connector_id = %route.connector_id, + link_id = %route.link_id, + base_url = %route.base_url, + error = %err, + "skipping invalid credentialed route" + ); + None + } + }) + .collect() + } +} + +fn route_mitm_hook( + route: &ResolvedCredentialRoute, + proxy_url: &str, +) -> anyhow::Result { + let base_url = Url::parse(&route.base_url)?; + anyhow::ensure!( + base_url.scheme() == "https", + "credentialed route must use https" + ); + let host = base_url + .host_str() + .ok_or_else(|| anyhow::anyhow!("credentialed route must include a host"))?; + anyhow::ensure!( + base_url.username().is_empty() && base_url.password().is_none(), + "credentialed route must not include user info" + ); + anyhow::ensure!( + base_url.fragment().is_none() && base_url.query().is_none(), + "credentialed route must not include query or fragment" + ); + let path_prefix = match base_url.path() { + "" => "/", + path => path, + }; + + Ok(MitmHookConfig { + host: host.to_string(), + matcher: MitmHookMatchConfig { + methods: vec![ + "DELETE".to_string(), + "GET".to_string(), + "HEAD".to_string(), + "PATCH".to_string(), + "POST".to_string(), + "PUT".to_string(), + ], + path_prefixes: vec![path_prefix.to_string()], + ..MitmHookMatchConfig::default() + }, + actions: MitmHookActionsConfig { + credentialed_route_proxy: Some(CredentialedRouteProxyActionConfig { + connector_id: route.connector_id.clone(), + link_id: route.link_id.clone(), + proxy_url: proxy_url.to_string(), + }), + ..MitmHookActionsConfig::default() + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_backend_client::CredentialRouteAuthType; + use pretty_assertions::assert_eq; + + #[test] + fn credentialed_routes_compile_to_internal_mitm_hooks() { + let config = CredentialedRoutesSessionConfig { + routes: vec![ResolvedCredentialRoute { + connector_id: "connector_123".to_string(), + link_id: "link_123".to_string(), + auth_type: CredentialRouteAuthType::OAuth, + base_url: "https://api.example.com/v1".to_string(), + }], + proxy_url: Some( + "https://chatgpt.com/backend-api/wham/credential_routes/proxy".to_string(), + ), + }; + + let hooks = config.mitm_hooks(); + + assert_eq!(hooks.len(), 1); + assert_eq!(hooks[0].host, "api.example.com"); + assert_eq!(hooks[0].matcher.path_prefixes, vec!["/v1".to_string()]); + assert_eq!( + hooks[0].actions.credentialed_route_proxy, + Some(CredentialedRouteProxyActionConfig { + connector_id: "connector_123".to_string(), + link_id: "link_123".to_string(), + proxy_url: "https://chatgpt.com/backend-api/wham/credential_routes/proxy" + .to_string(), + }) + ); + } +} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index db17f81432..e6c1d0cc87 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -877,7 +877,7 @@ async fn thread_title_from_thread_store( struct ManagedNetworkProxyStartParams<'a> { spec: &'a crate::config::NetworkProxySpec, - credentialed_routes: &'a [codex_backend_client::ResolvedCredentialRoute], + credentialed_routes: &'a crate::credentialed_routes::CredentialedRoutesSessionConfig, exec_policy: &'a codex_execpolicy::Policy, permission_profile: &'a PermissionProfile, network_policy_decider: Option>, @@ -961,10 +961,18 @@ impl Session { }: ManagedNetworkProxyStartParams<'_>, ) -> anyhow::Result<(StartedNetworkProxy, SessionNetworkProxyRuntime)> { debug!( - credentialed_routes = credentialed_routes.len(), + credentialed_routes = credentialed_routes.routes.len(), "starting managed network proxy" ); let spec = spec + .with_credentialed_routes(credentialed_routes) + .map_err(|err| { + tracing::warn!( + "failed to apply credentialed routes to managed proxy; continuing without credentialed routes: {err}" + ); + err + }) + .unwrap_or_else(|_| spec.clone()) .with_exec_policy_network_rules(exec_policy) .map_err(|err| { tracing::warn!( @@ -1023,6 +1031,15 @@ impl Session { return; } }; + let spec = match spec.with_credentialed_routes(&self.services.credentialed_routes) { + Ok(spec) => spec, + Err(err) => { + warn!( + "failed to apply credentialed routes while refreshing managed network proxy: {err}" + ); + return; + } + }; let current_exec_policy = self.services.exec_policy.current(); let spec = match spec.with_exec_policy_network_rules(current_exec_policy.as_ref()) { Ok(spec) => spec, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 2d9fda49b2..a19a04b942 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -811,11 +811,14 @@ impl Session { Arc::clone(network_policy_decider_session), ) }); + let credentialed_routes = if config.permissions.network.is_some() { + crate::credentialed_routes::load_for_session(&config.chatgpt_base_url, auth) + .await + } else { + crate::credentialed_routes::CredentialedRoutesSessionConfig::default() + }; let (network_proxy, session_network_proxy) = if let Some(spec) = config.permissions.network.as_ref() { - let credentialed_routes = - crate::credentialed_routes::load_for_session(&config.chatgpt_base_url, auth) - .await; let current_exec_policy = exec_policy.current(); let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy( ManagedNetworkProxyStartParams { @@ -923,6 +926,7 @@ impl Session { thread_extension_data, agent_control, network_proxy, + credentialed_routes, network_approval: Arc::clone(&network_approval), state_db: state_db_ctx.clone(), live_thread: live_thread_init.as_ref().cloned(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 03d4f7ac38..ad5d02d22a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -685,7 +685,7 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho let (started_proxy, _) = Session::start_managed_network_proxy(ManagedNetworkProxyStartParams { spec: &spec, - credentialed_routes: &[], + credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default(), exec_policy: &exec_policy, permission_profile: &permission_profile_for_sandbox_policy( &SandboxPolicy::new_workspace_write_policy(), @@ -732,7 +732,7 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() let (started_proxy, _) = Session::start_managed_network_proxy(ManagedNetworkProxyStartParams { spec: &spec, - credentialed_routes: &[], + credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default(), exec_policy: &exec_policy, permission_profile: &permission_profile_for_sandbox_policy( &SandboxPolicy::new_workspace_write_policy(), @@ -774,7 +774,7 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R let (started_proxy, _) = Session::start_managed_network_proxy(ManagedNetworkProxyStartParams { spec: &spec, - credentialed_routes: &[], + credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default(), exec_policy: &exec_policy, permission_profile: &permission_profile_for_sandbox_policy( &SandboxPolicy::DangerFullAccess, @@ -849,7 +849,7 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow )?; let (started_proxy, _) = Session::start_managed_network_proxy(ManagedNetworkProxyStartParams { spec: &spec, - credentialed_routes: &[], + credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default(), exec_policy: &Policy::empty(), permission_profile: &permission_profile_for_sandbox_policy(&initial_policy), network_policy_decider: None, @@ -4352,6 +4352,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), agent_control, network_proxy: None, + credentialed_routes: crate::credentialed_routes::CredentialedRoutesSessionConfig::default(), network_approval: Arc::clone(&network_approval), state_db: None, live_thread: None, @@ -6208,6 +6209,7 @@ where thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), agent_control, network_proxy: None, + credentialed_routes: crate::credentialed_routes::CredentialedRoutesSessionConfig::default(), network_approval: Arc::clone(&network_approval), state_db: state_db.clone(), live_thread: None, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index afba409a06..fe0f77828e 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -66,6 +66,7 @@ pub(crate) struct SessionServices { pub(crate) thread_extension_data: ExtensionData, pub(crate) agent_control: AgentControl, pub(crate) network_proxy: Option, + pub(crate) credentialed_routes: crate::credentialed_routes::CredentialedRoutesSessionConfig, pub(crate) network_approval: Arc, pub(crate) state_db: Option, pub(crate) live_thread: Option, diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 76316c8359..637f6a4682 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -24,6 +24,7 @@ pub use config::NetworkProxyConfig; pub use config::NetworkUnixSocketPermission; pub use config::NetworkUnixSocketPermissions; pub use config::host_and_port_from_network_addr; +pub use mitm_hook::CredentialedRouteProxyActionConfig; pub use mitm_hook::InjectedHeaderConfig; pub use mitm_hook::MitmHookActionsConfig; pub use mitm_hook::MitmHookBodyConfig; diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index b14b39ea5e..9056e48f50 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -47,6 +47,7 @@ use std::task::Context as TaskContext; use std::task::Poll; use tracing::info; use tracing::warn; +use url::Url; /// State needed to terminate a CONNECT tunnel and enforce policy on inner HTTPS requests. pub struct MitmState { @@ -228,10 +229,22 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu 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 - .headers - .insert(HOST, HeaderValue::from_str(&authority)?); + let original_request_uri = build_https_uri(&authority, &path)?; + if let Some(actions) = hook_actions.as_ref() + && let Some(credentialed_route_proxy) = actions.credentialed_route_proxy.as_ref() + { + let (proxy_uri, proxy_authority) = + credentialed_route_proxy_request(credentialed_route_proxy, &original_request_uri)?; + parts.uri = proxy_uri; + parts + .headers + .insert(HOST, HeaderValue::from_str(&proxy_authority)?); + } else { + parts.uri = original_request_uri; + parts + .headers + .insert(HOST, HeaderValue::from_str(&authority)?); + } let inspect = mitm.inspect_enabled(); let max_body_bytes = mitm.max_body_bytes(); @@ -547,6 +560,31 @@ fn build_https_uri(authority: &str, path: &str) -> Result { Ok(target.parse()?) } +fn credentialed_route_proxy_request( + action: &crate::mitm_hook::CredentialedRouteProxyAction, + request_uri: &Uri, +) -> Result<(Uri, String)> { + let mut proxy_url = Url::parse(&action.proxy_url) + .with_context(|| format!("invalid credentialed route proxy URL {}", action.proxy_url))?; + proxy_url + .query_pairs_mut() + .append_pair("link_id", &action.link_id) + .append_pair("connector_id", &action.connector_id) + .append_pair("request_url", &request_uri.to_string()); + let proxy_authority = url_authority_header_value(&proxy_url)?; + Ok((proxy_url.as_str().parse()?, proxy_authority)) +} + +fn url_authority_header_value(url: &Url) -> Result { + let host = url + .host_str() + .ok_or_else(|| anyhow!("URL must include a host"))?; + let port = url + .port_or_known_default() + .ok_or_else(|| anyhow!("URL must include a known port"))?; + Ok(authority_header_value(host, port)) +} + fn path_and_query(uri: &Uri) -> String { uri.path_and_query() .map(rama_http::uri::PathAndQuery::as_str) diff --git a/codex-rs/network-proxy/src/mitm_hook.rs b/codex-rs/network-proxy/src/mitm_hook.rs index 6262dcd4ff..7fe4d81766 100644 --- a/codex-rs/network-proxy/src/mitm_hook.rs +++ b/codex-rs/network-proxy/src/mitm_hook.rs @@ -47,6 +47,8 @@ pub struct MitmHookMatchConfig { pub struct MitmHookActionsConfig { pub strip_request_headers: Vec, pub inject_request_headers: Vec, + #[serde(skip)] + pub credentialed_route_proxy: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] @@ -94,6 +96,7 @@ pub struct HeaderConstraint { pub struct MitmHookActions { pub strip_request_headers: Vec, pub inject_request_headers: Vec, + pub credentialed_route_proxy: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -109,6 +112,20 @@ pub enum SecretSource { File(AbsolutePathBuf), } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CredentialedRouteProxyActionConfig { + pub connector_id: String, + pub link_id: String, + pub proxy_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CredentialedRouteProxyAction { + pub connector_id: String, + pub link_id: String, + pub proxy_url: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MitmHookBodyMatcher { pub raw: serde_json::Value, @@ -315,6 +332,15 @@ where .with_context(|| format!("failed to compile injected header {}", header.name)) }) .collect::>>()?; + let credentialed_route_proxy = + hook.actions + .credentialed_route_proxy + .as_ref() + .map(|action| CredentialedRouteProxyAction { + connector_id: action.connector_id.clone(), + link_id: action.link_id.clone(), + proxy_url: action.proxy_url.clone(), + }); hooks_by_host .entry(host.clone()) @@ -331,6 +357,7 @@ where actions: MitmHookActions { strip_request_headers, inject_request_headers, + credentialed_route_proxy, }, }); } @@ -676,6 +703,7 @@ mod tests { secret_file: None, prefix: Some("Bearer ".to_string()), }], + credentialed_route_proxy: None, }, } } diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index 3db22fbd72..b069cafbb5 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -32,6 +32,7 @@ fn github_write_hook() -> crate::mitm_hook::MitmHookConfig { secret_file: None, prefix: Some("Bearer ".to_string()), }], + credentialed_route_proxy: None, }, } } @@ -261,6 +262,7 @@ fn apply_mitm_hook_actions_replaces_authorization_header() { AbsolutePathBuf::try_from("/tmp/github-token").unwrap(), ), }], + credentialed_route_proxy: None, }; apply_mitm_hook_actions(&mut headers, Some(&actions)); @@ -274,3 +276,22 @@ fn apply_mitm_hook_actions_replaces_authorization_header() { Some(&HeaderValue::from_static("req_123")) ); } + +#[test] +fn credentialed_route_proxy_request_rewrites_upstream_target() { + let action = crate::mitm_hook::CredentialedRouteProxyAction { + connector_id: "connector_123".to_string(), + link_id: "link_123".to_string(), + proxy_url: "http://localhost:8080/api/codex/credential_routes/proxy".to_string(), + }; + let request_uri: Uri = "https://api.example.com/v1/items?limit=5".parse().unwrap(); + + let (proxy_uri, proxy_authority) = + credentialed_route_proxy_request(&action, &request_uri).unwrap(); + + assert_eq!( + proxy_uri.to_string(), + "http://localhost:8080/api/codex/credential_routes/proxy?link_id=link_123&connector_id=connector_123&request_url=https%3A%2F%2Fapi.example.com%2Fv1%2Fitems%3Flimit%3D5" + ); + assert_eq!(proxy_authority, "localhost:8080"); +}