Compare commits

...

3 Commits

Author SHA1 Message Date
Eva Wong
599ba1319c Wire MITM hooks into runtime enforcement 2026-05-07 12:32:40 -07:00
Eva Wong
6f6a8617a1 Clarify full mode MITM hook behavior 2026-05-07 12:32:17 -07:00
Eva Wong
bf98e920f1 Add MITM hook config model 2026-05-07 12:32:16 -07:00
17 changed files with 1820 additions and 28 deletions

View File

@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use codex_network_proxy::MitmHookConfig;
use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
@@ -158,6 +159,9 @@ pub struct NetworkToml {
pub domains: Option<NetworkDomainPermissionsToml>,
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
pub allow_local_binding: Option<bool>,
pub mitm: Option<bool>,
#[schemars(with = "Option<Vec<MitmHookConfigSchema>>")]
pub mitm_hooks: Option<Vec<MitmHookConfig>>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -167,6 +171,46 @@ enum NetworkModeSchema {
Full,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
struct MitmHookConfigSchema {
pub host: String,
#[serde(rename = "match", default)]
pub matcher: MitmHookMatchConfigSchema,
#[serde(default)]
pub actions: MitmHookActionsConfigSchema,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
struct MitmHookMatchConfigSchema {
pub methods: Vec<String>,
pub path_prefixes: Vec<String>,
pub query: BTreeMap<String, Vec<String>>,
pub headers: BTreeMap<String, Vec<String>>,
pub body: Option<MitmHookBodyConfigSchema>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
struct MitmHookActionsConfigSchema {
pub strip_request_headers: Vec<String>,
pub inject_request_headers: Vec<InjectedHeaderConfigSchema>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
struct InjectedHeaderConfigSchema {
pub name: String,
pub secret_env_var: Option<String>,
pub secret_file: Option<String>,
pub prefix: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(transparent)]
struct MitmHookBodyConfigSchema(pub serde_json::Value);
impl NetworkToml {
pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) {
if let Some(enabled) = self.enabled {
@@ -219,6 +263,12 @@ impl NetworkToml {
if let Some(allow_local_binding) = self.allow_local_binding {
config.network.allow_local_binding = allow_local_binding;
}
if let Some(mitm) = self.mitm {
config.network.mitm = mitm;
}
if let Some(mitm_hooks) = self.mitm_hooks.as_ref() {
config.network.mitm_hooks = mitm_hooks.clone();
}
}
pub fn to_network_proxy_config(&self) -> NetworkProxyConfig {

View File

@@ -1091,6 +1091,27 @@
},
"type": "object"
},
"InjectedHeaderConfigSchema": {
"properties": {
"name": {
"default": "",
"type": "string"
},
"prefix": {
"default": null,
"type": "string"
},
"secret_env_var": {
"default": null,
"type": "string"
},
"secret_file": {
"default": null,
"type": "string"
}
},
"type": "object"
},
"KeybindingsSpec": {
"anyOf": [
{
@@ -1271,6 +1292,101 @@
},
"type": "object"
},
"MitmHookActionsConfigSchema": {
"properties": {
"inject_request_headers": {
"default": [],
"items": {
"$ref": "#/definitions/InjectedHeaderConfigSchema"
},
"type": "array"
},
"strip_request_headers": {
"default": [],
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"MitmHookConfigSchema": {
"properties": {
"actions": {
"allOf": [
{
"$ref": "#/definitions/MitmHookActionsConfigSchema"
}
],
"default": {
"inject_request_headers": [],
"strip_request_headers": []
}
},
"host": {
"default": "",
"type": "string"
},
"match": {
"allOf": [
{
"$ref": "#/definitions/MitmHookMatchConfigSchema"
}
],
"default": {
"body": null,
"headers": {},
"methods": [],
"path_prefixes": [],
"query": {}
}
}
},
"type": "object"
},
"MitmHookMatchConfigSchema": {
"properties": {
"body": {
"default": null
},
"headers": {
"additionalProperties": {
"items": {
"type": "string"
},
"type": "array"
},
"default": {},
"type": "object"
},
"methods": {
"default": [],
"items": {
"type": "string"
},
"type": "array"
},
"path_prefixes": {
"default": [],
"items": {
"type": "string"
},
"type": "array"
},
"query": {
"additionalProperties": {
"items": {
"type": "string"
},
"type": "array"
},
"default": {},
"type": "object"
}
},
"type": "object"
},
"ModelAvailabilityNuxConfig": {
"additionalProperties": {
"format": "uint32",
@@ -1525,6 +1641,15 @@
"enabled": {
"type": "boolean"
},
"mitm": {
"type": "boolean"
},
"mitm_hooks": {
"items": {
"$ref": "#/definitions/MitmHookConfigSchema"
},
"type": "array"
},
"mode": {
"$ref": "#/definitions/NetworkModeSchema"
},

View File

@@ -62,6 +62,11 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
use codex_model_provider_info::WireApi;
use codex_models_manager::bundled_models_response;
use codex_network_proxy::InjectedHeaderConfig;
use codex_network_proxy::MitmHookActionsConfig;
use codex_network_proxy::MitmHookConfig;
use codex_network_proxy::MitmHookMatchConfig;
use codex_network_proxy::NetworkMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ActivePermissionProfileModification;
@@ -730,9 +735,29 @@ enabled = true
proxy_url = "http://127.0.0.1:43128"
enable_socks5 = false
allow_upstream_proxy = false
mode = "full"
mitm = true
[permissions.workspace.network.domains]
"openai.com" = "allow"
[[permissions.workspace.network.mitm_hooks]]
host = "api.github.com"
[permissions.workspace.network.mitm_hooks.match]
methods = ["POST", "PUT"]
path_prefixes = ["/repos/openai/"]
[permissions.workspace.network.mitm_hooks.match.headers]
"x-github-api-version" = ["2022-11-28"]
[permissions.workspace.network.mitm_hooks.actions]
strip_request_headers = ["authorization"]
[[permissions.workspace.network.mitm_hooks.actions.inject_request_headers]]
name = "authorization"
secret_env_var = "CODEX_GITHUB_TOKEN"
prefix = "Bearer "
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles");
@@ -769,7 +794,7 @@ allow_upstream_proxy = false
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_all_unix_sockets: None,
mode: None,
mode: Some(NetworkMode::Full),
domains: Some(NetworkDomainPermissionsToml {
entries: BTreeMap::from([(
"openai.com".to_string(),
@@ -778,6 +803,29 @@ allow_upstream_proxy = false
}),
unix_sockets: None,
allow_local_binding: None,
mitm: Some(true),
mitm_hooks: Some(vec![MitmHookConfig {
host: "api.github.com".to_string(),
matcher: MitmHookMatchConfig {
methods: vec!["POST".to_string(), "PUT".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
query: BTreeMap::new(),
headers: BTreeMap::from([(
"x-github-api-version".to_string(),
vec!["2022-11-28".to_string()],
)]),
body: None,
},
actions: MitmHookActionsConfig {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: vec![InjectedHeaderConfig {
name: "authorization".to_string(),
secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()),
secret_file: None,
prefix: Some("Bearer ".to_string()),
}],
},
}]),
}),
},
)]),
@@ -785,6 +833,38 @@ allow_upstream_proxy = false
);
}
#[test]
fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() {
let network = NetworkToml {
mode: Some(NetworkMode::Full),
mitm: Some(true),
mitm_hooks: Some(vec![MitmHookConfig {
host: "api.github.com".to_string(),
matcher: MitmHookMatchConfig {
methods: vec!["POST".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
..MitmHookMatchConfig::default()
},
actions: MitmHookActionsConfig {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: vec![InjectedHeaderConfig {
name: "authorization".to_string(),
secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()),
secret_file: None,
prefix: Some("Bearer ".to_string()),
}],
},
}]),
..NetworkToml::default()
};
let config = network.to_network_proxy_config();
assert_eq!(config.network.mode, NetworkMode::Full);
assert!(config.network.mitm);
assert_eq!(config.network.mitm_hooks, network.mitm_hooks.unwrap());
}
#[tokio::test]
async fn permissions_profiles_network_enabled_allows_runtime_network_without_proxy()
-> std::io::Result<()> {

View File

@@ -127,6 +127,11 @@ fn profile_network_requires_proxy(network: &NetworkToml) -> bool {
|| network.allow_upstream_proxy == Some(true)
|| network.dangerously_allow_non_loopback_proxy == Some(true)
|| network.dangerously_allow_all_unix_sockets == Some(true)
|| network.mitm == Some(true)
|| network
.mitm_hooks
.as_ref()
.is_some_and(|hooks| !hooks.is_empty())
|| network.mode.is_some()
|| network
.domains

View File

@@ -11,6 +11,8 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml;
use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml;
use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_network_proxy::MitmHookConfig;
use codex_network_proxy::MitmHookMatchConfig;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -246,6 +248,28 @@ fn profile_network_proxy_config_keeps_proxy_disabled_for_bare_network_access() {
assert!(!config.network.enabled);
}
#[test]
fn profile_network_proxy_config_enables_proxy_for_mitm_hooks() {
let config = network_proxy_config_from_profile_network(Some(&NetworkToml {
enabled: Some(true),
mitm: Some(true),
mitm_hooks: Some(vec![MitmHookConfig {
host: "api.github.com".to_string(),
matcher: MitmHookMatchConfig {
methods: vec!["POST".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
..MitmHookMatchConfig::default()
},
..MitmHookConfig::default()
}]),
..Default::default()
}));
assert!(config.network.enabled);
assert!(config.network.mitm);
assert_eq!(config.network.mitm_hooks.len(), 1);
}
#[test]
fn profile_network_proxy_config_enables_proxy_for_proxy_policy() {
let config = network_proxy_config_from_profile_network(Some(&NetworkToml {

View File

@@ -104,6 +104,75 @@ default_permissions = "workspace"
assert_eq!(config.network.denied_domains(), None);
}
#[test]
fn higher_precedence_profile_network_overrides_mitm_hooks() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
mode = "limited"
mitm = false
[permissions.workspace.network.domains]
"lower.example.com" = "allow"
[[permissions.workspace.network.mitm_hooks]]
host = "lower.example.com"
[permissions.workspace.network.mitm_hooks.match]
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
"#,
)
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
mode = "full"
mitm = true
[permissions.workspace.network.domains]
"higher.example.com" = "allow"
[[permissions.workspace.network.mitm_hooks]]
host = "api.github.com"
[permissions.workspace.network.mitm_hooks.match]
methods = ["PUT"]
path_prefixes = ["/repos/openai/"]
"#,
)
.expect("higher layer should parse");
let mut config = NetworkProxyConfig::default();
apply_network_tables(
&mut config,
network_tables_from_toml(&lower_network).expect("lower layer should deserialize"),
)
.expect("lower layer should apply");
apply_network_tables(
&mut config,
network_tables_from_toml(&higher_network).expect("higher layer should deserialize"),
)
.expect("higher layer should apply");
assert_eq!(config.network.mode, codex_network_proxy::NetworkMode::Full);
assert!(config.network.mitm);
assert_eq!(
config.network.allowed_domains(),
Some(vec![
"lower.example.com".to_string(),
"higher.example.com".to_string()
])
);
assert_eq!(config.network.mitm_hooks.len(), 1);
assert_eq!(config.network.mitm_hooks[0].host, "api.github.com");
assert_eq!(config.network.mitm_hooks[0].matcher.methods, vec!["PUT"]);
}
#[test]
fn execpolicy_network_rules_overlay_network_lists() {
let mut config = NetworkProxyConfig::default();

View File

@@ -5,7 +5,8 @@
- an HTTP proxy (default `127.0.0.1:3128`)
- a SOCKS5 proxy (default `127.0.0.1:8081`, enabled by default)
It enforces an allow/deny policy and a "limited" mode intended for read-only network access.
It enforces an allow/deny policy, a "limited" mode intended for read-only network access, and
host-specific HTTPS MITM hooks for request matching and header injection.
## Quickstart
@@ -32,8 +33,9 @@ allow_upstream_proxy = true
# By default, non-loopback binds are clamped to loopback for safety.
# If you want to expose these listeners beyond localhost, you must opt in explicitly.
dangerously_allow_non_loopback_proxy = false
mode = "full" # default when unset; use "limited" for read-only mode
# When true, HTTPS CONNECT can be terminated so limited-mode method policy still applies.
mode = "full" # default when unset; hooks can still clamp specific HTTPS hosts
# When true, HTTPS CONNECT can be terminated so limited-mode policy and host-specific MITM hooks
# can inspect inner requests.
mitm = false
# CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key).
@@ -60,6 +62,48 @@ dangerously_allow_all_unix_sockets = false
# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`.
[permissions.workspace.network.unix_sockets]
"/tmp/example.sock" = "allow"
[[permissions.workspace.network.mitm_hooks]]
host = "api.github.com"
[permissions.workspace.network.mitm_hooks.match]
methods = ["POST", "PUT"]
path_prefixes = ["/repos/openai/"]
[permissions.workspace.network.mitm_hooks.match.headers]
"x-github-api-version" = ["2022-11-28"]
[permissions.workspace.network.mitm_hooks.actions]
strip_request_headers = ["authorization"]
[[permissions.workspace.network.mitm_hooks.actions.inject_request_headers]]
name = "authorization"
secret_env_var = "CODEX_GITHUB_TOKEN"
prefix = "Bearer "
# `match.body` is reserved for a future release. Current hooks match only on
# method/path/query/headers and can mutate outbound request headers.
# `match.path_prefixes` accepts literal prefixes by default. Prefix path,
# query, or header matchers with `pattern:` to opt into wildcard matching.
# Use `literal:` when a literal value must start with a reserved prefix.
# In paths, `*` and `?` do not match `/`; use `**` when crossing segments is intended.
```
Matcher syntax example:
```toml
[permissions.workspace.network.mitm_hooks.match]
# Literal path prefixes are the default.
path_prefixes = [
"/repos/openai/",
"pattern:/repos/*/codex/issues*",
]
[permissions.workspace.network.mitm_hooks.match.query]
state = ["open", "pattern:triage*", "literal:pattern:*"]
[permissions.workspace.network.mitm_hooks.match.headers]
"x-github-api-version" = ["2022-11-28", "pattern:2022*preview"]
```
### 2) Run the proxy
@@ -92,12 +136,16 @@ When a request is blocked, the proxy responds with `403` and includes:
- `x-proxy-error`: one of:
- `blocked-by-allowlist`
- `blocked-by-denylist`
- `blocked-by-mitm-hook`
- `blocked-by-method-policy`
- `blocked-by-mitm-required`
- `blocked-by-policy`
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.
MITM to enforce limited-mode method policy; otherwise they are blocked. Separately, hosts covered
by `mitm_hooks` are authoritative even when `mode = "full"`: hooks are evaluated in order, the
first match wins, and if no hook matches the inner HTTPS request is denied with
`blocked-by-mitm-hook`. SOCKS5 remains blocked in limited mode.
Websocket clients typically tunnel `wss://` through HTTPS `CONNECT`; those CONNECT targets still go
through the same host allowlist/denylist checks.

View File

@@ -13,6 +13,8 @@ use std::path::Path;
use tracing::warn;
use url::Url;
use crate::mitm_hook::MitmHookConfig;
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct NetworkProxyConfig {
#[serde(default)]
@@ -139,6 +141,8 @@ pub struct NetworkProxySettings {
pub allow_local_binding: bool,
#[serde(default)]
pub mitm: bool,
#[serde(default)]
pub mitm_hooks: Vec<MitmHookConfig>,
}
impl Default for NetworkProxySettings {
@@ -157,6 +161,7 @@ impl Default for NetworkProxySettings {
unix_sockets: None,
allow_local_binding: false,
mitm: false,
mitm_hooks: Vec::new(),
}
}
}
@@ -273,8 +278,8 @@ pub enum NetworkMode {
/// 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.
/// Full network access: all HTTP methods are allowed. HTTPS CONNECTs are tunneled directly.
/// MITM hooks do not currently make full mode enter MITM.
#[default]
Full,
}
@@ -588,6 +593,7 @@ mod tests {
unix_sockets: None,
allow_local_binding: false,
mitm: false,
mitm_hooks: Vec::new(),
}
);
}
@@ -652,6 +658,7 @@ mod tests {
"unix_sockets": null,
"allow_local_binding": false,
"mitm": false,
"mitm_hooks": [],
}
})
);

View File

@@ -80,6 +80,9 @@ use tracing::error;
use tracing::info;
use tracing::warn;
#[derive(Clone, Copy, Debug)]
struct ConnectMitmEnabled(bool);
pub async fn run_http_proxy(
state: Arc<NetworkProxyState>,
addr: SocketAddr,
@@ -256,10 +259,18 @@ async fn http_connect_accept(
return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
}
};
let host_has_mitm_hooks = match app_state.host_has_mitm_hooks(&host).await {
Ok(has_hooks) => has_hooks,
Err(err) => {
error!("failed to inspect MITM hooks for {host}: {err}");
return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
}
};
let connect_needs_mitm = mode == NetworkMode::Limited || host_has_mitm_hooks;
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.
if connect_needs_mitm && mitm_state.is_none() {
// CONNECT needs MITM whenever HTTPS policy depends on inner-request inspection, either for
// limited-mode method enforcement or for host-specific MITM hooks.
emit_http_block_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
@@ -286,7 +297,7 @@ async fn http_connect_accept(
reason: REASON_MITM_REQUIRED.to_string(),
client: client.clone(),
method: Some("CONNECT".to_string()),
mode: Some(NetworkMode::Limited),
mode: Some(mode),
protocol: "http-connect".to_string(),
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
@@ -295,14 +306,16 @@ async fn http_connect_accept(
.await;
let client = client.as_deref().unwrap_or_default();
warn!(
"CONNECT blocked; MITM required for read-only HTTPS in limited mode (client={client}, host={host}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)"
"CONNECT blocked; MITM required to enforce HTTPS policy (client={client}, host={host}, mode={mode:?}, hooked_host={host_has_mitm_hooks})"
);
return Err(blocked_text_with_details(REASON_MITM_REQUIRED, &details));
}
req.extensions_mut().insert(ProxyTarget(authority));
req.extensions_mut()
.insert(ConnectMitmEnabled(connect_needs_mitm));
req.extensions_mut().insert(mode);
if let Some(mitm_state) = mitm_state {
if connect_needs_mitm && let Some(mitm_state) = mitm_state {
req.extensions_mut().insert(mitm_state);
}
@@ -331,7 +344,10 @@ async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> {
return Ok(());
};
if mode == NetworkMode::Limited
if upgraded
.extensions()
.get::<ConnectMitmEnabled>()
.is_some_and(|enabled| enabled.0)
&& upgraded
.extensions()
.get::<Arc<mitm::MitmState>>()
@@ -1094,6 +1110,42 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_connect_accept_blocks_hooked_host_in_full_mode_without_mitm_state() {
let mut policy = NetworkProxySettings {
mitm: true,
mitm_hooks: vec![crate::mitm_hook::MitmHookConfig {
host: "api.github.com".to_string(),
matcher: crate::mitm_hook::MitmHookMatchConfig {
methods: vec!["POST".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
..crate::mitm_hook::MitmHookMatchConfig::default()
},
actions: crate::mitm_hook::MitmHookActionsConfig::default(),
}],
..Default::default()
};
policy.set_allowed_domains(vec!["api.github.com".to_string()]);
let state = Arc::new(network_proxy_state_for_policy(policy));
let mut req = Request::builder()
.method(Method::CONNECT)
.uri("https://api.github.com:443")
.header("host", "api.github.com:443")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(state);
let response = http_connect_accept(/*policy_decider*/ None, req)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-mitm-required"
);
}
#[tokio::test]
async fn http_proxy_listener_accepts_plain_http1_connect_requests() {
let target_listener = TokioTcpListener::bind((Ipv4Addr::LOCALHOST, 0))

View File

@@ -5,6 +5,7 @@ mod config;
mod connect_policy;
mod http_proxy;
mod mitm;
mod mitm_hook;
mod network_policy;
mod policy;
mod proxy;
@@ -23,6 +24,11 @@ pub use config::NetworkProxyConfig;
pub use config::NetworkUnixSocketPermission;
pub use config::NetworkUnixSocketPermissions;
pub use config::host_and_port_from_network_addr;
pub use mitm_hook::InjectedHeaderConfig;
pub use mitm_hook::MitmHookActionsConfig;
pub use mitm_hook::MitmHookBodyConfig;
pub use mitm_hook::MitmHookConfig;
pub use mitm_hook::MitmHookMatchConfig;
pub use network_policy::NetworkDecision;
pub use network_policy::NetworkDecisionSource;
pub use network_policy::NetworkPolicyDecider;

View File

@@ -1,7 +1,10 @@
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;
@@ -23,6 +26,7 @@ use rama_core::rt::Executor;
use rama_core::service::service_fn;
use rama_http::Body;
use rama_http::BodyDataStream;
use rama_http::HeaderMap;
use rama_http::HeaderValue;
use rama_http::Request;
use rama_http::Response;
@@ -71,6 +75,13 @@ struct MitmRequestContext {
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;
@@ -86,9 +97,10 @@ impl std::fmt::Debug for MitmState {
impl MitmState {
pub(crate) fn new(config: MitmUpstreamConfig) -> 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.
// 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 {
@@ -200,9 +212,10 @@ async fn handle_mitm_request(
}
async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Result<Response> {
if let Some(response) = mitm_blocking_response(&req, &request_ctx.policy).await? {
return Ok(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;
@@ -213,6 +226,7 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu
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
@@ -247,12 +261,23 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu
)
}
#[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(Some(text_response(
return Ok(MitmPolicyDecision::Block(text_response(
StatusCode::METHOD_NOT_ALLOWED,
"CONNECT not supported inside MITM",
)));
@@ -272,7 +297,7 @@ async fn mitm_blocking_response(
"MITM host mismatch (target={}, request_host={normalized})",
policy.target_host
);
return Ok(Some(text_response(
return Ok(MitmPolicyDecision::Block(text_response(
StatusCode::BAD_REQUEST,
"host mismatch",
)));
@@ -307,7 +332,43 @@ async fn mitm_blocking_response(
"MITM blocked local/private target after CONNECT (host={}, port={}, method={method}, path={log_path})",
policy.target_host, policy.target_port
);
return Ok(Some(blocked_text_response(reason)));
return Ok(MitmPolicyDecision::Block(blocked_text_response(reason)));
}
match policy
.app_state
.evaluate_mitm_hook_request(&policy.target_host, req)
.await?
{
HookEvaluation::Matched { actions } => {
return Ok(MitmPolicyDecision::Allow {
hook_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 => {}
}
if !policy.mode.allows_method(&method) {
@@ -329,10 +390,25 @@ async fn mitm_blocking_response(
"MITM blocked by method policy (host={}, method={method}, path={log_path}, mode={:?}, allowed_methods=GET, HEAD, OPTIONS)",
policy.target_host, policy.mode
);
return Ok(Some(blocked_text_response(REASON_METHOD_NOT_ALLOWED)));
return Ok(MitmPolicyDecision::Block(blocked_text_response(
REASON_METHOD_NOT_ALLOWED,
)));
}
Ok(None)
Ok(MitmPolicyDecision::Allow { hook_actions: None })
}
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(

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,39 @@ use super::*;
use crate::config::NetworkProxySettings;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
use crate::reasons::REASON_MITM_HOOK_DENIED;
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
use crate::runtime::network_proxy_state_for_policy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use rama_http::Body;
use rama_http::HeaderMap;
use rama_http::HeaderValue;
use rama_http::Method;
use rama_http::Request;
use rama_http::StatusCode;
use rama_http::header::HeaderName;
use tempfile::NamedTempFile;
fn github_write_hook() -> crate::mitm_hook::MitmHookConfig {
crate::mitm_hook::MitmHookConfig {
host: "api.github.com".to_string(),
matcher: crate::mitm_hook::MitmHookMatchConfig {
methods: vec!["POST".to_string(), "PUT".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
..crate::mitm_hook::MitmHookMatchConfig::default()
},
actions: crate::mitm_hook::MitmHookActionsConfig {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: vec![crate::mitm_hook::InjectedHeaderConfig {
name: "authorization".to_string(),
secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()),
secret_file: None,
prefix: Some("Bearer ".to_string()),
}],
},
}
}
fn policy_ctx(
app_state: Arc<NetworkProxyState>,
@@ -126,3 +152,125 @@ async fn mitm_policy_rechecks_local_private_target_after_connect() {
assert_eq!(blocked[0].host, "10.0.0.1");
assert_eq!(blocked[0].port, Some(443));
}
#[tokio::test]
async fn mitm_policy_allows_matching_hooked_write_in_full_mode() {
let secret_file = NamedTempFile::new().unwrap();
std::fs::write(secret_file.path(), "ghp-secret\n").unwrap();
let mut hook = github_write_hook();
hook.actions.inject_request_headers[0].secret_env_var = None;
hook.actions.inject_request_headers[0].secret_file =
Some(secret_file.path().display().to_string());
let mut network = NetworkProxySettings {
mitm: true,
mitm_hooks: vec![hook],
mode: NetworkMode::Full,
..NetworkProxySettings::default()
};
network.set_allowed_domains(vec!["api.github.com".to_string()]);
let app_state = Arc::new(network_proxy_state_for_policy(network));
let ctx = policy_ctx(
app_state.clone(),
NetworkMode::Full,
"api.github.com",
/*target_port*/ 443,
);
let req = Request::builder()
.method(Method::POST)
.uri("/repos/openai/codex/issues")
.header(HOST, "api.github.com")
.body(Body::empty())
.unwrap();
let response = mitm_blocking_response(&req, &ctx).await.unwrap();
assert!(
response.is_none(),
"matching hook should be allowed in full mode"
);
assert_eq!(app_state.blocked_snapshot().await.unwrap().len(), 0);
}
#[tokio::test]
async fn mitm_policy_blocks_hook_miss_for_hooked_host_and_records_telemetry_in_full_mode() {
let secret_file = NamedTempFile::new().unwrap();
std::fs::write(secret_file.path(), "ghp-secret\n").unwrap();
let mut hook = github_write_hook();
hook.actions.inject_request_headers[0].secret_env_var = None;
hook.actions.inject_request_headers[0].secret_file =
Some(secret_file.path().display().to_string());
let mut network = NetworkProxySettings {
mitm: true,
mitm_hooks: vec![hook],
mode: NetworkMode::Full,
..NetworkProxySettings::default()
};
network.set_allowed_domains(vec!["api.github.com".to_string()]);
let app_state = Arc::new(network_proxy_state_for_policy(network));
let ctx = policy_ctx(
app_state.clone(),
NetworkMode::Full,
"api.github.com",
/*target_port*/ 443,
);
let req = Request::builder()
.method(Method::GET)
.uri("/repos/openai/codex/issues?token=secret")
.header(HOST, "api.github.com")
.header("authorization", "Bearer user-supplied")
.body(Body::empty())
.unwrap();
let response = mitm_blocking_response(&req, &ctx)
.await
.unwrap()
.expect("hook miss should be blocked");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-mitm-hook"
);
let blocked = app_state.drain_blocked().await.unwrap();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0].reason, REASON_MITM_HOOK_DENIED);
assert_eq!(blocked[0].method.as_deref(), Some("GET"));
assert_eq!(blocked[0].host, "api.github.com");
assert_eq!(blocked[0].port, Some(443));
}
#[test]
fn apply_mitm_hook_actions_replaces_authorization_header() {
let mut headers = HeaderMap::new();
headers.append(
HeaderName::from_static("authorization"),
HeaderValue::from_static("Bearer user-supplied"),
);
headers.append(
HeaderName::from_static("x-request-id"),
HeaderValue::from_static("req_123"),
);
let actions = crate::mitm_hook::MitmHookActions {
strip_request_headers: vec![HeaderName::from_static("authorization")],
inject_request_headers: vec![crate::mitm_hook::ResolvedInjectedHeader {
name: HeaderName::from_static("authorization"),
value: HeaderValue::from_static("Bearer secret-token"),
source: crate::mitm_hook::SecretSource::File(
AbsolutePathBuf::try_from("/tmp/github-token").unwrap(),
),
}],
};
apply_mitm_hook_actions(&mut headers, Some(&actions));
assert_eq!(
headers.get("authorization"),
Some(&HeaderValue::from_static("Bearer secret-token"))
);
assert_eq!(
headers.get("x-request-id"),
Some(&HeaderValue::from_static("req_123"))
);
}

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_HOOK_DENIED: &str = "mitm_hook_denied";
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";

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_HOOK_DENIED;
use crate::reasons::REASON_MITM_REQUIRED;
use crate::reasons::REASON_NOT_ALLOWED;
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
@@ -53,6 +54,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_HOOK_DENIED => "blocked-by-mitm-hook",
REASON_MITM_REQUIRED => "blocked-by-mitm-required",
_ => "blocked-by-policy",
}
@@ -64,6 +66,7 @@ pub fn blocked_message(reason: &str) -> &'static str {
REASON_NOT_ALLOWED_LOCAL => "Sandbox policy blocks local/private network addresses.",
REASON_DENIED => "Domain denied by the sandbox policy.",
REASON_METHOD_NOT_ALLOWED => "Method not allowed in limited mode.",
REASON_MITM_HOOK_DENIED => "HTTPS request denied by MITM hook policy.",
REASON_MITM_REQUIRED => "MITM required for limited HTTPS.",
REASON_PROXY_DISABLED => "network proxy is disabled",
_ => "Request blocked by network policy.",

View File

@@ -3,6 +3,9 @@ use crate::config::NetworkMode;
use crate::config::NetworkProxyConfig;
use crate::config::ValidatedUnixSocketPath;
use crate::mitm::MitmState;
use crate::mitm_hook::HookEvaluation;
use crate::mitm_hook::MitmHooksByHost;
use crate::mitm_hook::evaluate_mitm_hooks;
use crate::policy::Host;
use crate::policy::is_loopback_host;
use crate::policy::is_non_public_ip;
@@ -159,6 +162,7 @@ pub struct ConfigState {
pub allow_set: GlobSet,
pub deny_set: GlobSet,
pub mitm: Option<Arc<MitmState>>,
pub mitm_hooks: MitmHooksByHost,
pub constraints: NetworkProxyConstraints,
pub blocked: VecDeque<BlockedRequest>,
pub blocked_total: u64,
@@ -585,6 +589,22 @@ impl NetworkProxyState {
Ok(guard.mitm.clone())
}
pub(crate) async fn evaluate_mitm_hook_request(
&self,
host: &str,
req: &rama_http::Request,
) -> Result<HookEvaluation> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok(evaluate_mitm_hooks(&guard.mitm_hooks, host, req))
}
pub async fn host_has_mitm_hooks(&self, host: &str) -> Result<bool> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok(guard.mitm_hooks.contains_key(&normalize_host(host)))
}
pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
self.update_domain_list(host, DomainListKind::Allow).await
}
@@ -846,9 +866,23 @@ pub(crate) fn network_proxy_state_for_policy(
mut network: crate::config::NetworkProxySettings,
) -> NetworkProxyState {
network.enabled = true;
network.mode = NetworkMode::Full;
let config = NetworkProxyConfig { network };
let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
let state = ConfigState {
allow_set: crate::policy::compile_allowlist_globset(
&config.network.allowed_domains().unwrap_or_default(),
)
.unwrap(),
blocked: VecDeque::new(),
blocked_total: 0,
config: config.clone(),
constraints: NetworkProxyConstraints::default(),
deny_set: crate::policy::compile_denylist_globset(
&config.network.denied_domains().unwrap_or_default(),
)
.unwrap(),
mitm: None,
mitm_hooks: crate::mitm_hook::compile_mitm_hooks(&config).unwrap(),
};
NetworkProxyState::with_reloader(state, Arc::new(NoopReloader))
}

View File

@@ -4,6 +4,9 @@ use crate::config::NetworkProxyConfig;
use crate::config::NetworkUnixSocketPermissions;
use crate::mitm::MitmState;
use crate::mitm::MitmUpstreamConfig;
use crate::mitm_hook::MitmHookConfig;
use crate::mitm_hook::compile_mitm_hooks;
use crate::mitm_hook::validate_mitm_hook_config;
use crate::policy::DomainPattern;
use crate::policy::compile_allowlist_globset;
use crate::policy::compile_denylist_globset;
@@ -53,6 +56,9 @@ pub struct PartialNetworkConfig {
#[serde(default)]
pub unix_sockets: Option<NetworkUnixSocketPermissions>,
pub allow_local_binding: Option<bool>,
pub mitm: Option<bool>,
#[serde(default)]
pub mitm_hooks: Option<Vec<MitmHookConfig>>,
}
pub fn build_config_state(
@@ -66,6 +72,7 @@ pub fn build_config_state(
.map_err(NetworkProxyConstraintError::into_anyhow)?;
let deny_set = compile_denylist_globset(&denied_domains)?;
let allow_set = compile_allowlist_globset(&allowed_domains)?;
let mitm_hooks = compile_mitm_hooks(&config)?;
let mitm = if config.network.mitm {
Some(Arc::new(MitmState::new(MitmUpstreamConfig {
allow_upstream_proxy: config.network.allow_upstream_proxy,
@@ -79,6 +86,7 @@ pub fn build_config_state(
allow_set,
deny_set,
mitm,
mitm_hooks,
constraints,
blocked: std::collections::VecDeque::new(),
blocked_total: 0,
@@ -116,6 +124,7 @@ pub fn validate_policy_against_constraints(
.map(|entry| entry.to_ascii_lowercase())
.collect();
let config_allow_unix_sockets = config.network.allow_unix_sockets();
validate_mitm_hook_config(config).map_err(invalid_mitm_hook_configuration)?;
validate_non_global_wildcard_domain_patterns("network.denied_domains", &config_denied_domains)?;
if let Some(max_enabled) = constraints.enabled {
validate(enabled, move |candidate| {
@@ -376,6 +385,14 @@ pub fn validate_policy_against_constraints(
Ok(())
}
fn invalid_mitm_hook_configuration(err: anyhow::Error) -> NetworkProxyConstraintError {
NetworkProxyConstraintError::InvalidValue {
field_name: "network.mitm_hooks",
candidate: err.to_string(),
allowed: "valid MITM hook configuration".to_string(),
}
}
fn validate_non_global_wildcard_domain_patterns(
field_name: &'static str,
patterns: &[String],