mirror of
https://github.com/openai/codex.git
synced 2026-04-28 02:11:08 +03:00
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:
@@ -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`)
|
||||
|
||||
@@ -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()
|
||||
}));
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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})")]
|
||||
|
||||
Reference in New Issue
Block a user