Skip to content

Commit 7a0e974

Browse files
kevinlin-openaicopyberry
authored andcommitted
Harden network proxy MITM authorization (#37211)
## Why MITM hooks authorize requests before the upstream server parses them. Paths that can be decoded or normalized to a different resource must not match an allowed path, and hosts that require MITM inspection must not bypass it through the plain HTTP proxy path. ## What changed - Reject ambiguous hook paths, including traversal segments, backslashes, malformed percent encodings, and encoded separators or percent signs. - Block plain HTTP proxy requests for hosts whose policy always requires MITM, recording the decision as `mitm_required`. ## Testing - Cover safe and ambiguous path forms, encoded traversal through repository allowlists, and absolute-form HTTPS requests sent to the HTTP proxy. GitOrigin-RevId: 8812a980ac64a97cbac3a237376d29be5ded9220
1 parent 1ae82ce commit 7a0e974

6 files changed

Lines changed: 353 additions & 1 deletion

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/// Returns whether `path` has an unambiguous interpretation for authorization.
2+
///
3+
/// MITM hooks authorize the request before the upstream server parses it. Reject
4+
/// path forms that common upstreams may decode or normalize into a different
5+
/// resource after a hook has matched.
6+
pub(crate) fn is_safe_for_authorization(path: &str) -> bool {
7+
path.split('/').all(is_safe_segment_for_authorization)
8+
}
9+
10+
fn is_safe_segment_for_authorization(segment: &str) -> bool {
11+
let bytes = segment.as_bytes();
12+
let mut index = 0;
13+
let mut decoded_dots = 0;
14+
let mut has_non_dot = false;
15+
while index < bytes.len() {
16+
match bytes[index] {
17+
b'.' => {
18+
decoded_dots += 1;
19+
index += 1;
20+
}
21+
b'\\' => return false,
22+
b'%' => {
23+
let Some(high) = bytes
24+
.get(index + 1)
25+
.and_then(|byte| decode_hex_digit(*byte))
26+
else {
27+
return false;
28+
};
29+
let Some(low) = bytes
30+
.get(index + 2)
31+
.and_then(|byte| decode_hex_digit(*byte))
32+
else {
33+
return false;
34+
};
35+
let decoded = high << 4 | low;
36+
match decoded {
37+
b'%' | b'/' | b'\\' => return false,
38+
b'.' => decoded_dots += 1,
39+
_ => has_non_dot = true,
40+
}
41+
index += 3;
42+
}
43+
_ => {
44+
has_non_dot = true;
45+
index += 1;
46+
}
47+
}
48+
}
49+
50+
has_non_dot || !matches!(decoded_dots, 1 | 2)
51+
}
52+
53+
fn decode_hex_digit(byte: u8) -> Option<u8> {
54+
match byte {
55+
b'0'..=b'9' => Some(byte - b'0'),
56+
b'a'..=b'f' => Some(byte - b'a' + 10),
57+
b'A'..=b'F' => Some(byte - b'A' + 10),
58+
_ => None,
59+
}
60+
}
61+
62+
#[cfg(test)]
63+
#[path = "authorization_path_tests.rs"]
64+
mod tests;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use super::is_safe_for_authorization;
2+
use pretty_assertions::assert_eq;
3+
4+
#[test]
5+
fn accepts_unambiguous_paths() {
6+
let paths = [
7+
"/openai/openai",
8+
"/openai/openai/issues/123",
9+
"/openai/openai/a..b",
10+
"/openai/openai/%20space",
11+
"/openai/openai/%E2%9C%93",
12+
"/openai/openai/contents/%2Egitignore",
13+
"/openai/openai/contents/.%2Egithub",
14+
"/openai/openai/%2e%2efoo",
15+
"/openai/openai/%2e%2e%2e",
16+
];
17+
18+
assert_eq!(
19+
paths.map(is_safe_for_authorization),
20+
[true, true, true, true, true, true, true, true, true]
21+
);
22+
}
23+
24+
#[test]
25+
fn rejects_paths_with_ambiguous_segments_or_encodings() {
26+
let paths = [
27+
"/openai/openai/../codex",
28+
"/openai/openai/./issues",
29+
"/openai/openai\\..\\codex",
30+
"/openai/openai/%2e%2e/codex",
31+
"/openai/openai/%2E%2E/codex",
32+
"/openai/openai/%2f..%2fcodex",
33+
"/openai/openai/%5c..%5ccodex",
34+
"/openai/openai/%252e%252e/codex",
35+
"/openai/openai/%",
36+
"/openai/openai/%2",
37+
"/openai/openai/%zz",
38+
];
39+
40+
assert_eq!(
41+
paths.map(is_safe_for_authorization),
42+
[
43+
false, false, false, false, false, false, false, false, false, false, false
44+
]
45+
);
46+
}

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

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,54 @@ async fn http_plain_proxy(
758758
}
759759
}
760760

