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
This commit is contained in:
viyatb-oai
2026-03-06 13:06:24 -08:00
committed by GitHub
parent 7a5aff4972
commit 9a4787c240
5 changed files with 168 additions and 45 deletions

View File

@@ -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`)

View File

@@ -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()
}));

View File

@@ -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<GlobSet> {
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<GlobSet> {
#[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<String> {
match DomainPattern::parse(pattern) {
DomainPattern::Any => vec![pattern.to_string()],
DomainPattern::Exact(domain) => vec![domain],
DomainPattern::SubdomainsOnly(domain) => {
vec![format!("?*.{domain}")]

View File

@@ -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() {

View File

@@ -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<ConfigState> {
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<DomainPattern> = 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<String> = 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})")]