mirror of
https://github.com/openai/codex.git
synced 2026-04-28 00:25:56 +00:00
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user