Skip to content

Commit c444377

Browse files
committed
Wire MITM hooks into runtime enforcement
1 parent 5c1bf13 commit c444377

10 files changed

Lines changed: 419 additions & 25 deletions

File tree

codex-rs/core/src/config/permissions.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ fn profile_network_requires_proxy(network: &NetworkToml) -> bool {
127127
|| network.allow_upstream_proxy == Some(true)
128128
|| network.dangerously_allow_non_loopback_proxy == Some(true)
129129
|| network.dangerously_allow_all_unix_sockets == Some(true)
130+
|| network.mitm == Some(true)
131+
|| network
132+
.mitm_hooks
133+
.as_ref()
134+
.is_some_and(|hooks| !hooks.is_empty())
130135
|| network.mode.is_some()
131136
|| network
132137
.domains

codex-rs/core/src/config/permissions_tests.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml;
1111
use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml;
1212
use codex_config::permissions_toml::PermissionProfileToml;
1313
use codex_config::permissions_toml::PermissionsToml;
14+
use codex_network_proxy::MitmHookConfig;
15+
use codex_network_proxy::MitmHookMatchConfig;
1416
use codex_protocol::permissions::FileSystemAccessMode;
1517
use codex_protocol::permissions::FileSystemPath;
1618
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -246,6 +248,28 @@ fn profile_network_proxy_config_keeps_proxy_disabled_for_bare_network_access() {
246248
assert!(!config.network.enabled);
247249
}
248250

251+
#[test]
252+
fn profile_network_proxy_config_enables_proxy_for_mitm_hooks() {
253+
let config = network_proxy_config_from_profile_network(Some(&NetworkToml {
254+
enabled: Some(true),
255+
mitm: Some(true),
256+
mitm_hooks: Some(vec![MitmHookConfig {
257+
host: "api.github.com".to_string(),
258+
matcher: MitmHookMatchConfig {
259+
methods: vec!["POST".to_string()],
260+
path_prefixes: vec!["/repos/openai/".to_string()],
261+
..MitmHookMatchConfig::default()
262+
},
263+
..MitmHookConfig::default()
264+
}]),
265+
..Default::default()
266+
}));
267+
268+
assert!(config.network.enabled);
269+
assert!(config.network.mitm);
270+
assert_eq!(config.network.mitm_hooks.len(), 1);
271+
}
272+
249273
#[test]
250274
fn profile_network_proxy_config_enables_proxy_for_proxy_policy() {
251275
let config = network_proxy_config_from_profile_network(Some(&NetworkToml {

codex-rs/network-proxy/README.md

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
- an HTTP proxy (default `127.0.0.1:3128`)
66
- a SOCKS5 proxy (default `127.0.0.1:8081`, enabled by default)
77

8-
It enforces an allow/deny policy and a "limited" mode intended for read-only network access.
8+
It enforces an allow/deny policy, a "limited" mode intended for read-only network access, and
9+
host-specific HTTPS MITM hooks for request matching and header injection.
910

1011
## Quickstart
1112

@@ -32,8 +33,9 @@ allow_upstream_proxy = true
3233
# By default, non-loopback binds are clamped to loopback for safety.
3334
# If you want to expose these listeners beyond localhost, you must opt in explicitly.
3435
dangerously_allow_non_loopback_proxy = false
35-
mode = "full" # default when unset; use "limited" for read-only mode
36-
# When true, HTTPS CONNECT can be terminated so limited-mode method policy still applies.
36+
mode = "full" # default when unset; hooks can still clamp specific HTTPS hosts
37+
# When true, HTTPS CONNECT can be terminated so limited-mode policy and host-specific MITM hooks
38+
# can inspect inner requests.
3739
mitm = false
3840
# CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key).
3941

@@ -60,6 +62,48 @@ dangerously_allow_all_unix_sockets = false
6062
# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`.
6163
[permissions.workspace.network.unix_sockets]
6264
"/tmp/example.sock" = "allow"
65+
66+
[[permissions.workspace.network.mitm_hooks]]
67+
host = "api.github.com"
68+
69+
[permissions.workspace.network.mitm_hooks.match]
70+
methods = ["POST", "PUT"]
71+
path_prefixes = ["/repos/openai/"]
72+
73+
[permissions.workspace.network.mitm_hooks.match.headers]
74+
"x-github-api-version" = ["2022-11-28"]
75+
76+
[permissions.workspace.network.mitm_hooks.actions]
77+
strip_request_headers = ["authorization"]
78+
79+
[[permissions.workspace.network.mitm_hooks.actions.inject_request_headers]]
80+
name = "authorization"
81+
secret_env_var = "CODEX_GITHUB_TOKEN"
82+
prefix = "Bearer "
83+
84+
# `match.body` is reserved for a future release. Current hooks match only on
85+
# method/path/query/headers and can mutate outbound request headers.
86+
# `match.path_prefixes` accepts literal prefixes by default. Prefix path,
87+
# query, or header matchers with `pattern:` to opt into wildcard matching.
88+
# Use `literal:` when a literal value must start with a reserved prefix.
89+
# In paths, `*` and `?` do not match `/`; use `**` when crossing segments is intended.
90+
```
91+
92+
Matcher syntax example:
93+
94+
```toml
95+
[permissions.workspace.network.mitm_hooks.match]
96+
# Literal path prefixes are the default.
97+
path_prefixes = [
98+
"/repos/openai/",
99+
"pattern:/repos/*/codex/issues*",
100+
]
101+
102+
[permissions.workspace.network.mitm_hooks.match.query]
103+
state = ["open", "pattern:triage*", "literal:pattern:*"]
104+
105+
[permissions.workspace.network.mitm_hooks.match.headers]
106+
"x-github-api-version" = ["2022-11-28", "pattern:2022*preview"]
63107
```
64108

65109
### 2) Run the proxy
@@ -92,12 +136,16 @@ When a request is blocked, the proxy responds with `403` and includes:
92136
- `x-proxy-error`: one of:
93137
- `blocked-by-allowlist`
94138
- `blocked-by-denylist`
139+
- `blocked-by-mitm-hook`
95140
- `blocked-by-method-policy`
141+
- `blocked-by-mitm-required`
96142
- `blocked-by-policy`
97143

98144
In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed. HTTPS `CONNECT` requests require
99-
MITM to enforce limited-mode method policy; otherwise they are blocked. SOCKS5 remains blocked in
100-
limited mode.
145+
MITM to enforce limited-mode method policy; otherwise they are blocked. Separately, hosts covered
146+
by `mitm_hooks` are authoritative even when `mode = "full"`: hooks are evaluated in order, the
147+
first match wins, and if no hook matches the inner HTTPS request is denied with
148+
`blocked-by-mitm-hook`. SOCKS5 remains blocked in limited mode.
101149

102150
Websocket clients typically tunnel `wss://` through HTTPS `CONNECT`; those CONNECT targets still go
103151
through the same host allowlist/denylist checks.

codex-rs/network-proxy/src/http_proxy.rs

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ use tracing::error;
8080
use tracing::info;
8181
use tracing::warn;
8282

83+
#[derive(Clone, Copy, Debug)]
84+
struct ConnectMitmEnabled(bool);
85+
8386
pub async fn run_http_proxy(
8487
state: Arc<NetworkProxyState>,
8588
addr: SocketAddr,
@@ -256,10 +259,18 @@ async fn http_connect_accept(
256259
return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
257260
}
258261
};
262+
let host_has_mitm_hooks = match app_state.host_has_mitm_hooks(&host).await {
263+
Ok(has_hooks) => has_hooks,
264+
Err(err) => {
265+
error!("failed to inspect MITM hooks for {host}: {err}");
266+
return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
267+
}
268+
};
269+
let connect_needs_mitm = mode == NetworkMode::Limited || host_has_mitm_hooks;
259270

260-
if mode == NetworkMode::Limited && mitm_state.is_none() {
261-
// Limited mode is designed to be read-only. Without MITM, a CONNECT tunnel would hide the
262-
// inner HTTP method/headers from the proxy, effectively bypassing method policy.
271+
if connect_needs_mitm && mitm_state.is_none() {
272+
// CONNECT needs MITM whenever HTTPS policy depends on inner-request inspection, either for
273+
// limited-mode method enforcement or for host-specific MITM hooks.
263274
emit_http_block_decision_audit_event(
264275
&app_state,
265276
BlockDecisionAuditEventArgs {
@@ -286,7 +297,7 @@ async fn http_connect_accept(
286297
reason: REASON_MITM_REQUIRED.to_string(),
287298
client: client.clone(),
288299
method: Some("CONNECT".to_string()),
289-
mode: Some(NetworkMode::Limited),
300+
mode: Some(mode),
290301
protocol: "http-connect".to_string(),
291302
decision: Some(details.decision.as_str().to_string()),
292303
source: Some(details.source.as_str().to_string()),
@@ -295,14 +306,16 @@ async fn http_connect_accept(
295306
.await;
296307
let client = client.as_deref().unwrap_or_default();
297308
warn!(
298-
"CONNECT blocked; MITM required for read-only HTTPS in limited mode (client={client}, host={host}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)"
309+
"CONNECT blocked; MITM required to enforce HTTPS policy (client={client}, host={host}, mode={mode:?}, hooked_host={host_has_mitm_hooks})"
299310
);
300311
return Err(blocked_text_with_details(REASON_MITM_REQUIRED, &details));
301312
}
302313

303314
req.extensions_mut().insert(ProxyTarget(authority));
315+
req.extensions_mut()
316+
.insert(ConnectMitmEnabled(connect_needs_mitm));
304317
req.extensions_mut().insert(mode);
305-
if let Some(mitm_state) = mitm_state {
318+
if connect_needs_mitm && let Some(mitm_state) = mitm_state {
306319
req.extensions_mut().insert(mitm_state);
307320
}
308321

@@ -331,7 +344,10 @@ async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> {
331344
return Ok(());
332345
};
333346

334-
if mode == NetworkMode::Limited
347+
if upgraded
348+
.extensions()
349+
.get::<ConnectMitmEnabled>()
350+
.is_some_and(|enabled| enabled.0)
335351
&& upgraded
336352
.extensions()
337353
.get::<Arc<mitm::MitmState>>()
@@ -1094,6 +1110,42 @@ mod tests {
10941110
assert_eq!(response.status(), StatusCode::OK);
10951111
}
10961112

1113+
#[tokio::test]
1114+
async fn http_connect_accept_blocks_hooked_host_in_full_mode_without_mitm_state() {
1115+
let mut policy = NetworkProxySettings {
1116+
mitm: true,
1117+
mitm_hooks: vec![crate::mitm_hook::MitmHookConfig {
1118+
host: "api.github.com".to_string(),
1119+
matcher: crate::mitm_hook::MitmHookMatchConfig {
1120+
methods: vec!["POST".to_string()],
1121+
path_prefixes: vec!["/repos/openai/".to_string()],
1122+
..crate::mitm_hook::MitmHookMatchConfig::default()
1123+
},
1124+
actions: crate::mitm_hook::MitmHookActionsConfig::default(),
1125+
}],
1126+
..Default::default()
1127+
};
1128+
policy.set_allowed_domains(vec!["api.github.com".to_string()]);
1129+
let state = Arc::new(network_proxy_state_for_policy(policy));
1130+
1131+
let mut req = Request::builder()
1132+
.method(Method::CONNECT)
1133+
.uri("https://api.github.com:443")
1134+
.header("host", "api.github.com:443")
1135+
.body(Body::empty())
1136+
.unwrap();
1137+
req.extensions_mut().insert(state);
1138+
1139+
let response = http_connect_accept(/*policy_decider*/ None, req)
1140+
.await
1141+
.unwrap_err();
1142+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
1143+
assert_eq!(
1144+
response.headers().get("x-proxy-error").unwrap(),
1145+
"blocked-by-mitm-required"
1146+
);
1147+
}
1148+
10971149
#[tokio::test]
10981150
async fn http_proxy_listener_accepts_plain_http1_connect_requests() {
10991151
let target_listener = TokioTcpListener::bind((Ipv4Addr::LOCALHOST, 0))

0 commit comments

Comments
 (0)