Route credentialed traffic through MITM proxy

This commit is contained in:
Winston Howes
2026-05-15 10:21:12 -07:00
parent afc19347e2
commit 1e2356c6f4
11 changed files with 320 additions and 18 deletions
@@ -177,6 +177,37 @@ impl NetworkProxySpec {
Ok(spec)
}
pub(crate) fn with_credentialed_routes(
&self,
credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig,
) -> std::io::Result<Self> {
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,
@@ -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();
+131 -5
View File
@@ -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<ResolvedCredentialRoute>,
pub(crate) proxy_url: Option<String>,
}
pub(crate) async fn load_for_session(
chatgpt_base_url: &str,
auth: Option<&CodexAuth>,
) -> Vec<ResolvedCredentialRoute> {
) -> 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<MitmHookConfig> {
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<MitmHookConfig> {
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(),
})
);
}
}
+19 -2
View File
@@ -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<Arc<dyn codex_network_proxy::NetworkPolicyDecider>>,
@@ -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,
+7 -3
View File
@@ -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(),
+6 -4
View File
@@ -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,
+1
View File
@@ -66,6 +66,7 @@ pub(crate) struct SessionServices {
pub(crate) thread_extension_data: ExtensionData,
pub(crate) agent_control: AgentControl,
pub(crate) network_proxy: Option<StartedNetworkProxy>,
pub(crate) credentialed_routes: crate::credentialed_routes::CredentialedRoutesSessionConfig,
pub(crate) network_approval: Arc<NetworkApprovalService>,
pub(crate) state_db: Option<StateDbHandle>,
pub(crate) live_thread: Option<LiveThread>,
+1
View File
@@ -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;
+42 -4
View File
@@ -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<Uri> {
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<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)
+28
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,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::<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_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,
},
}
}
+21
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,
},
}
}
@@ -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");
}