761+
let host_mitm_requirement = match app_state.host_mitm_requirement(&host).await {
762+
Ok(requirement) => requirement,
763+
Err(err) => {
764+
return Ok(internal_error("failed to inspect MITM requirements", err));
765+
}
766+
};
767+
if host_mitm_requirement == HostMitmRequirement::Always {
768+
emit_http_block_decision_audit_event(
769+
&app_state,
770+
BlockDecisionAuditEventArgs {
771+
source: NetworkDecisionSource::ModeGuard,
772+
reason: REASON_MITM_REQUIRED,
773+
protocol: NetworkProtocol::Http,
774+
server_address: host.as_str(),
775+
server_port: port,
776+
method: Some(req.method().as_str()),
777+
client_addr: client.as_deref(),
778+
},
779+
);
780+
let details = PolicyDecisionDetails {
781+
decision: NetworkPolicyDecision::Deny,
782+
reason: REASON_MITM_REQUIRED,
783+
source: NetworkDecisionSource::ModeGuard,
784+
protocol: NetworkProtocol::Http,
785+
host: &host,
786+
port,
787+
};
788+
let _ = app_state
789+
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
790+
host: host.clone(),
791+
reason: REASON_MITM_REQUIRED.to_string(),
792+
client: client.clone(),
793+
method: Some(req.method().as_str().to_string()),
794+
mode: None,
795+
protocol: "http".to_string(),
796+
decision: Some(details.decision.as_str().to_string()),
797+
source: Some(details.source.as_str().to_string()),
798+
port: Some(port),
799+
}))
800+
.await;
801+
let client = client.as_deref().unwrap_or_default();
802+
warn!(
803+
"request blocked; MITM required to enforce host policy (client={client}, host={host}, method={})",
804+
req.method()
805+
);
806+
return Ok(json_blocked(&host, REASON_MITM_REQUIRED, Some(&details)));
807+
}
808+
761809
if !method_allowed {
762810
emit_http_block_decision_audit_event(
763811
&app_state,
@@ -1418,6 +1466,87 @@ mod tests {
14181466
target_task.await.expect("target task should finish");
14191467
}
14201468

1469+
#[tokio::test]
1470+
async fn http_proxy_blocks_absolute_form_https_for_hooked_host() {
1471+
let target_listener = TokioTcpListener::bind((Ipv4Addr::LOCALHOST, 0))
1472+
.await
1473+
.expect("target listener should bind");
1474+
let target_addr = target_listener
1475+
.local_addr()
1476+
.expect("target listener should expose local addr");
1477+
let target_task = tokio::spawn(async move {
1478+
timeout(Duration::from_secs(1), target_listener.accept())
1479+
.await
1480+
.is_ok()
1481+
});
1482+
1483+
let state = Arc::new(network_proxy_state_for_policy({
1484+
let mut network = NetworkProxyConfig {
1485+
allow_local_binding: true,
1486+
mitm: true,
1487+
mitm_hooks: vec![crate::mitm_hook::MitmHookConfig {
1488+
host: "127.0.0.1".to_string(),
1489+
matcher: crate::mitm_hook::MitmHookMatchConfig {
1490+
methods: vec!["GET".to_string()],
1491+
path_prefixes: vec!["/repos/openai/ALLOWED".to_string()],
1492+
..crate::mitm_hook::MitmHookMatchConfig::default()
1493+
},
1494+
actions: crate::mitm_hook::MitmHookActionsConfig::default(),
1495+
}],
1496+
..NetworkProxyConfig::default()
1497+
};
1498+
network.set_allowed_domains(vec!["127.0.0.1".to_string()]);
1499+
network
1500+
}));
1501+
let listener =
1502+
StdTcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("proxy listener should bind");
1503+
let proxy_addr = listener
1504+
.local_addr()
1505+
.expect("proxy listener should expose local addr");
1506+
let proxy_task = tokio::spawn(run_http_proxy_with_std_listener(
1507+
state.clone(),
1508+
listener,
1509+
/*policy_decider*/ None,
1510+
/*environment_id*/ None,
1511+
));
1512+
1513+
let mut stream = tokio::net::TcpStream::connect(proxy_addr)
1514+
.await
1515+
.expect("client should connect to proxy");
1516+
let request = format!(
1517+
"GET https://127.0.0.1:{port}/repos/openai/UNAUTHORIZED HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n",
1518+
port = target_addr.port()
1519+
);
1520+
stream
1521+
.write_all(request.as_bytes())
1522+
.await
1523+
.expect("client should write absolute-form HTTPS request");
1524+
1525+
let mut buf = [0_u8; 512];
1526+
let bytes_read = timeout(Duration::from_secs(2), stream.read(&mut buf))
1527+
.await
1528+
.expect("proxy should respond before timeout")
1529+
.expect("client should read proxy response");
1530+
let response = String::from_utf8_lossy(&buf[..bytes_read]);
1531+
assert!(
1532+
response.starts_with("HTTP/1.1 403 Forbidden\r\n"),
1533+
"unexpected proxy response: {response:?}"
1534+
);
1535+
assert!(response.contains("x-proxy-error: blocked-by-mitm-required\r\n"));
1536+
assert!(
1537+
!target_task.await.expect("target task should finish"),
1538+
"blocked request must not reach upstream"
1539+
);
1540+
1541+
let blocked = state.drain_blocked().await.unwrap();
1542+
assert_eq!(blocked.len(), 1);
1543+
assert_eq!(blocked[0].reason, REASON_MITM_REQUIRED);
1544+
1545+
drop(stream);
1546+
proxy_task.abort();
1547+
let _ = proxy_task.await;
1548+
}
1549+
14211550
#[tokio::test(flavor = "current_thread")]
14221551
async fn http_plain_proxy_blocks_unix_socket_when_method_not_allowed() {
14231552
let state = Arc::new(network_proxy_state_for_policy(NetworkProxyConfig::default()));

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#![deny(clippy::print_stdout, clippy::print_stderr)]
22

33
mod attribution;
4+
mod authorization_path;
45
mod certs;
56
mod config;
67
mod connect_policy;

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#![cfg_attr(not(test), allow(dead_code))]
22

3+
use crate::authorization_path::is_safe_for_authorization;
34
use crate::config::NetworkProxyConfig;
45
use crate::policy::normalize_host;
56
use anyhow::Context as _;
@@ -392,7 +393,7 @@ fn hook_matches(hook: &MitmHook, req: &Request) -> bool {
392393
}
393394

394395
let path = req.uri().path();
395-
if !path_matches(&hook.matcher.path_prefixes, path) {
396+
if !is_safe_for_authorization(path) || !path_matches(&hook.matcher.path_prefixes, path) {
396397
return false;
397398
}
398399

@@ -918,6 +919,46 @@ mod tests {
918919
);
919920
}
920921

922+
#[test]
923+
fn evaluate_rejects_paths_that_upstream_may_normalize() {
924+
let mut config = base_config();
925+
let mut hook = github_hook();
926+
hook.matcher.methods = vec!["GET".to_string()];
927+
hook.matcher.path_prefixes = vec!["pattern:/openai/openai/**".to_string()];
928+
config.mitm_hooks = vec![hook];
929+
930+
let hooks = compile_mitm_hooks_with_resolvers(
931+
&config,
932+
|_| Some("abc".to_string()),
933+
|_| Err(anyhow!("unexpected file lookup")),
934+
)
935+
.unwrap();
936+
let paths = [
937+
"/openai/openai/../codex",
938+
"/openai/openai/%2e%2e/codex",
939+
"/openai/openai/%2E%2E/codex",
940+
"/openai/openai/.%2e/codex",
941+
"/openai/openai/%2e./codex",
942+
"/openai/openai/%252e%252e/codex",
943+
"/openai/openai/%2f..%2fcodex",
944+
"/openai/openai/%5c..%5ccodex",
945+
"/openai/openai/%2e%2e/%2e%2e/microsoft/vscode",
946+
];
947+
let actual = paths
948+
.iter()
949+
.map(|path| {
950+
let req = Request::builder()
951+
.method(Method::GET)
952+
.uri(*path)
953+
.body(Body::empty())
954+
.unwrap();
955+
evaluate_mitm_hooks(&hooks, "api.github.com", &req)
956+
})
957+
.collect::<Vec<_>>();
958+
959+
assert_eq!(actual, vec![HookEvaluation::HookedHostNoMatch; paths.len()]);
960+
}
961+
921962
#[test]
922963
fn evaluate_treats_glob_metacharacters_as_literal_without_glob_prefix() {
923964
let mut config = base_config();

0 commit comments

Comments
 (0)