mirror of
https://github.com/openai/codex.git
synced 2026-05-01 11:52:10 +03:00
Allow global network allowlist wildcard (#15549)
## Problem Today `codex-network-proxy` rejects a global `*` in `network.allowed_domains`, so there is no static way to configure a denylist-only posture for public hosts. Users have to enumerate broad allowlist patterns instead. ## Approach - Make global wildcard acceptance field-specific: `allowed_domains` can use `*`, while `denied_domains` still rejects a global wildcard. - Keep the existing evaluation order, so explicit denies still win first and local/private protections still apply unless separately enabled. - Add coverage for the denylist-only behavior and update the README to document it. ## Validation - `just fmt` - `cargo test -p codex-network-proxy` (full run had one unrelated flaky telemetry test: `network_policy::tests::emit_block_decision_audit_event_emits_non_domain_event`; reran in isolation and it passed) - `cargo test -p codex-network-proxy network_policy::tests::emit_block_decision_audit_event_emits_non_domain_event -- --exact --nocapture` - `just fix -p codex-network-proxy` - `just argument-comment-lint`
This commit is contained in:
@@ -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<GlobSet> {
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum GlobalWildcard {
|
||||
Allow,
|
||||
Reject,
|
||||
}
|
||||
|
||||
pub(crate) fn compile_allowlist_globset(patterns: &[String]) -> Result<GlobSet> {
|
||||
compile_globset_with_policy(patterns, GlobalWildcard::Allow)
|
||||
}
|
||||
|
||||
pub(crate) fn compile_denylist_globset(patterns: &[String]) -> Result<GlobSet> {
|
||||
compile_globset_with_policy(patterns, GlobalWildcard::Reject)
|
||||
}
|
||||
|
||||
fn compile_globset_with_policy(
|
||||
patterns: &[String],
|
||||
global_wildcard: GlobalWildcard,
|
||||
) -> 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"
|
||||
);
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<ConfigState> {
|
||||
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<String> = 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<String> = 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> {
|
||||
|
||||
Reference in New Issue
Block a user