From 9a4787c24076ac4ca08a62507262818aa63d35f5 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 6 Mar 2026 13:06:24 -0800 Subject: [PATCH] fix: reject global wildcard network proxy domains (#13789) ## Summary - reject the global `*` domain pattern in proxy allow/deny lists and managed constraints introduced for testing earlier - keep exact hosts plus scoped wildcards like `*.example.com` and `**.example.com` - update docs and regression tests for the new invalid-config behavior --- codex-rs/network-proxy/README.md | 3 + codex-rs/network-proxy/src/mitm_tests.rs | 2 +- codex-rs/network-proxy/src/policy.rs | 24 ++-- codex-rs/network-proxy/src/runtime.rs | 157 ++++++++++++++++++----- codex-rs/network-proxy/src/state.rs | 27 ++++ 5 files changed, 168 insertions(+), 45 deletions(-) diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 2bdbe40ea..15f870944 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -36,6 +36,8 @@ mitm = false # CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key). # Hosts must match the allowlist (unless denied). +# Use exact hosts or scoped wildcards like `*.openai.com` or `**.openai.com`. +# The global `*` wildcard is rejected. # 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"] @@ -185,6 +187,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. - 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/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index 03e429bc2..d2f7984a7 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -83,7 +83,7 @@ async fn mitm_policy_rejects_host_mismatch() { #[tokio::test] async fn mitm_policy_rechecks_local_private_target_after_connect() { let app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["*".to_string()], + allowed_domains: vec!["example.com".to_string()], allow_local_binding: false, ..NetworkProxySettings::default() })); diff --git a/codex-rs/network-proxy/src/policy.rs b/codex-rs/network-proxy/src/policy.rs index d663aea06..fb0305c82 100644 --- a/codex-rs/network-proxy/src/policy.rs +++ b/codex-rs/network-proxy/src/policy.rs @@ -144,16 +144,26 @@ fn normalize_pattern(pattern: &str) -> String { } } +pub(crate) fn is_global_wildcard_domain_pattern(pattern: &str) -> bool { + let normalized = normalize_pattern(pattern); + expand_domain_pattern(&normalized) + .iter() + .any(|candidate| candidate == "*") +} + pub(crate) fn compile_globset(patterns: &[String]) -> 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" + ); 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 any host for candidate in expand_domain_pattern(&pattern) { if !seen.insert(candidate.clone()) { continue; @@ -170,7 +180,6 @@ pub(crate) fn compile_globset(patterns: &[String]) -> Result { #[derive(Debug, Clone)] pub(crate) enum DomainPattern { - Any, ApexAndSubdomains(String), SubdomainsOnly(String), Exact(String), @@ -186,9 +195,7 @@ impl DomainPattern { if input.is_empty() { return Self::Exact(String::new()); } - if input == "*" { - Self::Any - } else if let Some(domain) = input.strip_prefix("**.") { + if let Some(domain) = input.strip_prefix("**.") { Self::parse_domain(domain, Self::ApexAndSubdomains) } else if let Some(domain) = input.strip_prefix("*.") { Self::parse_domain(domain, Self::SubdomainsOnly) @@ -203,9 +210,6 @@ impl DomainPattern { if input.is_empty() { return Self::Exact(String::new()); } - if input == "*" { - return Self::Any; - } if let Some(domain) = input.strip_prefix("**.") { return Self::ApexAndSubdomains(parse_domain_for_constraints(domain)); } @@ -225,13 +229,11 @@ impl DomainPattern { pub(crate) fn allows(&self, candidate: &DomainPattern) -> bool { match self { - DomainPattern::Any => true, DomainPattern::Exact(domain) => match candidate { DomainPattern::Exact(candidate) => domain_eq(candidate, domain), _ => false, }, DomainPattern::SubdomainsOnly(domain) => match candidate { - DomainPattern::Any => false, DomainPattern::Exact(candidate) => is_strict_subdomain(candidate, domain), DomainPattern::SubdomainsOnly(candidate) => { is_subdomain_or_equal(candidate, domain) @@ -241,7 +243,6 @@ impl DomainPattern { } }, DomainPattern::ApexAndSubdomains(domain) => match candidate { - DomainPattern::Any => false, DomainPattern::Exact(candidate) => is_subdomain_or_equal(candidate, domain), DomainPattern::SubdomainsOnly(candidate) => { is_subdomain_or_equal(candidate, domain) @@ -275,7 +276,6 @@ fn parse_domain_for_constraints(domain: &str) -> String { fn expand_domain_pattern(pattern: &str) -> Vec { match DomainPattern::parse(pattern) { - DomainPattern::Any => vec![pattern.to_string()], DomainPattern::Exact(domain) => vec![domain], DomainPattern::SubdomainsOnly(domain) => { vec![format!("?*.{domain}")] diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 7e963f712..b82549580 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -821,6 +821,7 @@ mod tests { use crate::config::NetworkProxySettings; use crate::policy::compile_globset; use crate::state::NetworkProxyConstraints; + use crate::state::build_config_state; use crate::state::validate_policy_against_constraints; use pretty_assertions::assert_eq; @@ -1014,34 +1015,6 @@ mod tests { ); } - #[tokio::test] - async fn host_blocked_rejects_loopback_when_allowlist_is_wildcard() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["*".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); - - assert_eq!( - state.host_blocked("127.0.0.1", 80).await.unwrap(), - HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) - ); - } - - #[tokio::test] - async fn host_blocked_rejects_private_ip_literal_when_allowlist_is_wildcard() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["*".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); - - assert_eq!( - state.host_blocked("10.0.0.1", 80).await.unwrap(), - HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) - ); - } - #[tokio::test] async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() { let state = network_proxy_state_for_policy(NetworkProxySettings { @@ -1198,6 +1171,62 @@ mod tests { assert!(validate_policy_against_constraints(&config, &constraints).is_err()); } + #[test] + fn validate_policy_against_constraints_rejects_global_wildcard_in_managed_allowlist() { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["*".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["api.example.com".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_rejects_bracketed_global_wildcard_in_managed_allowlist() + { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["[*]".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["api.example.com".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_rejects_double_wildcard_bracketed_global_wildcard_in_managed_allowlist() + { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["**.[*]".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["api.example.com".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + #[test] fn validate_policy_against_constraints_requires_managed_denied_domains_entries() { let constraints = NetworkProxyConstraints { @@ -1349,11 +1378,21 @@ mod tests { } #[test] - fn compile_globset_matches_all_with_star() { + fn compile_globset_rejects_global_wildcard() { let patterns = vec!["*".to_string()]; - let set = compile_globset(&patterns).unwrap(); - assert!(set.is_match("openai.com")); - assert!(set.is_match("api.openai.com")); + assert!(compile_globset(&patterns).is_err()); + } + + #[test] + fn compile_globset_rejects_bracketed_global_wildcard() { + let patterns = vec!["[*]".to_string()]; + assert!(compile_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()); } #[test] @@ -1371,6 +1410,60 @@ mod tests { assert!(compile_globset(&patterns).is_err()); } + #[test] + fn build_config_state_rejects_global_wildcard_allowed_domains() { + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["*".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + } + + #[test] + fn build_config_state_rejects_bracketed_global_wildcard_allowed_domains() { + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["[*]".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + } + + #[test] + fn build_config_state_rejects_global_wildcard_denied_domains() { + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["example.com".to_string()], + denied_domains: vec!["*".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + } + + #[test] + fn build_config_state_rejects_bracketed_global_wildcard_denied_domains() { + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["example.com".to_string()], + denied_domains: vec!["[*]".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + } + #[cfg(target_os = "macos")] #[tokio::test] async fn unix_socket_allowlist_is_respected_on_macos() { diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index f685b7efb..fb8898f61 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -3,6 +3,7 @@ use crate::config::NetworkProxyConfig; use crate::mitm::MitmState; use crate::policy::DomainPattern; use crate::policy::compile_globset; +use crate::policy::is_global_wildcard_domain_pattern; use crate::runtime::ConfigState; use serde::Deserialize; use std::collections::HashSet; @@ -56,6 +57,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) + .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 mitm = if config.network.mitm { @@ -100,6 +105,8 @@ 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)?; if let Some(max_enabled) = constraints.enabled { validate(enabled, move |candidate| { if *candidate && !max_enabled { @@ -199,6 +206,7 @@ pub fn validate_policy_against_constraints( } if let Some(allowed_domains) = &constraints.allowed_domains { + validate_domain_patterns("network.allowed_domains", allowed_domains)?; let managed_patterns: Vec = allowed_domains .iter() .map(|entry| DomainPattern::parse_for_constraints(entry)) @@ -227,6 +235,7 @@ pub fn validate_policy_against_constraints( } if let Some(denied_domains) = &constraints.denied_domains { + validate_domain_patterns("network.denied_domains", denied_domains)?; let required_set: HashSet = denied_domains .iter() .map(|s| s.to_ascii_lowercase()) @@ -281,6 +290,24 @@ pub fn validate_policy_against_constraints( Ok(()) } +fn validate_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})")]