diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 6fb689490a0..0347e6ddf25 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -39,7 +39,8 @@ mitm = false # Hosts must match the allowlist (unless denied). # Use exact hosts or scoped wildcards like `*.openai.com` or `**.openai.com`. -# The global `*` wildcard is rejected. +# The global `*` wildcard is allowed in `allowed_domains` to delegate public-host filtering to +# `denied_domains`. # If `allowed_domains` is empty, the proxy blocks requests until an allowlist is configured. allowed_domains = ["*.openai.com", "localhost", "127.0.0.1", "::1"] denied_domains = ["evil.example"] @@ -189,7 +190,7 @@ This section documents the protections implemented by `codex-network-proxy`, and what it can reasonably guarantee. - Allowlist-first policy: if `allowed_domains` is empty, requests are blocked until an allowlist is configured. -- Domain patterns: exact hosts plus scoped wildcards (`*.example.com`, `**.example.com`) are supported; the global `*` wildcard is rejected. +- Domain patterns: exact hosts plus scoped wildcards (`*.example.com`, `**.example.com`) are supported. A global `*` wildcard is allowed in `allowed_domains` to permit all public hosts by default, while `denied_domains` remains field-specific and still rejects global `*`. - Deny wins: entries in `denied_domains` always override the allowlist. - Local/private network protection: when `allow_local_binding = false`, the proxy blocks loopback and common private/link-local ranges. Explicit allowlisting of local IP literals (or `localhost`) diff --git a/codex-rs/network-proxy/src/policy.rs b/codex-rs/network-proxy/src/policy.rs index 17927767f9f..c586ee9c11e 100644 --- a/codex-rs/network-proxy/src/policy.rs +++ b/codex-rs/network-proxy/src/policy.rs @@ -2,6 +2,7 @@ use crate::config::NetworkMode; use anyhow::Context; use anyhow::Result; +use anyhow::bail; use anyhow::ensure; use globset::GlobBuilder; use globset::GlobSet; @@ -151,19 +152,38 @@ pub(crate) fn is_global_wildcard_domain_pattern(pattern: &str) -> bool { .any(|candidate| candidate == "*") } -pub(crate) fn compile_globset(patterns: &[String]) -> Result { +#[derive(Clone, Copy, PartialEq, Eq)] +enum GlobalWildcard { + Allow, + Reject, +} + +pub(crate) fn compile_allowlist_globset(patterns: &[String]) -> Result { + compile_globset_with_policy(patterns, GlobalWildcard::Allow) +} + +pub(crate) fn compile_denylist_globset(patterns: &[String]) -> Result { + compile_globset_with_policy(patterns, GlobalWildcard::Reject) +} + +fn compile_globset_with_policy( + patterns: &[String], + global_wildcard: GlobalWildcard, +) -> Result { let mut builder = GlobSetBuilder::new(); let mut seen = HashSet::new(); for pattern in patterns { - ensure!( - !is_global_wildcard_domain_pattern(pattern), - "unsupported global wildcard domain pattern \"*\"; use exact hosts or scoped wildcards like *.example.com or **.example.com" - ); + if global_wildcard == GlobalWildcard::Reject && is_global_wildcard_domain_pattern(pattern) { + bail!( + "unsupported global wildcard domain pattern \"*\"; use exact hosts or scoped wildcards like *.example.com or **.example.com" + ); + } let pattern = normalize_pattern(pattern); // Supported domain patterns: // - "example.com": match the exact host // - "*.example.com": match any subdomain (not the apex) // - "**.example.com": match the apex and any subdomain + // - "*": match every host when explicitly enabled for allowlist compilation for candidate in expand_domain_pattern(&pattern) { if !seen.insert(candidate.clone()) { continue; @@ -333,7 +353,7 @@ mod tests { #[test] fn compile_globset_normalizes_trailing_dots() { - let set = compile_globset(&["Example.COM.".to_string()]).unwrap(); + let set = compile_denylist_globset(&["Example.COM.".to_string()]).unwrap(); assert_eq!(true, set.is_match("example.com")); assert_eq!(false, set.is_match("api.example.com")); @@ -341,7 +361,7 @@ mod tests { #[test] fn compile_globset_normalizes_wildcards() { - let set = compile_globset(&["*.Example.COM.".to_string()]).unwrap(); + let set = compile_denylist_globset(&["*.Example.COM.".to_string()]).unwrap(); assert_eq!(true, set.is_match("api.example.com")); assert_eq!(false, set.is_match("example.com")); @@ -349,7 +369,7 @@ mod tests { #[test] fn compile_globset_normalizes_apex_and_subdomains() { - let set = compile_globset(&["**.Example.COM.".to_string()]).unwrap(); + let set = compile_denylist_globset(&["**.Example.COM.".to_string()]).unwrap(); assert_eq!(true, set.is_match("example.com")); assert_eq!(true, set.is_match("api.example.com")); @@ -357,7 +377,7 @@ mod tests { #[test] fn compile_globset_normalizes_bracketed_ipv6_literals() { - let set = compile_globset(&["[::1]".to_string()]).unwrap(); + let set = compile_denylist_globset(&["[::1]".to_string()]).unwrap(); assert_eq!(true, set.is_match("::1")); } diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index b634a8630e3..9381fab7f42 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -819,7 +819,8 @@ mod tests { use crate::config::NetworkProxyConfig; use crate::config::NetworkProxySettings; - use crate::policy::compile_globset; + use crate::policy::compile_allowlist_globset; + use crate::policy::compile_denylist_globset; use crate::state::NetworkProxyConstraints; use crate::state::build_config_state; use crate::state::validate_policy_against_constraints; @@ -894,6 +895,29 @@ mod tests { ); } + #[tokio::test] + async fn add_denied_domain_forces_block_with_global_wildcard_allowlist() { + let state = network_proxy_state_for_policy(NetworkProxySettings { + allowed_domains: vec!["*".to_string()], + ..NetworkProxySettings::default() + }); + + assert_eq!( + state.host_blocked("evil.example", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + + state.add_denied_domain("evil.example").await.unwrap(); + + let (allowed, denied) = state.current_patterns().await.unwrap(); + assert_eq!(allowed, vec!["*".to_string()]); + assert_eq!(denied, vec!["evil.example".to_string()]); + assert_eq!( + state.host_blocked("evil.example", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::Denied) + ); + } + #[tokio::test] async fn add_allowed_domain_succeeds_when_managed_baseline_allows_expansion() { let config = NetworkProxyConfig { @@ -1089,6 +1113,28 @@ mod tests { ); } + #[tokio::test] + async fn host_blocked_global_wildcard_allowlist_allows_public_hosts_except_denylist() { + let state = network_proxy_state_for_policy(NetworkProxySettings { + allowed_domains: vec!["*".to_string()], + denied_domains: vec!["evil.example".to_string()], + ..NetworkProxySettings::default() + }); + + assert_eq!( + state.host_blocked("example.com", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + assert_eq!( + state.host_blocked("api.openai.com", 443).await.unwrap(), + HostBlockDecision::Allowed + ); + assert_eq!( + state.host_blocked("evil.example", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::Denied) + ); + } + #[tokio::test] async fn host_blocked_rejects_loopback_when_local_binding_disabled() { let state = network_proxy_state_for_policy(NetworkProxySettings { @@ -1484,7 +1530,7 @@ mod tests { #[test] fn compile_globset_is_case_insensitive() { let patterns = vec!["ExAmPle.CoM".to_string()]; - let set = compile_globset(&patterns).unwrap(); + let set = compile_denylist_globset(&patterns).unwrap(); assert!(set.is_match("example.com")); assert!(set.is_match("EXAMPLE.COM")); } @@ -1492,7 +1538,7 @@ mod tests { #[test] fn compile_globset_excludes_apex_for_subdomain_patterns() { let patterns = vec!["*.openai.com".to_string()]; - let set = compile_globset(&patterns).unwrap(); + let set = compile_denylist_globset(&patterns).unwrap(); assert!(set.is_match("api.openai.com")); assert!(!set.is_match("openai.com")); assert!(!set.is_match("evilopenai.com")); @@ -1501,7 +1547,7 @@ mod tests { #[test] fn compile_globset_includes_apex_for_double_wildcard_patterns() { let patterns = vec!["**.openai.com".to_string()]; - let set = compile_globset(&patterns).unwrap(); + let set = compile_denylist_globset(&patterns).unwrap(); assert!(set.is_match("openai.com")); assert!(set.is_match("api.openai.com")); assert!(!set.is_match("evilopenai.com")); @@ -1510,25 +1556,34 @@ mod tests { #[test] fn compile_globset_rejects_global_wildcard() { let patterns = vec!["*".to_string()]; - assert!(compile_globset(&patterns).is_err()); + assert!(compile_denylist_globset(&patterns).is_err()); + } + + #[test] + fn compile_globset_allows_global_wildcard_when_enabled() { + let patterns = vec!["*".to_string()]; + let set = compile_allowlist_globset(&patterns).unwrap(); + assert!(set.is_match("example.com")); + assert!(set.is_match("api.openai.com")); + assert!(set.is_match("localhost")); } #[test] fn compile_globset_rejects_bracketed_global_wildcard() { let patterns = vec!["[*]".to_string()]; - assert!(compile_globset(&patterns).is_err()); + assert!(compile_denylist_globset(&patterns).is_err()); } #[test] fn compile_globset_rejects_double_wildcard_bracketed_global_wildcard() { let patterns = vec!["**.[*]".to_string()]; - assert!(compile_globset(&patterns).is_err()); + assert!(compile_denylist_globset(&patterns).is_err()); } #[test] fn compile_globset_dedupes_patterns_without_changing_behavior() { let patterns = vec!["example.com".to_string(), "example.com".to_string()]; - let set = compile_globset(&patterns).unwrap(); + let set = compile_denylist_globset(&patterns).unwrap(); assert!(set.is_match("example.com")); assert!(set.is_match("EXAMPLE.COM")); assert!(!set.is_match("not-example.com")); @@ -1537,11 +1592,11 @@ mod tests { #[test] fn compile_globset_rejects_invalid_patterns() { let patterns = vec!["[".to_string()]; - assert!(compile_globset(&patterns).is_err()); + assert!(compile_denylist_globset(&patterns).is_err()); } #[test] - fn build_config_state_rejects_global_wildcard_allowed_domains() { + fn build_config_state_allows_global_wildcard_allowed_domains() { let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, @@ -1550,11 +1605,11 @@ mod tests { }, }; - assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok()); } #[test] - fn build_config_state_rejects_bracketed_global_wildcard_allowed_domains() { + fn build_config_state_allows_bracketed_global_wildcard_allowed_domains() { let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, @@ -1563,7 +1618,7 @@ mod tests { }, }; - assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok()); } #[test] diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 612e6c5b5cf..435e5beab45 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -2,7 +2,8 @@ use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; use crate::mitm::MitmState; use crate::policy::DomainPattern; -use crate::policy::compile_globset; +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; @@ -59,12 +60,10 @@ pub fn build_config_state( constraints: NetworkProxyConstraints, ) -> anyhow::Result { crate::config::validate_unix_socket_allowlist_paths(&config)?; - validate_domain_patterns("network.allowed_domains", &config.network.allowed_domains) + validate_denylist_domain_patterns("network.denied_domains", &config.network.denied_domains) .map_err(NetworkProxyConstraintError::into_anyhow)?; - validate_domain_patterns("network.denied_domains", &config.network.denied_domains) - .map_err(NetworkProxyConstraintError::into_anyhow)?; - let deny_set = compile_globset(&config.network.denied_domains)?; - let allow_set = compile_globset(&config.network.allowed_domains)?; + let deny_set = compile_denylist_globset(&config.network.denied_domains)?; + let allow_set = compile_allowlist_globset(&config.network.allowed_domains)?; let mitm = if config.network.mitm { Some(Arc::new(MitmState::new( config.network.allow_upstream_proxy, @@ -107,8 +106,7 @@ pub fn validate_policy_against_constraints( } let enabled = config.network.enabled; - validate_domain_patterns("network.allowed_domains", &config.network.allowed_domains)?; - validate_domain_patterns("network.denied_domains", &config.network.denied_domains)?; + validate_denylist_domain_patterns("network.denied_domains", &config.network.denied_domains)?; if let Some(max_enabled) = constraints.enabled { validate(enabled, move |candidate| { if *candidate && !max_enabled { @@ -208,7 +206,6 @@ pub fn validate_policy_against_constraints( } if let Some(allowed_domains) = &constraints.allowed_domains { - validate_domain_patterns("network.allowed_domains", allowed_domains)?; match constraints.allowlist_expansion_enabled { Some(true) => { let required_set: HashSet = allowed_domains @@ -288,7 +285,7 @@ pub fn validate_policy_against_constraints( } if let Some(denied_domains) = &constraints.denied_domains { - validate_domain_patterns("network.denied_domains", denied_domains)?; + validate_denylist_domain_patterns("network.denied_domains", denied_domains)?; let required_set: HashSet = denied_domains .iter() .map(|s| s.to_ascii_lowercase()) @@ -364,7 +361,7 @@ pub fn validate_policy_against_constraints( Ok(()) } -fn validate_domain_patterns( +fn validate_denylist_domain_patterns( field_name: &'static str, patterns: &[String], ) -> Result<(), NetworkProxyConstraintError> {