feat(network-proxy): add MITM support and gate limited-mode CONNECT (#9859)

## Description
- Adds MITM support (CA load/issue, TLS termination, optional body
inspection).
- Adds `codex-network-proxy init` to create
`CODEX_HOME/network_proxy/mitm`.
- Enforces limited-mode HTTPS correctly: `CONNECT` requires MITM,
otherwise blocked with `mitm_required`.
- Keeps `origin/main` layering/reload semantics (managed layers included
in reload checks).
- Centralizes block reasons (`REASON_MITM_REQUIRED`) and removes
`println!`.
- Scope is MITM-only (no SOCKS changes).

gated by `mitm=false` (default)
This commit is contained in:
viyatb-oai
2026-02-24 10:15:15 -08:00
committed by GitHub
parent ca556fa313
commit 8d3d58f992
13 changed files with 1091 additions and 12 deletions

View File

@@ -1,4 +1,5 @@
use crate::config::NetworkMode;
use crate::mitm;
use crate::network_policy::NetworkDecision;
use crate::network_policy::NetworkDecisionSource;
use crate::network_policy::NetworkPolicyDecider;
@@ -9,6 +10,7 @@ use crate::network_policy::NetworkProtocol;
use crate::network_policy::evaluate_host_policy;
use crate::policy::normalize_host;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
use crate::reasons::REASON_MITM_REQUIRED;
use crate::reasons::REASON_NOT_ALLOWED;
use crate::reasons::REASON_PROXY_DISABLED;
use crate::responses::PolicyDecisionDetails;
@@ -49,6 +51,7 @@ use rama_http_backend::server::HttpServer;
use rama_http_backend::server::layer::upgrade::UpgradeLayer;
use rama_http_backend::server::layer::upgrade::Upgraded;
use rama_net::Protocol;
use rama_net::address::HostWithOptPort;
use rama_net::address::ProxyAddress;
use rama_net::client::ConnectorService;
use rama_net::client::EstablishedClientConnection;
@@ -233,10 +236,20 @@ async fn http_connect_accept(
.await
.map_err(|err| internal_error("failed to read network mode", err))?;
if mode == NetworkMode::Limited {
let mitm_state = match app_state.mitm_state().await {
Ok(state) => state,
Err(err) => {
error!("failed to load MITM state: {err}");
return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
}
};
if mode == NetworkMode::Limited && mitm_state.is_none() {
// Limited mode is designed to be read-only. Without MITM, a CONNECT tunnel would hide the
// inner HTTP method/headers from the proxy, effectively bypassing method policy.
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,
reason: REASON_MITM_REQUIRED,
source: NetworkDecisionSource::ModeGuard,
protocol: NetworkProtocol::HttpsConnect,
host: &host,
@@ -245,7 +258,7 @@ async fn http_connect_accept(
let _ = app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: REASON_METHOD_NOT_ALLOWED.to_string(),
reason: REASON_MITM_REQUIRED.to_string(),
client: client.clone(),
method: Some("CONNECT".to_string()),
mode: Some(NetworkMode::Limited),
@@ -256,15 +269,17 @@ async fn http_connect_accept(
}))
.await;
let client = client.as_deref().unwrap_or_default();
warn!("CONNECT blocked by method policy (client={client}, host={host}, mode=limited)");
return Err(blocked_text_with_details(
REASON_METHOD_NOT_ALLOWED,
&details,
));
warn!(
"CONNECT blocked; MITM required for read-only HTTPS in limited mode (client={client}, host={host}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)"
);
return Err(blocked_text_with_details(REASON_MITM_REQUIRED, &details));
}
req.extensions_mut().insert(ProxyTarget(authority));
req.extensions_mut().insert(mode);
if let Some(mitm_state) = mitm_state {
req.extensions_mut().insert(mitm_state);
}
Ok((
Response::builder()
@@ -276,9 +291,34 @@ async fn http_connect_accept(
}
async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> {
if upgraded.extensions().get::<ProxyTarget>().is_none() {
let mode = upgraded
.extensions()
.get::<NetworkMode>()
.copied()
.unwrap_or(NetworkMode::Full);
let Some(target) = upgraded
.extensions()
.get::<ProxyTarget>()
.map(|t| t.0.clone())
else {
warn!("CONNECT missing proxy target");
return Ok(());
};
if mode == NetworkMode::Limited
&& upgraded
.extensions()
.get::<Arc<mitm::MitmState>>()
.is_some()
{
let host = normalize_host(&target.host.to_string());
let port = target.port;
info!("CONNECT MITM enabled (host={host}, port={port}, mode={mode:?})");
if let Err(err) = mitm::mitm_tunnel(upgraded).await {
warn!("MITM tunnel error: {err}");
}
return Ok(());
}
let allow_upstream_proxy = match upgraded
@@ -465,6 +505,10 @@ async fn http_plain_proxy(
};
let host = normalize_host(&authority.host.to_string());
let port = authority.port;
if let Err(err) = validate_plain_http_host_header(&req, &authority) {
warn!("HTTP request host mismatch: {err}");
return Ok(text_response(StatusCode::BAD_REQUEST, "host mismatch"));
}
let enabled = match app_state
.enabled()
.await
@@ -669,6 +713,45 @@ fn remove_hop_by_hop_request_headers(headers: &mut HeaderMap) {
}
}
fn validate_plain_http_host_header(
req: &Request,
target: &rama_net::address::HostWithPort,
) -> std::result::Result<(), &'static str> {
// Only enforce this in absolute-form requests. Origin-form requests use the Host header as the
// routing authority, so there is no separate target authority to compare against.
if req.uri().authority().is_none() {
return Ok(());
}
let Some(raw_host) = req.headers().get(header::HOST) else {
return Ok(());
};
let raw_host = raw_host.to_str().map_err(|_| "invalid Host header")?;
let parsed = HostWithOptPort::try_from(raw_host).map_err(|_| "invalid Host header")?;
let target_host = normalize_host(&target.host.to_string());
let request_host = normalize_host(&parsed.host.to_string());
if request_host.is_empty() || request_host != target_host {
return Err("request Host header host does not match target authority");
}
let expected_port = target.port;
let request_port = match parsed.port {
Some(port) => port,
None => match req.uri().scheme_str() {
Some("http") => 80,
Some("https") => 443,
Some(_) | None => expected_port,
},
};
if request_port != expected_port {
return Err("request Host header port does not match target authority");
}
Ok(())
}
fn json_blocked(host: &str, reason: &str, details: Option<&PolicyDecisionDetails<'_>>) -> Response {
let (message, decision, source, protocol, port) = details
.map(|details| {
@@ -804,7 +887,7 @@ mod tests {
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-method-policy"
"blocked-by-mitm-required"
);
}
@@ -853,6 +936,23 @@ mod tests {
);
}
#[tokio::test]
async fn http_plain_proxy_rejects_absolute_uri_host_header_mismatch() {
let state = Arc::new(network_proxy_state_for_policy(
NetworkProxySettings::default(),
));
let mut req = Request::builder()
.method(Method::GET)
.uri("http://raw.githubusercontent.com/openai/codex/main/README.md")
.header(header::HOST, "api.github.com")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(state);
let response = http_plain_proxy(None, req).await;
assert_eq!(response.unwrap().status(), StatusCode::BAD_REQUEST);
}
#[test]
fn remove_hop_by_hop_request_headers_keeps_forwarding_headers() {
let mut headers = HeaderMap::new();