Files
codex/codex-rs/network-proxy/src/mitm.rs
Winston Howes 634ebc1865 Support credential brokering in plaintext HTTP tunnels (#44089)
## Why

Configured HTTP credential destinations need dummy credentials translated inside `CONNECT` and SOCKS5 tunnels, where credential interception previously detected only TLS.

## What changed

- Detect plaintext HTTP for configured credential destinations and proxy requests with URL-scoped credential substitution.
- Keep requests bound to the authorized tunnel destination, rejecting mismatched authorities and nested `CONNECT` requests.
- Support HTTP/2 clients and preserve HTTP upgrades, including `h2c`, and opaque traffic in full mode.
- Enforce limited-mode method restrictions on plaintext HTTP and reject upgrades and opaque traffic.

## Testing

Add end-to-end tests for both tunnel transports covering credential URL scope, request bodies, HTTP/2, destination mismatches, upgrades, lossless opaque forwarding, and limited-mode enforcement.

GitOrigin-RevId: 89262c996ea4ee156c628f9ed8e72b52504b2f6b
2026-09-09 07:06:12 +00:00

633 lines
20 KiB
Rust

use crate::certs::ManagedMitmCa;
use crate::config::NetworkMode;
use crate::mitm_hook::HookEvaluation;
use crate::mitm_hook::MitmHookActions;
use crate::policy::normalize_host;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
use crate::reasons::REASON_MITM_HOOK_DENIED;
use crate::responses::blocked_text_response;
use crate::responses::text_response;
use crate::runtime::HostBlockDecision;
use crate::runtime::HostBlockReason;
use crate::state::BlockedRequest;
use crate::state::BlockedRequestArgs;
use crate::state::NetworkProxyState;
use crate::upstream::UpstreamClient;
use anyhow::Context as _;
use anyhow::Result;
use anyhow::anyhow;
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
use rama_core::Layer;
use rama_core::Service;
use rama_core::bytes::Bytes;
use rama_core::error::BoxError;
use rama_core::extensions::ExtensionsMut;
use rama_core::extensions::ExtensionsRef;
use rama_core::futures::stream::Stream as FuturesStream;
use rama_core::rt::Executor;
use rama_core::service::service_fn;
use rama_core::stream::Stream;
use rama_http::Body;
use rama_http::BodyDataStream;
use rama_http::HeaderMap;
use rama_http::HeaderValue;
use rama_http::Request;
use rama_http::Response;
use rama_http::StatusCode;
use rama_http::Uri;
use rama_http::header::HOST;
use rama_http::layer::remove_header::RemoveRequestHeaderLayer;
use rama_http::layer::remove_header::RemoveResponseHeaderLayer;
use rama_http::uri::Scheme;
use rama_http_backend::server::HttpServer;
use rama_net::proxy::ProxyTarget;
use rama_net::stream::SocketInfo;
use rama_tls_rustls::dep::rustls;
use rama_tls_rustls::server::TlsAcceptorData;
use rama_tls_rustls::server::TlsAcceptorLayer;
use std::pin::Pin;
use std::sync::Arc;
use std::task::Context as TaskContext;
use std::task::Poll;
use tracing::info;
use tracing::warn;
/// State needed to terminate a CONNECT tunnel and enforce policy on inner HTTPS requests.
pub struct MitmState {
ca: Arc<ManagedMitmCa>,
allow_upstream_proxy: bool,
upstream_tls_root_store: Arc<rustls::RootCertStore>,
inspect: bool,
max_body_bytes: usize,
}
pub(crate) struct MitmUpstreamConfig {
pub(crate) allow_upstream_proxy: bool,
}
#[derive(Clone)]
struct MitmPolicyContext {
target_host: String,
target_port: u16,
scheme: Scheme,
mode: NetworkMode,
app_state: Arc<NetworkProxyState>,
}
#[derive(Clone)]
struct MitmRequestContext {
policy: MitmPolicyContext,
mitm: Arc<MitmState>,
upstream: UpstreamClient,
}
enum MitmPolicyDecision {
Allow {
hook_actions: Option<MitmHookActions>,
},
Block(Response),
}
const MITM_INSPECT_BODIES: bool = false;
const MITM_MAX_BODY_BYTES: usize = 4096;
impl std::fmt::Debug for MitmState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Avoid dumping internal state (CA material, connectors, etc.) to logs.
f.debug_struct("MitmState")
.field("inspect", &self.inspect)
.field("max_body_bytes", &self.max_body_bytes)
.finish_non_exhaustive()
}
}
impl MitmState {
pub(crate) fn new(config: MitmUpstreamConfig) -> Result<Self> {
ensure_rustls_crypto_provider();
// MITM exists when HTTPS policy depends on the inner request: limited-mode method clamps
// and host-specific hooks both need visibility after CONNECT is established. We
// generate a process-local CA and issue per-host leaf certs so we can terminate TLS and
// apply policy.
let ca = ManagedMitmCa::load_or_create()?;
let upstream_tls_root_store =
crate::certs::upstream_tls_root_store(&crate::certs::ca_env_from_process())?;
Ok(Self {
ca,
allow_upstream_proxy: config.allow_upstream_proxy,
upstream_tls_root_store,
inspect: MITM_INSPECT_BODIES,
max_body_bytes: MITM_MAX_BODY_BYTES,
})
}
fn tls_acceptor_data_for_host(&self, host: &str) -> Result<TlsAcceptorData> {
self.ca.tls_acceptor_data_for_host(host)
}
pub(crate) fn inspect_enabled(&self) -> bool {
self.inspect
}
pub(crate) fn max_body_bytes(&self) -> usize {
self.max_body_bytes
}
}
/// Proxy inner HTTP traffic, terminating TLS with a generated leaf certificate for HTTPS.
pub(crate) async fn mitm_stream<S>(stream: S, scheme: Scheme) -> Result<()>
where
S: Stream + Unpin + ExtensionsMut,
{
let mitm = stream
.extensions()
.get::<Arc<MitmState>>()
.cloned()
.context("missing MITM state")?;
let app_state = stream
.extensions()
.get::<Arc<NetworkProxyState>>()
.cloned()
.context("missing app state")?;
let target = stream
.extensions()
.get::<ProxyTarget>()
.context("missing proxy target")?
.0
.clone();
let target_host = normalize_host(&target.host.to_string());
let target_port = target.port;
let acceptor_data = if scheme == Scheme::HTTPS {
Some(mitm.tls_acceptor_data_for_host(&target_host)?)
} else {
None
};
let mode = stream
.extensions()
.get::<NetworkMode>()
.copied()
.unwrap_or(NetworkMode::Full);
let upstream = if mitm.allow_upstream_proxy {
UpstreamClient::from_env_proxy_with_tls_root_store(
app_state.clone(),
mitm.upstream_tls_root_store.clone(),
)
} else {
UpstreamClient::direct_with_tls_root_store(
app_state.clone(),
mitm.upstream_tls_root_store.clone(),
)
};
let request_ctx = Arc::new(MitmRequestContext {
policy: MitmPolicyContext {
target_host,
target_port,
scheme,
mode,
app_state,
},
mitm,
upstream,
});
let executor = stream
.extensions()
.get::<Executor>()
.cloned()
.unwrap_or_default();
let http_service = HttpServer::auto(executor).service(
(
(request_ctx.policy.scheme == Scheme::HTTPS)
.then(RemoveResponseHeaderLayer::hop_by_hop),
(request_ctx.policy.scheme == Scheme::HTTPS).then(RemoveRequestHeaderLayer::hop_by_hop),
)
.into_layer(service_fn({
let request_ctx = request_ctx.clone();
move |req| {
let request_ctx = request_ctx.clone();
async move { handle_mitm_request(req, request_ctx).await }
}
})),
);
match acceptor_data {
Some(acceptor_data) => {
TlsAcceptorLayer::new(acceptor_data)
.with_store_client_hello(true)
.into_layer(http_service)
.serve(stream)
.await
}
None => http_service.serve(stream).await,
}
.map_err(|err| anyhow!("MITM serve error: {err}"))?;
Ok(())
}
async fn handle_mitm_request(
req: Request,
request_ctx: Arc<MitmRequestContext>,
) -> Result<Response, std::convert::Infallible> {
let response = match forward_request(req, &request_ctx).await {
Ok(resp) => resp,
Err(err) => {
warn!("MITM request handling failed: {err}");
text_response(StatusCode::BAD_GATEWAY, "mitm upstream error")
}
};
Ok(response)
}
async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Result<Response> {
let hook_actions = match evaluate_mitm_policy(&req, &request_ctx.policy).await? {
MitmPolicyDecision::Allow { hook_actions } => hook_actions,
MitmPolicyDecision::Block(response) => return Ok(response),
};
let target_host = request_ctx.policy.target_host.clone();
let target_port = request_ctx.policy.target_port;
let mitm = request_ctx.mitm.clone();
let method = req.method().as_str().to_string();
let path = path_and_query(req.uri());
let log_path = path_for_log(req.uri());
let (mut parts, body) = req.into_parts();
let authority = authority_header_value(&target_host, target_port, &request_ctx.policy.scheme);
parts.uri = Uri::builder()
.scheme(request_ctx.policy.scheme.clone())
.authority(authority.as_str())
.path_and_query(path.as_str())
.build()?;
// Server-wide OPTIONS must not borrow authority from a narrower credential URL prefix.
let credential_path = if path == "*" { "/" } else { &path };
let credential_destination = format!(
"{}://{authority}{credential_path}",
request_ctx.policy.scheme
);
request_ctx
.policy
.app_state
.inject_request_credentials(&credential_destination, &mut parts.headers);
apply_mitm_hook_actions(&mut parts.headers, hook_actions.as_ref());
parts
.headers
.insert(HOST, HeaderValue::from_str(&authority)?);
let inspect = mitm.inspect_enabled();
let max_body_bytes = mitm.max_body_bytes();
let body = if inspect {
inspect_body(
body,
max_body_bytes,
RequestLogContext {
host: authority.clone(),
method: method.clone(),
path: log_path.clone(),
},
)
} else {
body
};
let upstream_req = Request::from_parts(parts, body);
let upstream_resp = if request_ctx.policy.scheme == Scheme::HTTP {
crate::brokered_tunnel::forward_http_request(upstream_req, &request_ctx.upstream).await?
} else {
request_ctx.upstream.serve(upstream_req).await?
};
respond_with_inspection(
upstream_resp,
inspect,
max_body_bytes,
&method,
&log_path,
&authority,
)
}
#[cfg_attr(not(test), allow(dead_code))]
async fn mitm_blocking_response(
req: &Request,
policy: &MitmPolicyContext,
) -> Result<Option<Response>> {
match evaluate_mitm_policy(req, policy).await? {
MitmPolicyDecision::Allow { .. } => Ok(None),
MitmPolicyDecision::Block(response) => Ok(Some(response)),
}
}
async fn evaluate_mitm_policy(
req: &Request,
policy: &MitmPolicyContext,
) -> Result<MitmPolicyDecision> {
if policy.scheme == Scheme::HTTP {
let matches_target = |authority: &rama_http::uri::Authority| {
!authority.as_str().contains('@')
&& normalize_host(authority.host()) == policy.target_host
&& authority.port_u16().unwrap_or(80) == policy.target_port
};
let invalid_destination = req
.uri()
.scheme()
.is_some_and(|scheme| scheme != &Scheme::HTTP)
|| req
.uri()
.authority()
.is_some_and(|authority| !matches_target(authority))
|| req.headers().get_all(HOST).iter().any(|value| {
value
.to_str()
.ok()
.and_then(|value| value.parse::<rama_http::uri::Authority>().ok())
.is_none_or(|authority| !matches_target(&authority))
});
if invalid_destination {
return Ok(MitmPolicyDecision::Block(text_response(
StatusCode::BAD_REQUEST,
"tunnel destination mismatch",
)));
}
}
if req.method().as_str() == "CONNECT" {
return Ok(MitmPolicyDecision::Block(text_response(
StatusCode::METHOD_NOT_ALLOWED,
"CONNECT not supported inside MITM",
)));
}
let method = req.method().as_str().to_string();
let log_path = path_for_log(req.uri());
let client = req
.extensions()
.get::<SocketInfo>()
.map(|info| info.peer_addr().to_string());
if let Some(request_host) = extract_request_host(req) {
let normalized = normalize_host(&request_host);
if !normalized.is_empty() && normalized != policy.target_host {
warn!(
"MITM host mismatch (target={}, request_host={normalized})",
policy.target_host
);
return Ok(MitmPolicyDecision::Block(text_response(
StatusCode::BAD_REQUEST,
"host mismatch",
)));
}
}
// CONNECT already handled allowlist/denylist + decider policy. Re-check local/private
// resolution here to defend against DNS rebinding between CONNECT and inner HTTPS requests.
if matches!(
policy
.app_state
.host_blocked(&policy.target_host, policy.target_port)
.await?,
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
) {
let reason = HostBlockReason::NotAllowedLocal.as_str();
let _ = policy
.app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: policy.target_host.clone(),
reason: reason.to_string(),
client: client.clone(),
method: Some(method.clone()),
mode: Some(policy.mode),
protocol: policy.scheme.to_string(),
decision: None,
source: None,
port: Some(policy.target_port),
}))
.await;
warn!(
"MITM blocked local/private target after CONNECT (host={}, port={}, method={method}, path={log_path})",
policy.target_host, policy.target_port
);
return Ok(MitmPolicyDecision::Block(blocked_text_response(reason)));
}
let hook_actions = match policy
.app_state
.evaluate_mitm_hook_request(&policy.target_host, req)
.await?
{
HookEvaluation::Matched { actions } => Some(actions),
HookEvaluation::HookedHostNoMatch => {
let _ = policy
.app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: policy.target_host.clone(),
reason: REASON_MITM_HOOK_DENIED.to_string(),
client: client.clone(),
method: Some(method.clone()),
mode: Some(policy.mode),
protocol: policy.scheme.to_string(),
decision: None,
source: None,
port: Some(policy.target_port),
}))
.await;
warn!(
"MITM blocked by hook policy (host={}, method={method}, mode={:?})",
policy.target_host, policy.mode
);
return Ok(MitmPolicyDecision::Block(blocked_text_response(
REASON_MITM_HOOK_DENIED,
)));
}
HookEvaluation::NoHooksForHost => None,
};
let opaque_http_upgrade = policy.scheme == Scheme::HTTP
&& policy.mode == NetworkMode::Limited
&& req.headers().contains_key(rama_http::header::UPGRADE);
if !policy.mode.allows_method(&method) || opaque_http_upgrade {
let _ = policy
.app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: policy.target_host.clone(),
reason: REASON_METHOD_NOT_ALLOWED.to_string(),
client: client.clone(),
method: Some(method.clone()),
mode: Some(policy.mode),
protocol: policy.scheme.to_string(),
decision: None,
source: None,
port: Some(policy.target_port),
}))
.await;
warn!(
"MITM blocked by method policy (host={}, method={method}, path={log_path}, mode={:?}, allowed_methods=GET, HEAD, OPTIONS)",
policy.target_host, policy.mode
);
return Ok(MitmPolicyDecision::Block(blocked_text_response(
REASON_METHOD_NOT_ALLOWED,
)));
}
Ok(MitmPolicyDecision::Allow { hook_actions })
}
fn apply_mitm_hook_actions(headers: &mut HeaderMap, actions: Option<&MitmHookActions>) {
let Some(actions) = actions else {
return;
};
for header_name in &actions.strip_request_headers {
headers.remove(header_name);
}
for injected_header in &actions.inject_request_headers {
headers.insert(injected_header.name.clone(), injected_header.value.clone());
}
}
fn respond_with_inspection(
resp: Response,
inspect: bool,
max_body_bytes: usize,
method: &str,
log_path: &str,
authority: &str,
) -> Result<Response> {
if !inspect {
return Ok(resp);
}
let (parts, body) = resp.into_parts();
let body = inspect_body(
body,
max_body_bytes,
ResponseLogContext {
host: authority.to_string(),
method: method.to_string(),
path: log_path.to_string(),
status: parts.status,
},
);
Ok(Response::from_parts(parts, body))
}
fn inspect_body<T: BodyLoggable + Send + 'static>(
body: Body,
max_body_bytes: usize,
ctx: T,
) -> Body {
Body::from_stream(InspectStream {
inner: Box::pin(body.into_data_stream()),
ctx: Some(Box::new(ctx)),
len: 0,
max_body_bytes,
})
}
struct InspectStream<T> {
inner: Pin<Box<BodyDataStream>>,
ctx: Option<Box<T>>,
len: usize,
max_body_bytes: usize,
}
impl<T: BodyLoggable> FuturesStream for InspectStream<T> {
type Item = Result<Bytes, BoxError>;
fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
match this.inner.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(bytes))) => {
this.len = this.len.saturating_add(bytes.len());
Poll::Ready(Some(Ok(bytes)))
}
Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))),
Poll::Ready(None) => {
if let Some(ctx) = this.ctx.take() {
ctx.log(this.len, this.len > this.max_body_bytes);
}
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
}
}
}
struct RequestLogContext {
host: String,
method: String,
path: String,
}
struct ResponseLogContext {
host: String,
method: String,
path: String,
status: StatusCode,
}
trait BodyLoggable {
fn log(self, len: usize, truncated: bool);
}
impl BodyLoggable for RequestLogContext {
fn log(self, len: usize, truncated: bool) {
let host = self.host;
let method = self.method;
let path = self.path;
info!(
"MITM inspected request body (host={host}, method={method}, path={path}, body_len={len}, truncated={truncated})"
);
}
}
impl BodyLoggable for ResponseLogContext {
fn log(self, len: usize, truncated: bool) {
let host = self.host;
let method = self.method;
let path = self.path;
let status = self.status;
info!(
"MITM inspected response body (host={host}, method={method}, path={path}, status={status}, body_len={len}, truncated={truncated})"
);
}
}
fn extract_request_host(req: &Request) -> Option<String> {
req.headers()
.get(HOST)
.and_then(|v| v.to_str().ok())
.map(ToString::to_string)
.or_else(|| req.uri().authority().map(|a| a.as_str().to_string()))
}
fn authority_header_value(host: &str, port: u16, scheme: &Scheme) -> String {
// Host header / URI authority formatting.
let default_port = if scheme == &Scheme::HTTP { 80 } else { 443 };
if host.contains(':') {
if port == default_port {
format!("[{host}]")
} else {
format!("[{host}]:{port}")
}
} else if port == default_port {
host.to_string()
} else {
format!("{host}:{port}")
}
}
fn path_and_query(uri: &Uri) -> String {
uri.path_and_query()
.map(rama_http::uri::PathAndQuery::as_str)
.unwrap_or("/")
.to_string()
}
fn path_for_log(uri: &Uri) -> String {
uri.path().to_string()
}
#[cfg(test)]
#[path = "mitm_tests.rs"]
mod tests;