diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 402585108d..75cd3966f2 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -214,6 +214,9 @@ what it can reasonably guarantee. - Allowlist-first policy: if `domains` has no `allow` entries, requests are blocked until an allowlist is configured. - Domain patterns: exact hosts are supported, `*.example.com` matches subdomains only, and `**.example.com` matches the apex plus subdomains; the global `*` wildcard is only accepted when explicitly enabled for allowlist compilation and is otherwise rejected. +- Within a domain pattern, `?` matches exactly one character, including a dot. For example, + `api?.example.com` matches `api1.example.com`, but not `api.example.com` or `api12.example.com`. + It can be combined with `*`, `*.`, and `**.` in both allow and deny entries. - Deny wins: `domains` entries marked `deny` 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/policy.rs b/codex-rs/network-proxy/src/policy.rs index ede4c319e4..c8ed7c1c29 100644 --- a/codex-rs/network-proxy/src/policy.rs +++ b/codex-rs/network-proxy/src/policy.rs @@ -200,6 +200,9 @@ pub(crate) fn compile_denylist_globset(patterns: &[String]) -> Result { compile_globset_with_policy(patterns, GlobalWildcard::Reject) } +// Browser network-policy matchers implement a subset of this hostname grammar. +// Keep changes to shared grammar and normalization in sync with their contract +// cases and the Rust tests below, including compile_globset_supports_question_mark_wildcards. fn compile_globset_with_policy( patterns: &[String], global_wildcard: GlobalWildcard, @@ -217,6 +220,7 @@ fn compile_globset_with_policy( // - "example.com": match the exact host // - "*.example.com": match any subdomain (not the apex) // - "**.example.com": match the apex and any subdomain + // - "api?.example.com": match exactly one character after "api" // - "*": match every host when explicitly enabled for allowlist compilation for candidate in expand_domain_pattern(&pattern) { if !seen.insert(candidate.clone()) { @@ -411,6 +415,46 @@ mod tests { assert_eq!(false, set.is_match("foo.region1.v2.argotunnel.com")); } + // Keep this table one-for-one with the browser network-policy matchers' + // question-mark contract cases so grammar changes are checked on both sides. + #[test] + fn compile_globset_supports_question_mark_wildcards() -> Result<()> { + for (pattern, host, expected) in [ + ("api?.example.com", "api1.example.com", true), + ("api?.example.com", "api.example.com", false), + ("api?.example.com", "api12.example.com", false), + ("api??.example.com", "api12.example.com", true), + ("api??.example.com", "api1.example.com", false), + ("api*?.example.com", "api.example.com", false), + ("api*?.example.com", "api1.example.com", true), + ("api*?.example.com", "api123.example.com", true), + ("api?example.com", "api.example.com", true), + ("*.api?.example.com", "api1.example.com", false), + ("*.api?.example.com", "www.api1.example.com", true), + ("*.api?.example.com", "nested.www.api1.example.com", true), + ("*.api?.example.com", "www.api12.example.com", false), + ("**.api?.example.com", "api1.example.com", true), + ("**.api?.example.com", "www.api1.example.com", true), + ("**.api?.example.com", "nested.www.api1.example.com", true), + ("**.api?.example.com", "api12.example.com", false), + ("**.api?.example.com", "www.api12.example.com", false), + (" API?.EXAMPLE.COM. ", "API1.EXAMPLE.COM.", true), + ] { + let patterns = [pattern.to_string()]; + for set in [ + compile_allowlist_globset(&patterns)?, + compile_denylist_globset(&patterns)?, + ] { + assert_eq!( + set.is_match(normalize_host(host)), + expected, + "pattern {pattern}, host {host}" + ); + } + } + Ok(()) + } + #[test] fn compile_globset_normalizes_apex_and_subdomains() { let set = compile_denylist_globset(&["**.Example.COM.".to_string()]).unwrap();