Compare commits

...

7 Commits

Author SHA1 Message Date
Winston Howes
f74951488b Preserve trusted MITM hook constraints 2026-05-16 01:34:43 -07:00
Eva Wong
60c59733f2 Preserve MITM hook order 2026-05-15 05:20:56 -07:00
Eva Wong
b4a1e10ce5 Use named MITM permissions config 2026-05-15 05:20:56 -07:00
Eva Wong
2c9ccbe030 Wire MITM hooks into runtime enforcement 2026-05-15 05:20:34 -07:00
Eva Wong
af0113519a Keep MITM profile config internal 2026-05-15 05:20:17 -07:00
Eva Wong
cdefa6f448 Clarify full mode MITM hook behavior 2026-05-15 05:20:17 -07:00
Eva Wong
4334b19c23 Add MITM hook config model 2026-05-15 05:20:17 -07:00
19 changed files with 2187 additions and 29 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2432,6 +2432,7 @@ dependencies = [
"dunce",
"futures",
"gethostname",
"indexmap 2.13.0",
"libc",
"multimap",
"pretty_assertions",

View File

@@ -28,6 +28,7 @@ codex-utils-path = { workspace = true }
dunce = { workspace = true }
futures = { workspace = true, features = ["alloc", "std"] }
gethostname = { workspace = true }
indexmap = { workspace = true, features = ["serde"] }
multimap = { workspace = true }
prost = "0.14.3"
schemars = { workspace = true }
@@ -38,7 +39,7 @@ serde_path_to_error = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
toml = { workspace = true }
toml = { workspace = true, features = ["preserve_order"] }
toml_edit = { workspace = true }
tonic = { workspace = true }
tonic-prost = { workspace = true }

View File

@@ -1,11 +1,17 @@
use std::collections::BTreeMap;
use codex_network_proxy::InjectedHeaderConfig;
use codex_network_proxy::MitmHookActionsConfig;
use codex_network_proxy::MitmHookBodyConfig;
use codex_network_proxy::MitmHookConfig;
use codex_network_proxy::MitmHookMatchConfig;
use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
use codex_network_proxy::normalize_host;
use codex_protocol::permissions::FileSystemAccessMode;
use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -173,6 +179,38 @@ pub struct NetworkToml {
pub domains: Option<NetworkDomainPermissionsToml>,
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
pub allow_local_binding: Option<bool>,
pub mitm: Option<NetworkMitmToml>,
}
#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkMitmToml {
#[schemars(with = "Option<BTreeMap<String, NetworkMitmHookToml>>")]
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
#[schemars(with = "Option<BTreeMap<String, NetworkMitmActionToml>>")]
pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NetworkMitmTomlUnchecked {
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkMitmHookToml {
pub host: String,
pub methods: Vec<String>,
pub path_prefixes: Vec<String>,
#[serde(default)]
pub query: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub headers: BTreeMap<String, Vec<String>>,
#[schemars(with = "Option<MitmHookBodyConfigSchema>")]
pub body: Option<MitmHookBodyConfig>,
pub action: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -182,6 +220,114 @@ enum NetworkModeSchema {
Full,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
pub struct NetworkMitmActionToml {
pub strip_request_headers: Vec<String>,
pub inject_request_headers: Vec<NetworkMitmInjectedHeaderToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
pub struct NetworkMitmInjectedHeaderToml {
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<'de> Deserialize<'de> for NetworkMitmToml {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let unchecked = NetworkMitmTomlUnchecked::deserialize(deserializer)?;
let mitm = Self {
hooks: unchecked.hooks,
actions: unchecked.actions,
};
mitm.validate_action_definitions()
.map_err(serde::de::Error::custom)?;
Ok(mitm)
}
}
impl NetworkMitmToml {
pub fn validate_action_definitions(&self) -> Result<(), String> {
if let Some(actions) = self.actions.as_ref() {
for (action_name, action) in actions {
if action.is_empty() {
return Err(format!(
"network.mitm.actions.{action_name} must define at least one operation"
));
}
}
}
let Some(hooks) = self.hooks.as_ref() else {
return Ok(());
};
for (hook_name, hook) in hooks {
if hook.action.is_empty() {
return Err(format!(
"network.mitm.hooks.{hook_name}.action must not be empty"
));
}
}
Ok(())
}
pub fn validate_action_references(
&self,
actions_by_name: &IndexMap<String, NetworkMitmActionToml>,
) -> Result<(), String> {
self.validate_action_definitions()?;
let Some(hooks) = self.hooks.as_ref() else {
return Ok(());
};
for (hook_name, hook) in hooks {
for action_name in &hook.action {
if !actions_by_name.contains_key(action_name) {
return Err(format!(
"network.mitm.hooks.{hook_name}.action references undefined action `{action_name}`"
));
}
}
}
Ok(())
}
pub fn to_runtime_hooks(
&self,
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
) -> Vec<MitmHookConfig> {
self.hooks
.as_ref()
.map(|hooks| {
hooks
.values()
.map(|hook| hook.to_runtime(actions_by_name))
.collect()
})
.unwrap_or_default()
}
}
impl NetworkMitmActionToml {
pub fn is_empty(&self) -> bool {
self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty()
}
}
impl NetworkToml {
pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) {
if let Some(enabled) = self.enabled {
@@ -234,6 +380,11 @@ 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.as_ref() {
config.network.mitm_hooks = mitm.to_runtime_hooks(mitm.actions.as_ref());
}
config.network.mitm =
config.network.mode == NetworkMode::Limited || !config.network.mitm_hooks.is_empty();
}
pub fn to_network_proxy_config(&self) -> NetworkProxyConfig {
@@ -243,6 +394,61 @@ impl NetworkToml {
}
}
impl NetworkMitmHookToml {
fn to_runtime(
&self,
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
) -> MitmHookConfig {
MitmHookConfig {
host: self.host.clone(),
matcher: MitmHookMatchConfig {
methods: self.methods.clone(),
path_prefixes: self.path_prefixes.clone(),
query: self.query.clone(),
headers: self.headers.clone(),
body: self.body.clone(),
},
actions: self.selected_actions(actions_by_name),
}
}
fn selected_actions(
&self,
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
) -> MitmHookActionsConfig {
let Some(actions_by_name) = actions_by_name else {
return MitmHookActionsConfig::default();
};
let mut selected = MitmHookActionsConfig::default();
for action_name in &self.action {
if let Some(action) = actions_by_name.get(action_name) {
selected
.strip_request_headers
.extend(action.strip_request_headers.clone());
selected.inject_request_headers.extend(
action
.inject_request_headers
.iter()
.map(NetworkMitmInjectedHeaderToml::to_runtime),
);
}
}
selected
}
}
impl NetworkMitmInjectedHeaderToml {
fn to_runtime(&self) -> InjectedHeaderConfig {
InjectedHeaderConfig {
name: self.name.clone(),
secret_env_var: self.secret_env_var.clone(),
secret_file: self.secret_file.clone(),
prefix: self.prefix.clone(),
}
}
}
pub fn overlay_network_domain_permissions(
config: &mut NetworkProxyConfig,
domains: &NetworkDomainPermissionsToml,

View File

@@ -1535,6 +1535,118 @@
"NetworkDomainPermissionsToml": {
"type": "object"
},
"NetworkMitmActionToml": {
"properties": {
"inject_request_headers": {
"default": [],
"items": {
"$ref": "#/definitions/NetworkMitmInjectedHeaderToml"
},
"type": "array"
},
"strip_request_headers": {
"default": [],
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"NetworkMitmHookToml": {
"additionalProperties": false,
"properties": {
"action": {
"items": {
"type": "string"
},
"type": "array"
},
"body": true,
"headers": {
"additionalProperties": {
"items": {
"type": "string"
},
"type": "array"
},
"default": {},
"type": "object"
},
"host": {
"type": "string"
},
"methods": {
"items": {
"type": "string"
},
"type": "array"
},
"path_prefixes": {
"items": {
"type": "string"
},
"type": "array"
},
"query": {
"additionalProperties": {
"items": {
"type": "string"
},
"type": "array"
},
"default": {},
"type": "object"
}
},
"required": [
"action",
"host",
"methods",
"path_prefixes"
],
"type": "object"
},
"NetworkMitmInjectedHeaderToml": {
"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"
},
"NetworkMitmToml": {
"additionalProperties": false,
"properties": {
"actions": {
"additionalProperties": {
"$ref": "#/definitions/NetworkMitmActionToml"
},
"type": "object"
},
"hooks": {
"additionalProperties": {
"$ref": "#/definitions/NetworkMitmHookToml"
},
"type": "object"
}
},
"type": "object"
},
"NetworkModeSchema": {
"enum": [
"limited",
@@ -1638,6 +1750,9 @@
"enabled": {
"type": "boolean"
},
"mitm": {
"$ref": "#/definitions/NetworkMitmToml"
},
"mode": {
"$ref": "#/definitions/NetworkModeSchema"
},

View File

@@ -26,6 +26,9 @@ use codex_config::permissions_toml::FilesystemPermissionToml;
use codex_config::permissions_toml::FilesystemPermissionsToml;
use codex_config::permissions_toml::NetworkDomainPermissionToml;
use codex_config::permissions_toml::NetworkDomainPermissionsToml;
use codex_config::permissions_toml::NetworkMitmActionToml;
use codex_config::permissions_toml::NetworkMitmHookToml;
use codex_config::permissions_toml::NetworkMitmToml;
use codex_config::permissions_toml::NetworkToml;
use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::permissions_toml::PermissionsToml;
@@ -69,6 +72,7 @@ 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::NetworkMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
@@ -94,6 +98,7 @@ use core_test_support::PathBufExt;
use core_test_support::PathExt;
use core_test_support::TempDirExt;
use core_test_support::test_absolute_path;
use indexmap::IndexMap;
use pretty_assertions::assert_eq;
use rmcp::model::ElicitationCapability;
use rmcp::model::FormElicitationCapability;
@@ -750,9 +755,19 @@ enabled = true
proxy_url = "http://127.0.0.1:43128"
enable_socks5 = false
allow_upstream_proxy = false
mode = "full"
[permissions.workspace.network.domains]
"openai.com" = "allow"
[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST", "PUT"]
path_prefixes = ["/repos/openai/"]
action = ["strip_auth"]
[permissions.workspace.network.mitm.actions.strip_auth]
strip_request_headers = ["authorization"]
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles");
@@ -795,7 +810,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(),
@@ -804,6 +819,27 @@ allow_upstream_proxy = false
}),
unix_sockets: None,
allow_local_binding: None,
mitm: Some(NetworkMitmToml {
hooks: Some(IndexMap::from([(
"github_write".to_string(),
NetworkMitmHookToml {
host: "api.github.com".to_string(),
methods: vec!["POST".to_string(), "PUT".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
query: BTreeMap::new(),
headers: BTreeMap::new(),
body: None,
action: vec!["strip_auth".to_string()],
},
)])),
actions: Some(IndexMap::from([(
"strip_auth".to_string(),
NetworkMitmActionToml {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: Vec::new(),
},
)])),
}),
}),
},
)]),
@@ -811,6 +847,140 @@ allow_upstream_proxy = false
);
}
#[test]
fn config_toml_rejects_empty_mitm_action_reference_list() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = []
[permissions.workspace.network.mitm.actions.strip_auth]
strip_request_headers = ["authorization"]
"#;
let err =
toml::from_str::<ConfigToml>(toml).expect_err("empty MITM action refs should fail closed");
assert!(
err.to_string()
.contains("network.mitm.hooks.github_write.action must not be empty"),
"{err}"
);
}
#[test]
fn config_toml_rejects_empty_mitm_action_definition() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = ["strip_auth"]
[permissions.workspace.network.mitm.actions.strip_auth]
"#;
let err = toml::from_str::<ConfigToml>(toml)
.expect_err("empty MITM action definitions should fail closed");
assert!(
err.to_string()
.contains("network.mitm.actions.strip_auth must define at least one operation"),
"{err}"
);
}
#[test]
fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() {
let network = NetworkToml {
mode: Some(NetworkMode::Full),
mitm: Some(NetworkMitmToml {
hooks: Some(IndexMap::from([(
"github_write".to_string(),
NetworkMitmHookToml {
host: "api.github.com".to_string(),
methods: vec!["POST".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
action: vec!["strip_auth".to_string()],
..NetworkMitmHookToml::default()
},
)])),
actions: Some(IndexMap::from([(
"strip_auth".to_string(),
NetworkMitmActionToml {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: Vec::new(),
},
)])),
}),
..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.len(), 1);
assert_eq!(config.network.mitm_hooks[0].host, "api.github.com");
assert_eq!(
config.network.mitm_hooks[0].matcher.methods,
vec!["POST".to_string()]
);
assert_eq!(
config.network.mitm_hooks[0].actions.strip_request_headers,
vec!["authorization".to_string()]
);
}
#[test]
fn permissions_profile_network_to_proxy_config_preserves_mitm_hook_declaration_order() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace.network.mitm.actions.noop]
strip_request_headers = ["authorization"]
[permissions.workspace.network.mitm.hooks.z_first]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = ["noop"]
[permissions.workspace.network.mitm.hooks.a_second]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/"]
action = ["noop"]
"#;
let cfg: ConfigToml = toml::from_str(toml).expect("permissions profile should deserialize");
let permissions = cfg.permissions.expect("permissions should deserialize");
let network = permissions
.entries
.get("workspace")
.expect("workspace profile should exist")
.network
.as_ref()
.expect("network profile should exist");
let config = network.to_network_proxy_config();
assert_eq!(config.network.mitm_hooks.len(), 2);
assert_eq!(
config.network.mitm_hooks[0].matcher.path_prefixes,
vec!["/repos/openai/".to_string()]
);
assert_eq!(
config.network.mitm_hooks[1].matcher.path_prefixes,
vec!["/repos/".to_string()]
);
}
#[tokio::test]
async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_without_feature()
-> std::io::Result<()> {

View File

@@ -185,6 +185,7 @@ pub(crate) fn apply_network_proxy_feature_config(
}
}),
allow_local_binding: feature_config.allow_local_binding,
mitm: None,
}
.apply_to_network_proxy_config(config);
}

