Compare commits

...

3 Commits

Author SHA1 Message Date
viyatb-oai
1790a96d89 refactor(network-proxy): align MITM blocked request metadata 2026-02-11 13:36:33 -08:00
viyatb-oai
20ae699cbb network-proxy: store MITM certs under CODEX_HOME/proxy 2026-02-11 13:31:59 -08:00
viyatb-oai
a4b65c5795 Add MITM support to network proxy 2026-02-11 13:22:40 -08:00
11 changed files with 782 additions and 20 deletions

View File

@@ -17,6 +17,7 @@ async-trait = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-utils-absolute-path = { workspace = true }
globset = { workspace = true }
rcgen-rama = { package = "rcgen", version = "0.14", default-features = false, features = ["pem", "x509-parser", "ring"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
@@ -30,7 +31,9 @@ rama-http-backend = { version = "=0.3.0-alpha.4", features = ["tls"] }
rama-net = { version = "=0.3.0-alpha.4", features = ["http", "tls"] }
rama-socks5 = { version = "=0.3.0-alpha.4" }
rama-tcp = { version = "=0.3.0-alpha.4", features = ["http"] }
rama-tls-boring = { version = "=0.3.0-alpha.4", features = ["http"] }
rama-tls-rustls = { version = "=0.3.0-alpha.4", features = ["http"] }
rama-utils = { version = "=0.3.0-alpha.4" }
[dev-dependencies]
pretty_assertions = { workspace = true }

View File

@@ -35,6 +35,15 @@ dangerously_allow_non_loopback_proxy = false
dangerously_allow_non_loopback_admin = false
mode = "full" # default when unset; use "limited" for read-only mode
[network.mitm]
# When enabled, HTTPS CONNECT can be terminated so limited-mode method policy still applies.
# CA cert/key paths are relative to CODEX_HOME by default.
enabled = false
ca_cert_path = "proxy/ca.pem"
ca_key_path = "proxy/ca.key"
# Maximum size of request/response bodies MITM will buffer for inspection.
max_body_bytes = 1048576
# Hosts must match the allowlist (unless denied).
# If `allowed_domains` is empty, the proxy blocks requests until an allowlist is configured.
allowed_domains = ["*.openai.com"]
@@ -80,8 +89,9 @@ When a request is blocked, the proxy responds with `403` and includes:
- `blocked-by-method-policy`
- `blocked-by-policy`
In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed. HTTPS `CONNECT` and SOCKS5 are
blocked because they would bypass method enforcement.
In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed. HTTPS `CONNECT` requests require
MITM to enforce limited-mode method policy; otherwise they are blocked. SOCKS5 remains blocked in
limited mode.
## Library API

View File

@@ -5,6 +5,7 @@ use serde::Deserialize;
use serde::Serialize;
use std::net::IpAddr;
use std::net::SocketAddr;
use std::path::PathBuf;
use tracing::warn;
use url::Url;
@@ -44,6 +45,8 @@ pub struct NetworkProxySettings {
pub allow_unix_sockets: Vec<String>,
#[serde(default)]
pub allow_local_binding: bool,
#[serde(default)]
pub mitm: MitmConfig,
}
impl Default for NetworkProxySettings {
@@ -63,6 +66,7 @@ impl Default for NetworkProxySettings {
denied_domains: Vec::new(),
allow_unix_sockets: Vec::new(),
allow_local_binding: false,
mitm: MitmConfig::default(),
}
}
}
@@ -72,6 +76,7 @@ impl Default for NetworkProxySettings {
pub enum NetworkMode {
/// Limited (read-only) access: only GET/HEAD/OPTIONS are allowed for HTTP. HTTPS CONNECT is
/// blocked unless MITM is enabled so the proxy can enforce method policy on inner requests.
/// SOCKS5 remains blocked in limited mode.
Limited,
/// Full network access: all HTTP methods are allowed, and HTTPS CONNECTs are tunneled without
/// MITM interception.
@@ -88,6 +93,32 @@ impl NetworkMode {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MitmConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub inspect: bool,
#[serde(default = "default_mitm_max_body_bytes")]
pub max_body_bytes: usize,
#[serde(default = "default_ca_cert_path")]
pub ca_cert_path: PathBuf,
#[serde(default = "default_ca_key_path")]
pub ca_key_path: PathBuf,
}
impl Default for MitmConfig {
fn default() -> Self {
Self {
enabled: false,
inspect: false,
max_body_bytes: default_mitm_max_body_bytes(),
ca_cert_path: default_ca_cert_path(),
ca_key_path: default_ca_key_path(),
}
}
}
fn default_proxy_url() -> String {
"http://127.0.0.1:3128".to_string()
}
@@ -100,6 +131,18 @@ fn default_socks_url() -> String {
"http://127.0.0.1:8081".to_string()
}
fn default_ca_cert_path() -> PathBuf {
PathBuf::from("proxy/ca.pem")
}
fn default_ca_key_path() -> PathBuf {
PathBuf::from("proxy/ca.key")
}
fn default_mitm_max_body_bytes() -> usize {
4096
}
/// Clamp non-loopback bind addresses to loopback unless explicitly allowed.
fn clamp_non_loopback(addr: SocketAddr, allow_non_loopback: bool, name: &str) -> SocketAddr {
if addr.ip().is_loopback() {

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;
@@ -208,35 +210,47 @@ async fn http_connect_accept(
.await
.map_err(|err| internal_error("failed to read network mode", err))?;
if mode == NetworkMode::Limited {
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,
source: NetworkDecisionSource::ModeGuard,
protocol: NetworkProtocol::HttpsConnect,
host: &host,
port: authority.port,
};
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 _ = 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),
protocol: "http-connect".to_string(),
}))
.await;
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_MITM_REQUIRED,
source: NetworkDecisionSource::ModeGuard,
protocol: NetworkProtocol::HttpsConnect,
host: &host,
port: authority.port,
};
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()
@@ -248,9 +262,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
@@ -718,7 +757,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"
);
}
}

