From c56b36d04f33e01a8cdf67b915a62ca4baaca1e3 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 10 Jun 2026 16:39:19 -0700 Subject: [PATCH] Route credentialed traffic through MITM proxy --- codex-rs/backend-client/src/client.rs | 12 +++ .../src/chatgpt_cloudflare_cookies.rs | 4 + codex-rs/codex-client/src/lib.rs | 1 + codex-rs/network-proxy/src/lib.rs | 2 + codex-rs/network-proxy/src/mitm.rs | 73 ++++++++++++++++++- codex-rs/network-proxy/src/mitm_hook.rs | 46 ++++++++++++ codex-rs/network-proxy/src/mitm_tests.rs | 52 +++++++++++++ 7 files changed, 186 insertions(+), 4 deletions(-) diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 4e6785eca0..50a48e3063 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -10,6 +10,7 @@ use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_api::SharedAuthProvider; use codex_client::build_reqwest_client_with_custom_ca; +use codex_client::chatgpt_cloudflare_cookie_header; use codex_client::with_chatgpt_cloudflare_cookie_store; use codex_login::CodexAuth; use codex_login::default_client::get_codex_user_agent; @@ -21,6 +22,7 @@ use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::SpendControlLimitSnapshot; use reqwest::StatusCode; use reqwest::header::CONTENT_TYPE; +use reqwest::header::COOKIE; use reqwest::header::HeaderMap; use reqwest::header::HeaderName; use reqwest::header::HeaderValue; @@ -450,6 +452,16 @@ impl Client { } } + pub fn credential_routes_proxy_headers(&self) -> HeaderMap { + let mut headers = self.headers(); + if let Ok(proxy_url) = reqwest::Url::parse(&self.credential_routes_proxy_url()) + && let Some(cookie) = chatgpt_cloudflare_cookie_header(&proxy_url) + { + headers.insert(COOKIE, cookie); + } + headers + } + /// Create a new task (user turn) by POSTing to the appropriate backend path /// based on `path_style`. Returns the created task id. pub async fn create_task(&self, request_body: serde_json::Value) -> Result { diff --git a/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs b/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs index c5f4bbd4eb..55fec099a3 100644 --- a/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs +++ b/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs @@ -55,6 +55,10 @@ pub fn with_chatgpt_cloudflare_cookie_store( builder.cookie_provider(Arc::clone(&SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE)) } +pub fn chatgpt_cloudflare_cookie_header(url: &reqwest::Url) -> Option { + SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE.cookies(url) +} + fn is_chatgpt_cookie_url(url: &reqwest::Url) -> bool { match url.scheme() { "https" => {} diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 0f503fb3e2..bafe8524fb 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -9,6 +9,7 @@ mod sse; mod telemetry; mod transport; +pub use crate::chatgpt_cloudflare_cookies::chatgpt_cloudflare_cookie_header; pub use crate::chatgpt_cloudflare_cookies::with_chatgpt_cloudflare_cookie_store; pub use crate::chatgpt_hosts::is_allowed_chatgpt_host; pub use crate::custom_ca::BuildCustomCaTransportError; diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 9ad16f8ecc..d3e8db33c4 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -26,6 +26,8 @@ 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::CredentialedRouteProxyHeader; 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 345c5b5032..ce9d752820 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -36,6 +36,7 @@ use rama_http::Response; use rama_http::StatusCode; use rama_http::Uri; use rama_http::header::HOST; +use rama_http::header::HeaderName; use rama_http::layer::remove_header::RemoveRequestHeaderLayer; use rama_http::layer::remove_header::RemoveResponseHeaderLayer; use rama_http_backend::server::HttpServer; @@ -50,6 +51,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 { @@ -87,6 +89,10 @@ enum MitmPolicyDecision { const MITM_INSPECT_BODIES: bool = false; const MITM_MAX_BODY_BYTES: usize = 4096; +const CREDENTIAL_ROUTE_CONNECTOR_ID_HEADER: &str = + "x-openai-internal-credential-route-connector-id"; +const CREDENTIAL_ROUTE_LINK_ID_HEADER: &str = "x-openai-internal-credential-route-link-id"; +const CREDENTIAL_ROUTE_REQUEST_URL_HEADER: &str = "x-openai-internal-credential-route-request-url"; impl std::fmt::Debug for MitmState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -241,10 +247,27 @@ 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)?; + apply_credentialed_route_proxy_headers( + &mut parts.headers, + 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(); @@ -556,6 +579,48 @@ fn build_https_uri(authority: &str, path: &str) -> Result { Ok(target.parse()?) } +fn credentialed_route_proxy_request( + action: &crate::mitm_hook::CredentialedRouteProxyAction, +) -> Result<(Uri, String)> { + let proxy_url = Url::parse(&action.proxy_url) + .with_context(|| format!("invalid credentialed route proxy URL {}", action.proxy_url))?; + let proxy_authority = url_authority_header_value(&proxy_url)?; + Ok((proxy_url.as_str().parse()?, proxy_authority)) +} + +fn apply_credentialed_route_proxy_headers( + headers: &mut HeaderMap, + action: &crate::mitm_hook::CredentialedRouteProxyAction, + request_uri: &Uri, +) -> Result<()> { + for proxy_header in &action.proxy_headers { + headers.insert(proxy_header.name.clone(), proxy_header.value.clone()); + } + headers.insert( + HeaderName::from_static(CREDENTIAL_ROUTE_CONNECTOR_ID_HEADER), + HeaderValue::from_str(&action.connector_id)?, + ); + headers.insert( + HeaderName::from_static(CREDENTIAL_ROUTE_LINK_ID_HEADER), + HeaderValue::from_str(&action.link_id)?, + ); + headers.insert( + HeaderName::from_static(CREDENTIAL_ROUTE_REQUEST_URL_HEADER), + HeaderValue::from_str(&request_uri.to_string())?, + ); + Ok(()) +} + +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..ac8d61c28d 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,37 @@ pub enum SecretSource { File(AbsolutePathBuf), } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CredentialedRouteProxyActionConfig { + pub connector_id: String, + pub link_id: String, + pub proxy_headers: Vec, + pub proxy_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CredentialedRouteProxyAction { + pub connector_id: String, + pub link_id: String, + pub proxy_headers: Vec, + pub proxy_url: String, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct CredentialedRouteProxyHeader { + pub name: HeaderName, + pub value: HeaderValue, +} + +impl std::fmt::Debug for CredentialedRouteProxyHeader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CredentialedRouteProxyHeader") + .field("name", &self.name) + .field("value", &"") + .finish() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MitmHookBodyMatcher { pub raw: serde_json::Value, @@ -315,6 +349,16 @@ 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_headers: action.proxy_headers.clone(), + proxy_url: action.proxy_url.clone(), + }); hooks_by_host .entry(host.clone()) @@ -331,6 +375,7 @@ where actions: MitmHookActions { strip_request_headers, inject_request_headers, + credentialed_route_proxy, }, }); } @@ -676,6 +721,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 0823be517c..c4f54a4358 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, }, } } @@ -305,6 +306,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)); @@ -318,3 +320,53 @@ 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_headers: vec![crate::mitm_hook::CredentialedRouteProxyHeader { + name: HeaderName::from_static("authorization"), + value: HeaderValue::from_static("Bearer codex-token"), + }], + 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 mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("authorization"), + HeaderValue::from_static("Bearer provider-token"), + ); + headers.insert( + HeaderName::from_static(CREDENTIAL_ROUTE_REQUEST_URL_HEADER), + HeaderValue::from_static("https://spoofed.example.com"), + ); + + let (proxy_uri, proxy_authority) = credentialed_route_proxy_request(&action).unwrap(); + apply_credentialed_route_proxy_headers(&mut headers, &action, &request_uri).unwrap(); + + assert_eq!( + proxy_uri.to_string(), + "http://localhost:8080/api/codex/credential_routes/proxy" + ); + assert_eq!(proxy_authority, "localhost:8080"); + assert_eq!( + headers.get("authorization"), + Some(&HeaderValue::from_static("Bearer codex-token")) + ); + assert_eq!( + headers.get(CREDENTIAL_ROUTE_CONNECTOR_ID_HEADER), + Some(&HeaderValue::from_static("connector_123")) + ); + assert_eq!( + headers.get(CREDENTIAL_ROUTE_LINK_ID_HEADER), + Some(&HeaderValue::from_static("link_123")) + ); + assert_eq!( + headers.get(CREDENTIAL_ROUTE_REQUEST_URL_HEADER), + Some(&HeaderValue::from_static( + "https://api.example.com/v1/items?limit=5" + )) + ); +}