mirror of
https://github.com/openai/codex.git
synced 2026-04-23 22:24:57 +00:00
Compare commits
3 Commits
pr17693
...
codex/viya
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1790a96d89 | ||
|
|
20ae699cbb | ||
|
|
a4b65c5795 |
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
16
codex-rs/network-proxy/src/init.rs
Normal file
16
codex-rs/network-proxy/src/init.rs
Normal 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(())
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod admin;
|
||||
mod config;
|
||||
mod http_proxy;
|
||||
mod mitm;
|
||||
mod network_policy;
|
||||
mod policy;
|
||||
mod proxy;
|
||||
|
||||
612
codex-rs/network-proxy/src/mitm.rs
Normal file
612
codex-rs/network-proxy/src/mitm.rs
Normal 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()))
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user