View File

@@ -0,0 +1,16 @@
use anyhow::Context;
use anyhow::Result;
use codex_core::config::find_codex_home;
use std::fs;
use tracing::info;
pub fn run_init() -> Result<()> {
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let proxy_dir = codex_home.join("proxy");
fs::create_dir_all(&proxy_dir)
.with_context(|| format!("failed to create {}", proxy_dir.display()))?;
info!("ensured {}", proxy_dir.display());
Ok(())
}

View File

@@ -3,6 +3,7 @@
mod admin;
mod config;
mod http_proxy;
mod mitm;
mod network_policy;
mod policy;
mod proxy;

View File

@@ -0,0 +1,612 @@
use crate::config::MitmConfig;
use crate::config::NetworkMode;
use crate::policy::normalize_host;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
use crate::responses::blocked_text_response;
use crate::responses::text_response;
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 rama_core::Layer;
use rama_core::Service;
use rama_core::bytes::Bytes;
use rama_core::error::BoxError;
use rama_core::extensions::ExtensionsRef;
use rama_core::futures::stream::Stream;
use rama_core::rt::Executor;
use rama_core::service::service_fn;
use rama_http::Body;
use rama_http::BodyDataStream;
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_net::tls::ApplicationProtocol;
use rama_net::tls::DataEncoding;
use rama_net::tls::server::ServerAuth;
use rama_net::tls::server::ServerAuthData;
use rama_net::tls::server::ServerConfig;
use rama_tls_boring::server::TlsAcceptorData;
use rama_tls_boring::server::TlsAcceptorLayer;
use rama_utils::str::NonEmptyStr;
use std::fs;
use std::fs::File;
use std::fs::OpenOptions;
use std::io::Write;
use std::net::IpAddr;
use std::pin::Pin;
use std::sync::Arc;
use std::task::Context as TaskContext;
use std::task::Poll;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use tracing::info;
use tracing::warn;
use rcgen_rama::BasicConstraints;
use rcgen_rama::CertificateParams;
use rcgen_rama::DistinguishedName;
use rcgen_rama::DnType;
use rcgen_rama::ExtendedKeyUsagePurpose;
use rcgen_rama::IsCa;
use rcgen_rama::Issuer;
use rcgen_rama::KeyPair;
use rcgen_rama::KeyUsagePurpose;
use rcgen_rama::SanType;
pub struct MitmState {
issuer: Issuer<'static, KeyPair>,
upstream: UpstreamClient,
inspect: bool,
max_body_bytes: usize,
}
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 fn new(cfg: &MitmConfig, allow_upstream_proxy: bool) -> Result<Self> {
// MITM exists to make limited-mode HTTPS enforceable: once CONNECT is established, plain
// proxying would lose visibility into the inner HTTP request. We generate/load a local CA
// and issue per-host leaf certs so we can terminate TLS and apply policy.
let (ca_cert_pem, ca_key_pem) = load_or_create_ca(cfg)?;
let ca_key = KeyPair::from_pem(&ca_key_pem).context("failed to parse CA key")?;
let issuer: Issuer<'static, KeyPair> =
Issuer::from_ca_cert_pem(&ca_cert_pem, ca_key).context("failed to parse CA cert")?;
let upstream = if allow_upstream_proxy {
UpstreamClient::from_env_proxy()
} else {
UpstreamClient::direct()
};
Ok(Self {
issuer,
upstream,
inspect: cfg.inspect,
max_body_bytes: cfg.max_body_bytes,
})
}
fn tls_acceptor_data_for_host(&self, host: &str) -> Result<TlsAcceptorData> {
let (cert_pem, key_pem) = issue_host_certificate_pem(host, &self.issuer)?;
let cert_chain = DataEncoding::Pem(
NonEmptyStr::try_from(cert_pem.as_str()).context("failed to encode host cert PEM")?,
);
let private_key = DataEncoding::Pem(
NonEmptyStr::try_from(key_pem.as_str()).context("failed to encode host key PEM")?,
);
let auth = ServerAuthData {
private_key,
cert_chain,
ocsp: None,
};
let mut server_config = ServerConfig::new(ServerAuth::Single(auth));
server_config.application_layer_protocol_negotiation = Some(vec![
ApplicationProtocol::HTTP_2,
ApplicationProtocol::HTTP_11,
]);
TlsAcceptorData::try_from(server_config).context("failed to build boring acceptor config")
}
pub fn inspect_enabled(&self) -> bool {
self.inspect
}
pub fn max_body_bytes(&self) -> usize {
self.max_body_bytes
}
}
pub async fn mitm_tunnel(upgraded: Upgraded) -> Result<()> {
let state = upgraded
.extensions()
.get::<Arc<MitmState>>()
.cloned()
.context("missing MITM state")?;
let target = upgraded
.extensions()
.get::<ProxyTarget>()
.context("missing proxy target")?
.0
.clone();
let host = normalize_host(&target.host.to_string());
let acceptor_data = state.tls_acceptor_data_for_host(&host)?;
let executor = upgraded
.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(handle_mitm_request)),
);
let https_service = TlsAcceptorLayer::new(acceptor_data)
.with_store_client_hello(true)
.into_layer(http_service);
https_service
.serve(upgraded)
.await
.map_err(|err| anyhow!("MITM serve error: {err}"))?;
Ok(())
}
async fn handle_mitm_request(req: Request) -> Result<Response, std::convert::Infallible> {
let response = match forward_request(req).await {
Ok(resp) => resp,
Err(err) => {
warn!("MITM upstream request failed: {err}");
text_response(StatusCode::BAD_GATEWAY, "mitm upstream error")
}
};
Ok(response)
}
async fn forward_request(req: Request) -> Result<Response> {
let target = req
.extensions()
.get::<ProxyTarget>()
.context("missing proxy target")?
.0
.clone();
let target_host = normalize_host(&target.host.to_string());
let target_port = target.port;
let mode = req
.extensions()
.get::<NetworkMode>()
.copied()
.unwrap_or(NetworkMode::Full);
let mitm = req
.extensions()
.get::<Arc<MitmState>>()
.cloned()
.context("missing MITM state")?;
let app_state = req
.extensions()
.get::<Arc<NetworkProxyState>>()
.cloned()
.context("missing app state")?;
if req.method().as_str() == "CONNECT" {
return Ok(text_response(
StatusCode::METHOD_NOT_ALLOWED,
"CONNECT not supported inside MITM",
));
}
let method = req.method().as_str().to_string();
let path = path_and_query(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 != target_host {
warn!("MITM host mismatch (target={target_host}, request_host={normalized})");
return Ok(text_response(StatusCode::BAD_REQUEST, "host mismatch"));
}
}
if !mode.allows_method(&method) {
let _ = app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: target_host.clone(),
reason: REASON_METHOD_NOT_ALLOWED.to_string(),
client: client.clone(),
method: Some(method.clone()),
mode: Some(mode),
protocol: "https".to_string(),
}))
.await;
warn!(
"MITM blocked by method policy (host={target_host}, method={method}, path={path}, mode={mode:?}, allowed_methods=GET, HEAD, OPTIONS)"
);
return Ok(blocked_text_response(REASON_METHOD_NOT_ALLOWED));
}
let (mut parts, body) = req.into_parts();
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: 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,
&path,
&authority,
)
}
fn respond_with_inspection(
resp: Response,
inspect: bool,
max_body_bytes: usize,
method: &str,
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: 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> Stream 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 issue_host_certificate_pem(
host: &str,
issuer: &Issuer<'_, KeyPair>,
) -> Result<(String, String)> {
let mut params = if let Ok(ip) = host.parse::<IpAddr>() {
let mut params = CertificateParams::new(Vec::new())
.map_err(|err| anyhow!("failed to create cert params: {err}"))?;
params.subject_alt_names.push(SanType::IpAddress(ip));
params
} else {
CertificateParams::new(vec![host.to_string()])
.map_err(|err| anyhow!("failed to create cert params: {err}"))?
};
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
let key_pair = KeyPair::generate_for(&rcgen_rama::PKCS_ECDSA_P256_SHA256)
.map_err(|err| anyhow!("failed to generate host key pair: {err}"))?;
let cert = params
.signed_by(&key_pair, issuer)
.map_err(|err| anyhow!("failed to sign host cert: {err}"))?;
Ok((cert.pem(), key_pair.serialize_pem()))
}
fn load_or_create_ca(cfg: &MitmConfig) -> Result<(String, String)> {
let cert_path = &cfg.ca_cert_path;
let key_path = &cfg.ca_key_path;
if cert_path.exists() || key_path.exists() {
if !cert_path.exists() || !key_path.exists() {
return Err(anyhow!("both ca_cert_path and ca_key_path must exist"));
}
let cert_pem = fs::read_to_string(cert_path)
.with_context(|| format!("failed to read CA cert {}", cert_path.display()))?;
let key_pem = fs::read_to_string(key_path)
.with_context(|| format!("failed to read CA key {}", key_path.display()))?;
return Ok((cert_pem, key_pem));
}
if let Some(parent) = cert_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
if let Some(parent) = key_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let (cert_pem, key_pem) = generate_ca()?;
// The CA key is a high-value secret. Create it atomically with restrictive permissions.
// The cert can be world-readable, but we still write it atomically to avoid partial writes.
//
// We intentionally use create-new semantics: if a key already exists, we should not overwrite
// it silently (that would invalidate previously-trusted cert chains).
write_atomic_create_new(key_path, key_pem.as_bytes(), 0o600)
.with_context(|| format!("failed to persist CA key {}", key_path.display()))?;
if let Err(err) = write_atomic_create_new(cert_path, cert_pem.as_bytes(), 0o644)
.with_context(|| format!("failed to persist CA cert {}", cert_path.display()))
{
// Avoid leaving a partially-created CA around (cert missing) if the second write fails.
let _ = fs::remove_file(key_path);
return Err(err);
}
let cert_path = cert_path.display();
let key_path = key_path.display();
info!("generated MITM CA (cert_path={cert_path}, key_path={key_path})");
Ok((cert_pem, key_pem))
}
fn generate_ca() -> Result<(String, String)> {
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "network_proxy MITM CA");
params.distinguished_name = dn;
let key_pair = KeyPair::generate_for(&rcgen_rama::PKCS_ECDSA_P256_SHA256)
.map_err(|err| anyhow!("failed to generate CA key pair: {err}"))?;
let cert = params
.self_signed(&key_pair)
.map_err(|err| anyhow!("failed to generate CA cert: {err}"))?;
Ok((cert.pem(), key_pair.serialize_pem()))
}
fn write_atomic_create_new(path: &std::path::Path, contents: &[u8], mode: u32) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow!("missing parent directory"))?;
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let pid = std::process::id();
let file_name = path.file_name().unwrap_or_default().to_string_lossy();
let tmp_path = parent.join(format!(".{file_name}.tmp.{pid}.{nanos}"));
let mut file = open_create_new_with_mode(&tmp_path, mode)?;
file.write_all(contents)
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
file.sync_all()
.with_context(|| format!("failed to fsync {}", tmp_path.display()))?;
drop(file);
// Create the final file using "create-new" semantics (no overwrite). `rename` on Unix can
// overwrite existing files, so prefer a hard-link, which fails if the destination exists.
match fs::hard_link(&tmp_path, path) {
Ok(()) => {
fs::remove_file(&tmp_path)
.with_context(|| format!("failed to remove {}", tmp_path.display()))?;
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
let _ = fs::remove_file(&tmp_path);
return Err(anyhow!(
"refusing to overwrite existing file {}",
path.display()
));
}
Err(_) => {
// Best-effort fallback for environments where hard links are not supported.
// This is still subject to a TOCTOU race, but the typical case is a private per-user
// config directory, where other users cannot create files anyway.
if path.exists() {
let _ = fs::remove_file(&tmp_path);
return Err(anyhow!(
"refusing to overwrite existing file {}",
path.display()
));
}
fs::rename(&tmp_path, path).with_context(|| {
format!(
"failed to rename {} -> {}",
tmp_path.display(),
path.display()
)
})?;
}
}
// Best-effort durability: ensure the directory entry is persisted too.
let dir = File::open(parent).with_context(|| format!("failed to open {}", parent.display()))?;
dir.sync_all()
.with_context(|| format!("failed to fsync {}", parent.display()))?;
Ok(())
}
#[cfg(unix)]
fn open_create_new_with_mode(path: &std::path::Path, mode: u32) -> Result<File> {
use std::os::unix::fs::OpenOptionsExt;
OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(path)
.with_context(|| format!("failed to create {}", path.display()))
}
#[cfg(not(unix))]
fn open_create_new_with_mode(path: &std::path::Path, _mode: u32) -> Result<File> {
OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.with_context(|| format!("failed to create {}", path.display()))
}

