Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions codex-rs/network-proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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`)
Expand Down
38 changes: 29 additions & 9 deletions codex-rs/network-proxy/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -333,31 +353,31 @@ 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"));
}

#[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"));
}

#[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"));
}

#[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"));
}
Expand Down
81 changes: 68 additions & 13 deletions codex-rs/network-proxy/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1089,6 +1113,28 @@ mod tests {
);
}

#[tokio::test]
async fn host_blocked_global_wildcard_allowlist_allows_public_hosts_except_denylist() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bolinfest this test confirms this usecase functions as expected!

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 {
Expand Down Expand Up @@ -1484,15 +1530,15 @@ 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"));
}

#[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"));
Expand All @@ -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"));
Expand All @@ -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"));
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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]
Expand Down
19 changes: 8 additions & 11 deletions codex-rs/network-proxy/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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> {
Expand Down
Loading