Route credentialed traffic through MITM proxy

This commit is contained in:
Winston Howes
2026-06-10 17:48:10 -07:00
parent fc5812247d
commit c56b36d04f
7 changed files with 186 additions and 4 deletions
+12
View File
@@ -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<String> {
@@ -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<HeaderValue> {
SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE.cookies(url)
}
fn is_chatgpt_cookie_url(url: &reqwest::Url) -> bool {
match url.scheme() {
"https" => {}
+1
View File
@@ -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;
+2
View File
@@ -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;
+69 -4
View File
@@ -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<Uri> {
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<String> {
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)
+46
View File
@@ -47,6 +47,8 @@ pub struct MitmHookMatchConfig {
pub struct MitmHookActionsConfig {
pub strip_request_headers: Vec<String>,
pub inject_request_headers: Vec<InjectedHeaderConfig>,
#[serde(skip)]
pub credentialed_route_proxy: Option<CredentialedRouteProxyActionConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
@@ -94,6 +96,7 @@ pub struct HeaderConstraint {
pub struct MitmHookActions {
pub strip_request_headers: Vec<HeaderName>,
pub inject_request_headers: Vec<ResolvedInjectedHeader>,
pub credentialed_route_proxy: Option<CredentialedRouteProxyAction>,
}
#[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<CredentialedRouteProxyHeader>,
pub proxy_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialedRouteProxyAction {
pub connector_id: String,
pub link_id: String,
pub proxy_headers: Vec<CredentialedRouteProxyHeader>,
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", &"<redacted>")
.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::<Result<Vec<_>>>()?;
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,
},
}
}
+52
View File
@@ -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"
))
);
}