Files
codex/codex-rs/network-proxy/src/state.rs
evawong-oai f6970214d2 Wire MITM hooks into runtime enforcement (#20659)
## Stack
1. Parent PR: #18868 adds MITM hook config and model only.
2. This PR wires runtime enforcement.
3. User facing config follow up: #18240 moves MITM policy into the
PermissionProfile network tree.

## Why
1. After the hook model exists, the proxy needs a separate behavior
change that can be tested at the request path.
2. This PR makes hooked HTTPS hosts require MITM, evaluates inner
requests after CONNECT, mutates headers for matching hooks, and blocks
hooked hosts when no hook matches.
3. It also fixes the activation path so a permission profile with MITM
hook policy starts the managed proxy.
4. Keeping this separate from #18868 lets reviewers focus on runtime
effects, telemetry, and request mutation.

## Summary
1. Store compiled MITM hooks in network proxy state.
2. Require MITM for hooked hosts even when network mode is full.
3. Evaluate inner HTTPS requests against host specific hooks.
4. Apply hook actions by replacing request headers before forwarding.
5. Block hooked hosts when no hook matches and record block telemetry.
6. Treat profile MITM hook policy as managed proxy policy so the proxy
starts when needed.
7. Keep the duplicate authorization header replacement and query
preserving request rebuild in this runtime PR.
8. Add runtime tests and README guidance for hook enforcement.

## Validation
1. Ran the network proxy MITM policy tests.
2. Ran the hooked host CONNECT test.
3. Ran the authorization header replacement test.
4. Ran the core permission profile proxy activation test for MITM hooks.
5. Ran the scoped Clippy fixer for the network proxy crate.
6. Ran the scoped Clippy fixer for the core crate.
2026-05-20 14:08:14 -07:00

439 lines
16 KiB
Rust

use crate::config::NetworkDomainPermissions;
use crate::config::NetworkMode;
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;
use crate::policy::is_global_wildcard_domain_pattern;
use crate::runtime::ConfigState;
use serde::Deserialize;
use std::collections::HashSet;
use std::sync::Arc;
pub use crate::runtime::BlockedRequest;
pub use crate::runtime::BlockedRequestArgs;
pub use crate::runtime::NetworkProxyAuditMetadata;
pub use crate::runtime::NetworkProxyState;
#[cfg(test)]
pub(crate) use crate::runtime::network_proxy_state_for_policy;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct NetworkProxyConstraints {
pub enabled: Option<bool>,
pub mode: Option<NetworkMode>,
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
pub allowed_domains: Option<Vec<String>>,
pub allowlist_expansion_enabled: Option<bool>,
pub denied_domains: Option<Vec<String>>,
pub denylist_expansion_enabled: Option<bool>,
pub allow_unix_sockets: Option<Vec<String>>,
pub allow_local_binding: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PartialNetworkProxyConfig {
#[serde(default)]
pub network: PartialNetworkConfig,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct PartialNetworkConfig {
pub enabled: Option<bool>,
pub mode: Option<NetworkMode>,
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
#[serde(default)]
pub domains: Option<NetworkDomainPermissions>,
#[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(
config: NetworkProxyConfig,
constraints: NetworkProxyConstraints,
) -> anyhow::Result<ConfigState> {
crate::config::validate_unix_socket_allowlist_paths(&config)?;
let allowed_domains = config.network.allowed_domains().unwrap_or_default();
let denied_domains = config.network.denied_domains().unwrap_or_default();
validate_non_global_wildcard_domain_patterns("network.denied_domains", &denied_domains)
.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,
allow_local_binding: config.network.allow_local_binding,
})?))
} else {
None
};
Ok(ConfigState {
config,
allow_set,
deny_set,
mitm,
mitm_hooks,
constraints,
blocked: std::collections::VecDeque::new(),
blocked_total: 0,
})
}
pub fn validate_policy_against_constraints(
config: &NetworkProxyConfig,
constraints: &NetworkProxyConstraints,
) -> Result<(), NetworkProxyConstraintError> {
fn invalid_value(
field_name: &'static str,
candidate: impl Into<String>,
allowed: impl Into<String>,
) -> NetworkProxyConstraintError {
NetworkProxyConstraintError::InvalidValue {
field_name,
candidate: candidate.into(),
allowed: allowed.into(),
}
}
fn validate<T>(
candidate: T,
validator: impl FnOnce(&T) -> Result<(), NetworkProxyConstraintError>,
) -> Result<(), NetworkProxyConstraintError> {
validator(&candidate)
}
let enabled = config.network.enabled;
let config_allowed_domains = config.network.allowed_domains().unwrap_or_default();
let config_denied_domains = config.network.denied_domains().unwrap_or_default();
let denied_domain_overrides: HashSet<String> = config_denied_domains
.iter()
.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| {
if *candidate && !max_enabled {
Err(invalid_value(
"network.enabled",
"true",
"false (disabled by managed config)",
))
} else {
Ok(())
}
})?;
}
if let Some(max_mode) = constraints.mode {
validate(config.network.mode, move |candidate| {
if network_mode_rank(*candidate) > network_mode_rank(max_mode) {
Err(invalid_value(
"network.mode",
format!("{candidate:?}"),
format!("{max_mode:?} or more restrictive"),
))
} else {
Ok(())
}
})?;
}
let allow_upstream_proxy = constraints.allow_upstream_proxy;
validate(
config.network.allow_upstream_proxy,
move |candidate| match allow_upstream_proxy {
Some(true) | None => Ok(()),
Some(false) => {
if *candidate {
Err(invalid_value(
"network.allow_upstream_proxy",
"true",
"false (disabled by managed config)",
))
} else {
Ok(())
}
}
},
)?;
let allow_non_loopback_proxy = constraints.dangerously_allow_non_loopback_proxy;
validate(
config.network.dangerously_allow_non_loopback_proxy,
move |candidate| match allow_non_loopback_proxy {
Some(true) | None => Ok(()),
Some(false) => {
if *candidate {
Err(invalid_value(
"network.dangerously_allow_non_loopback_proxy",
"true",
"false (disabled by managed config)",
))
} else {
Ok(())
}
}
},
)?;
let allow_all_unix_sockets = constraints
.dangerously_allow_all_unix_sockets
.unwrap_or(constraints.allow_unix_sockets.is_none());
validate(
config.network.dangerously_allow_all_unix_sockets,
move |candidate| {
if *candidate && !allow_all_unix_sockets {
Err(invalid_value(
"network.dangerously_allow_all_unix_sockets",
"true",
"false (disabled by managed config)",
))
} else {
Ok(())
}
},
)?;
if let Some(allow_local_binding) = constraints.allow_local_binding {
validate(config.network.allow_local_binding, move |candidate| {
if *candidate && !allow_local_binding {
Err(invalid_value(
"network.allow_local_binding",
"true",
"false (disabled by managed config)",
))
} else {
Ok(())
}
})?;
}
if let Some(allowed_domains) = &constraints.allowed_domains {
validate_non_global_wildcard_domain_patterns("network.allowed_domains", allowed_domains)?;
match constraints.allowlist_expansion_enabled {
Some(true) => {
let required_set: HashSet<String> = allowed_domains
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
validate(config_allowed_domains, |candidate| {
let candidate_set: HashSet<String> = candidate
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
let missing: Vec<String> = required_set
.iter()
.filter(|entry| {
!candidate_set.contains(*entry)
&& !denied_domain_overrides.contains(*entry)
})
.cloned()
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(invalid_value(
"network.allowed_domains",
"missing managed allowed_domains entries",
format!("{missing:?}"),
))
}
})?;
}
Some(false) => {
let required_set: HashSet<String> = allowed_domains
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
validate(config_allowed_domains, |candidate| {
let candidate_set: HashSet<String> = candidate
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
let expected_set: HashSet<String> = required_set
.difference(&denied_domain_overrides)
.cloned()
.collect();
if candidate_set == expected_set {
Ok(())
} else {
Err(invalid_value(
"network.allowed_domains",
format!("{candidate:?}"),
"must match managed allowed_domains",
))
}
})?;
}
None => {
let managed_patterns: Vec<DomainPattern> = allowed_domains
.iter()
.map(|entry| DomainPattern::parse_for_constraints(entry))
.collect();
validate(config_allowed_domains, move |candidate| {
let mut invalid = Vec::new();
for entry in candidate {
let candidate_pattern = DomainPattern::parse_for_constraints(entry);
if !managed_patterns
.iter()
.any(|managed| managed.allows(&candidate_pattern))
{
invalid.push(entry.clone());
}
}
if invalid.is_empty() {
Ok(())
} else {
Err(invalid_value(
"network.allowed_domains",
format!("{invalid:?}"),
"subset of managed allowed_domains",
))
}
})?;
}
}
}
if let Some(denied_domains) = &constraints.denied_domains {
validate_non_global_wildcard_domain_patterns("network.denied_domains", denied_domains)?;
let required_set: HashSet<String> = denied_domains
.iter()
.map(|s| s.to_ascii_lowercase())
.collect();
match constraints.denylist_expansion_enabled {
Some(false) => {
validate(config_denied_domains, move |candidate| {
let candidate_set: HashSet<String> = candidate
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
if candidate_set == required_set {
Ok(())
} else {
Err(invalid_value(
"network.denied_domains",
format!("{candidate:?}"),
"must match managed denied_domains",
))
}
})?;
}
Some(true) | None => {
validate(config_denied_domains, move |candidate| {
let candidate_set: HashSet<String> =
candidate.iter().map(|s| s.to_ascii_lowercase()).collect();
let missing: Vec<String> = required_set
.iter()
.filter(|entry| !candidate_set.contains(*entry))
.cloned()
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(invalid_value(
"network.denied_domains",
"missing managed denied_domains entries",
format!("{missing:?}"),
))
}
})?;
}
}
}
if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets {
let allowed_set: HashSet<String> = allow_unix_sockets
.iter()
.map(|s| s.to_ascii_lowercase())
.collect();
validate(config_allow_unix_sockets, move |candidate| {
let mut invalid = Vec::new();
for entry in candidate {
if !allowed_set.contains(&entry.to_ascii_lowercase()) {
invalid.push(entry.clone());
}
}
if invalid.is_empty() {
Ok(())
} else {
Err(invalid_value(
"network.allow_unix_sockets",
format!("{invalid:?}"),
"subset of managed allow_unix_sockets",
))
}
})?;
}
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],
) -> Result<(), NetworkProxyConstraintError> {
if let Some(pattern) = patterns
.iter()
.find(|pattern| is_global_wildcard_domain_pattern(pattern))
{
return Err(NetworkProxyConstraintError::InvalidValue {
field_name,
candidate: pattern.trim().to_string(),
allowed: "exact hosts or scoped wildcards like *.example.com or **.example.com"
.to_string(),
});
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum NetworkProxyConstraintError {
#[error("invalid value for {field_name}: {candidate} (allowed {allowed})")]
InvalidValue {
field_name: &'static str,
candidate: String,
allowed: String,
},
}
impl NetworkProxyConstraintError {
pub fn into_anyhow(self) -> anyhow::Error {
anyhow::anyhow!(self)
}
}
fn network_mode_rank(mode: NetworkMode) -> u8 {
match mode {
NetworkMode::Limited => 0,
NetworkMode::Full => 1,
}
}
#[cfg(test)]
mod tests {}