View File

@@ -1,5 +1,6 @@
pub(crate) const REASON_DENIED: &str = "denied";
pub(crate) const REASON_METHOD_NOT_ALLOWED: &str = "method_not_allowed";
pub(crate) const REASON_MITM_REQUIRED: &str = "mitm_required";
pub(crate) const REASON_NOT_ALLOWED: &str = "not_allowed";
pub(crate) const REASON_NOT_ALLOWED_LOCAL: &str = "not_allowed_local";
pub(crate) const REASON_POLICY_DENIED: &str = "policy_denied";

View File

@@ -3,6 +3,7 @@ use crate::network_policy::NetworkPolicyDecision;
use crate::network_policy::NetworkProtocol;
use crate::reasons::REASON_DENIED;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
use crate::reasons::REASON_MITM_REQUIRED;
use crate::reasons::REASON_NOT_ALLOWED;
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
use rama_http::Body;
@@ -64,6 +65,7 @@ pub fn blocked_header_value(reason: &str) -> &'static str {
REASON_NOT_ALLOWED | REASON_NOT_ALLOWED_LOCAL => "blocked-by-allowlist",
REASON_DENIED => "blocked-by-denylist",
REASON_METHOD_NOT_ALLOWED => "blocked-by-method-policy",
REASON_MITM_REQUIRED => "blocked-by-mitm-required",
_ => "blocked-by-policy",
}
}
@@ -78,6 +80,7 @@ pub fn blocked_message(reason: &str) -> &'static str {
REASON_METHOD_NOT_ALLOWED => {
"Codex blocked this request: method not allowed in limited mode."
}
REASON_MITM_REQUIRED => "Codex blocked this request: MITM required for limited HTTPS.",
_ => "Codex blocked this request by network policy.",
}
}

