Codex Handbook
network-proxy/src/mitm.rs 572 lines
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_backend::server::HttpServer;use rama_http_backend::server::layer::upgrade::Upgraded;use rama_net::proxy::ProxyTarget;use rama_net::stream::SocketInfo;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: ManagedMitmCa,    upstream: UpstreamClient,    inspect: bool,    max_body_bytes: usize,}pub(crate) struct MitmUpstreamConfig {    pub(crate) allow_upstream_proxy: bool,    pub(crate) allow_local_binding: bool,}#[derive(Clone)]struct MitmPolicyContext {    target_host: String,    target_port: u16,    mode: NetworkMode,    app_state: Arc<NetworkProxyState>,}#[derive(Clone)]struct MitmRequestContext {    policy: MitmPolicyContext,    mitm: Arc<MitmState>,}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/load a local CA and issue per-host leaf certs so we can terminate TLS and        // apply policy.        let ca = ManagedMitmCa::load_or_create()?;        let upstream = if config.allow_upstream_proxy {            UpstreamClient::from_env_proxy_with_allow_local_binding(config.allow_local_binding)        } else {            UpstreamClient::direct_with_allow_local_binding(config.allow_local_binding)        };        Ok(Self {            ca,            upstream,            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    }}/// Terminate the upgraded CONNECT stream with a generated leaf cert and proxy inner HTTPS traffic.pub(crate) async fn mitm_tunnel(upgraded: Upgraded) -> Result<()> {    mitm_stream(upgraded).await}/// Terminate a raw client stream with a generated leaf cert and proxy inner HTTPS traffic.pub(crate) async fn mitm_stream<S>(stream: S) -> 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 = mitm.tls_acceptor_data_for_host(&target_host)?;    let mode = stream        .extensions()        .get::<NetworkMode>()        .copied()        .unwrap_or(NetworkMode::Full);    let request_ctx = Arc::new(MitmRequestContext {        policy: MitmPolicyContext {            target_host,            target_port,            mode,            app_state,        },        mitm,    });    let executor = stream        .extensions()        .get::<Executor>()        .cloned()        .unwrap_or_default();    let http_service = HttpServer::auto(executor).service(        (            RemoveResponseHeaderLayer::hop_by_hop(),            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 }                }            })),    );    let https_service = TlsAcceptorLayer::new(acceptor_data)        .with_store_client_hello(true)        .into_layer(http_service);    https_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();    apply_mitm_hook_actions(&mut parts.headers, hook_actions.as_ref());    let authority = authority_header_value(&target_host, target_port);    parts.uri = build_https_uri(&authority, &path)?;    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 = mitm.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 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: "https".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: "https".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,    };    if !policy.mode.allows_method(&method) {        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: "https".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) -> String {    // Host header / URI authority formatting.    if host.contains(':') {        if port == 443 {            format!("[{host}]")        } else {            format!("[{host}]:{port}")        }    } else if port == 443 {        host.to_string()    } else {        format!("{host}:{port}")    }}fn build_https_uri(authority: &str, path: &str) -> Result<Uri> {    let target = format!("https://{authority}{path}");    Ok(target.parse()?)}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;