View File

@@ -13,12 +13,16 @@ use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::LoaderOverrides;
use codex_config::loader::load_config_layers_state;
use codex_config::permissions_toml::NetworkMitmActionToml;
use codex_config::permissions_toml::NetworkMitmHookToml;
use codex_config::permissions_toml::NetworkMitmToml;
use codex_config::permissions_toml::NetworkToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_config::permissions_toml::overlay_network_domain_permissions;
use codex_exec_server::LOCAL_FS;
use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraintError;
use codex_network_proxy::NetworkProxyConstraints;
@@ -27,6 +31,7 @@ use codex_network_proxy::build_config_state;
use codex_network_proxy::normalize_host;
use codex_network_proxy::validate_policy_against_constraints;
use codex_utils_absolute_path::AbsolutePathBuf;
use indexmap::IndexMap;
use serde::Deserialize;
use std::sync::Arc;
use tokio::sync::RwLock;
@@ -117,6 +122,7 @@ fn network_constraints_from_trusted_layers(
layers: &ConfigLayerStack,
) -> Result<NetworkProxyConstraints> {
let mut constraints = NetworkProxyConstraints::default();
let mut trusted_network = NetworkConfigAccumulator::default();
for layer in layers.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
@@ -127,9 +133,13 @@ fn network_constraints_from_trusted_layers(
let parsed = network_tables_from_toml(&layer.config)?;
if let Some(network) = selected_network_from_tables(parsed)? {
apply_network_constraints(network, &mut constraints);
apply_network_constraints(network.clone(), &mut constraints);
trusted_network.apply_network(network);
}
}
let trusted_mitm_hooks = trusted_network.finish()?.network.mitm_hooks;
constraints.required_mitm_hook_prefix =
(!trusted_mitm_hooks.is_empty()).then_some(trusted_mitm_hooks);
Ok(constraints)
}
@@ -198,6 +208,7 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result<Option<Netw
Ok(profile.network.clone())
}
#[cfg(test)]
fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) -> Result<()> {
if let Some(network) = selected_network_from_tables(parsed)? {
network.apply_to_network_proxy_config(config);
@@ -205,18 +216,66 @@ fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesTo
Ok(())
}
#[derive(Default)]
struct NetworkConfigAccumulator {
config: NetworkProxyConfig,
mitm_hooks: IndexMap<String, NetworkMitmHookToml>,
mitm_actions: IndexMap<String, NetworkMitmActionToml>,
}
impl NetworkConfigAccumulator {
fn apply_network_tables(&mut self, parsed: NetworkTablesToml) -> Result<()> {
if let Some(network) = selected_network_from_tables(parsed)? {
self.apply_network(network);
}
Ok(())
}
fn apply_network(&mut self, mut network: NetworkToml) {
let mitm = network.mitm.take();
network.apply_to_network_proxy_config(&mut self.config);
if let Some(mitm) = mitm {
if let Some(actions) = mitm.actions {
self.mitm_actions.extend(actions);
}
if let Some(hooks) = mitm.hooks {
self.mitm_hooks.extend(hooks);
}
}
}
fn finish(mut self) -> Result<NetworkProxyConfig> {
if !self.mitm_hooks.is_empty() {
let actions = self.mitm_actions;
let mitm = NetworkMitmToml {
hooks: Some(self.mitm_hooks),
actions: Some(actions.clone()),
};
mitm.validate_action_references(&actions)
.map_err(anyhow::Error::msg)?;
self.config.network.mitm_hooks = mitm.to_runtime_hooks(Some(&actions));
}
self.config.network.mitm = self.config.network.mode == NetworkMode::Limited
|| !self.config.network.mitm_hooks.is_empty();
Ok(self.config)
}
}
fn config_from_layers(
layers: &ConfigLayerStack,
exec_policy: &codex_execpolicy::Policy,
) -> Result<NetworkProxyConfig> {
let mut config = NetworkProxyConfig::default();
let mut accumulator = NetworkConfigAccumulator::default();
for layer in layers.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
) {
let parsed = network_tables_from_toml(&layer.config)?;
apply_network_tables(&mut config, parsed)?;
accumulator.apply_network_tables(parsed)?;
}
let mut config = accumulator.finish()?;
apply_exec_policy_network_rules(&mut config, exec_policy);
Ok(config)
}