View File

@@ -1,5 +1,6 @@
use crate::config::NetworkMode;
use crate::config::NetworkProxyConfig;
use crate::mitm::MitmState;
use crate::policy::Host;
use crate::policy::is_loopback_host;
use crate::policy::is_non_public_ip;
@@ -111,6 +112,7 @@ pub struct ConfigState {
pub config: NetworkProxyConfig,
pub allow_set: GlobSet,
pub deny_set: GlobSet,
pub mitm: Option<Arc<MitmState>>,
pub constraints: NetworkProxyConstraints,
pub cfg_path: PathBuf,
pub blocked: VecDeque<BlockedRequest>,
@@ -373,6 +375,12 @@ impl NetworkProxyState {
}
}
pub async fn mitm_state(&self) -> Result<Option<Arc<MitmState>>> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok(guard.mitm.clone())
}
async fn reload_if_needed(&self) -> Result<()> {
match self.reloader.maybe_reload().await? {
None => Ok(()),

View File

@@ -1,11 +1,14 @@
use crate::config::NetworkMode;
use crate::config::NetworkProxyConfig;
use crate::mitm::MitmState;
use crate::policy::DomainPattern;
use crate::policy::compile_globset;
use crate::runtime::ConfigState;
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
pub use crate::runtime::BlockedRequest;
pub use crate::runtime::BlockedRequestArgs;
@@ -50,22 +53,45 @@ pub struct PartialNetworkConfig {
}
pub fn build_config_state(
config: NetworkProxyConfig,
mut config: NetworkProxyConfig,
constraints: NetworkProxyConstraints,
cfg_path: PathBuf,
) -> anyhow::Result<ConfigState> {
resolve_mitm_paths(&mut config, &cfg_path);
let deny_set = compile_globset(&config.network.denied_domains)?;
let allow_set = compile_globset(&config.network.allowed_domains)?;
let mitm = if config.network.mitm.enabled {
Some(Arc::new(MitmState::new(
&config.network.mitm,
config.network.allow_upstream_proxy,
)?))
} else {
None
};
Ok(ConfigState {
config,
allow_set,
deny_set,
mitm,
constraints,
cfg_path,
blocked: std::collections::VecDeque::new(),
})
}
fn resolve_mitm_paths(config: &mut NetworkProxyConfig, cfg_path: &Path) {
let mitm = &mut config.network.mitm;
let Some(config_dir) = cfg_path.parent() else {
return;
};
if mitm.ca_cert_path.is_relative() {
mitm.ca_cert_path = config_dir.join(&mitm.ca_cert_path);
}
if mitm.ca_key_path.is_relative() {
mitm.ca_key_path = config_dir.join(&mitm.ca_key_path);
}
}
pub fn validate_policy_against_constraints(
config: &NetworkProxyConfig,
constraints: &NetworkProxyConstraints,