View File

@@ -104,6 +104,142 @@ default_permissions = "workspace"
assert_eq!(config.network.denied_domains(), None);
}
#[test]
fn higher_precedence_profile_network_overrides_named_mitm_actions() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
mode = "full"
[permissions.workspace.network.domains]
"lower.example.com" = "allow"
[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = ["strip_auth"]
[permissions.workspace.network.mitm.actions.strip_auth]
strip_request_headers = ["authorization"]
"#,
)
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
mode = "full"
[permissions.workspace.network.domains]
"higher.example.com" = "allow"
[permissions.workspace.network.mitm.actions.strip_auth]
strip_request_headers = ["x-api-key"]
"#,
)
.expect("higher layer should parse");
let mut accumulator = NetworkConfigAccumulator::default();
accumulator
.apply_network_tables(
network_tables_from_toml(&lower_network).expect("lower layer should deserialize"),
)
.expect("lower layer should apply");
accumulator
.apply_network_tables(
network_tables_from_toml(&higher_network).expect("higher layer should deserialize"),
)
.expect("higher layer should apply");
let config = accumulator.finish().expect("merged config should build");
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!["POST"]);
assert_eq!(
config.network.mitm_hooks[0].actions.strip_request_headers,
vec!["x-api-key"]
);
}
#[test]
fn trusted_named_mitm_actions_become_required_hook_constraints() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
mode = "full"
[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = ["strip_auth"]
[permissions.workspace.network.mitm.actions.strip_auth]
strip_request_headers = ["authorization"]
"#,
)
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
mode = "full"
[permissions.workspace.network.mitm.actions.strip_auth]
strip_request_headers = ["x-api-key"]
"#,
)
.expect("higher layer should parse");
let mut trusted_network = NetworkConfigAccumulator::default();
trusted_network
.apply_network_tables(
network_tables_from_toml(&lower_network).expect("lower layer should deserialize"),
)
.expect("lower layer should apply");
trusted_network
.apply_network_tables(
network_tables_from_toml(&higher_network).expect("higher layer should deserialize"),
)
.expect("higher layer should apply");
let constraints = NetworkProxyConstraints {
required_mitm_hook_prefix: Some(
trusted_network
.finish()
.expect("trusted network should build")
.network
.mitm_hooks,
),
..NetworkProxyConstraints::default()
};
assert_eq!(
constraints
.required_mitm_hook_prefix
.expect("trusted hooks should be constrained")[0]
.actions
.strip_request_headers,
vec!["x-api-key"]
);
}
#[test]
fn execpolicy_network_rules_overlay_network_lists() {
let mut config = NetworkProxyConfig::default();

View File

@@ -33,8 +33,7 @@ allow_upstream_proxy = true
# 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.
mitm = false
# HTTPS MITM is enabled automatically when `mode = "limited"` or when MITM hooks are configured.
# CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key).
# If false, local/private networking is rejected. Explicit allowlisting of local IP literals
@@ -57,6 +56,17 @@ dangerously_allow_all_unix_sockets = false
"::1" = "allow"
"evil.example" = "deny"
# MITM hooks match HTTPS requests after CONNECT is terminated.
[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST", "PUT"]
path_prefixes = ["/repos/openai/"]
action = ["strip_auth"]
# Named actions can be shared across hooks and overridden by higher-precedence config layers.
[permissions.workspace.network.mitm.actions.strip_auth]
strip_request_headers = ["authorization"]
# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`.
[permissions.workspace.network.unix_sockets]
"/tmp/example.sock" = "allow"

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 bypass method clamp"
);
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))
}
@@ -1495,6 +1529,61 @@ mod tests {
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_preserves_managed_mitm_hooks() {
let managed_hook = 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 {
strip_request_headers: vec!["authorization".to_string()],
..crate::mitm_hook::MitmHookActionsConfig::default()
},
};
let extra_hook = crate::mitm_hook::MitmHookConfig {
host: "api.example.com".to_string(),
matcher: crate::mitm_hook::MitmHookMatchConfig {
methods: vec!["GET".to_string()],
path_prefixes: vec!["/".to_string()],
..crate::mitm_hook::MitmHookMatchConfig::default()
},
..crate::mitm_hook::MitmHookConfig::default()
};
let constraints = NetworkProxyConstraints {
required_mitm_hook_prefix: Some(vec![managed_hook.clone()]),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
mitm: true,
mitm_hooks: vec![managed_hook.clone(), extra_hook],
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
mitm: true,
mitm_hooks: vec![crate::mitm_hook::MitmHookConfig {
actions: crate::mitm_hook::MitmHookActionsConfig {
strip_request_headers: vec!["x-api-key".to_string()],
..crate::mitm_hook::MitmHookActionsConfig::default()
},
..managed_hook
}],
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_allows_narrowing_wildcard_allowlist() {
let constraints = NetworkProxyConstraints {

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;
@@ -33,6 +36,7 @@ pub struct NetworkProxyConstraints {
pub denylist_expansion_enabled: Option<bool>,
pub allow_unix_sockets: Option<Vec<String>>,
pub allow_local_binding: Option<bool>,
pub required_mitm_hook_prefix: Option<Vec<MitmHookConfig>>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -53,6 +57,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 +73,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 +87,7 @@ pub fn build_config_state(
allow_set,
deny_set,
mitm,
mitm_hooks,
constraints,
blocked: std::collections::VecDeque::new(),
blocked_total: 0,
@@ -116,7 +125,20 @@ 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(required_mitm_hook_prefix) = constraints.required_mitm_hook_prefix.as_ref()
&& !config
.network
.mitm_hooks
.starts_with(required_mitm_hook_prefix)
{
return Err(invalid_value(
"network.mitm_hooks",
"managed MITM hooks were replaced or reordered",
"managed MITM hooks preserved before lower-trust hooks",
));
}
if let Some(max_enabled) = constraints.enabled {
validate(enabled, move |candidate| {
if *candidate && !max_enabled {
@@ -376,6 +398,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],