mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Use a dedicated matcher for network domain policies (#51897)
## Why Network domain rules need explicit wildcard semantics independent of filesystem glob syntax, including matching Unicode hosts with `?`. ## What changed Replace `globset` in network allowlist and denylist matching with a domain matcher. Make `?` consume one Unicode scalar value, retain ASCII case-insensitive matching and existing host normalization, and preserve the distinction between `*.example.com` and `**.example.com`. Reject empty patterns, repeated wildcards outside the supported `**.` prefix, character classes, brace expansions, escapes, slashes, and embedded whitespace when loading policies. Continue allowing global wildcards only in allowlists. ## Testing Add coverage for Unicode wildcard semantics, unsupported syntax, IPv6 normalization, invalid entries in either policy list, and adversarial matching inputs. Compare the matcher against an independent dynamic-programming implementation over exhaustive short inputs. GitOrigin-RevId: 64f65fcda1a50998d70e046445d22cd2d2dcf27a
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
//! Network-domain wildcards over Unicode scalar values, independent of filesystem semantics.
|
||||
//! Construction validates and normalizes patterns and expands domain prefixes.
|
||||
//! Callers normalize candidate hosts before matching.
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use anyhow::ensure;
|
||||
use std::collections::HashSet;
|
||||
use std::net::IpAddr;
|
||||
use std::net::Ipv6Addr;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum GlobalWildcard {
|
||||
Allow,
|
||||
Reject,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DomainPatternSet {
|
||||
patterns: Arc<[Vec<char>]>,
|
||||
}
|
||||
|
||||
impl DomainPatternSet {
|
||||
pub(crate) fn new(patterns: &[String], global_wildcard: GlobalWildcard) -> Result<Self> {
|
||||
let mut compiled = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for pattern in patterns {
|
||||
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 raw = pattern.trim();
|
||||
ensure!(
|
||||
!raw.contains(['{', '}', '\\', '/']) && !raw.chars().any(char::is_whitespace),
|
||||
"unsupported domain pattern syntax: {pattern}"
|
||||
);
|
||||
let host = raw
|
||||
.strip_prefix("**.")
|
||||
.or_else(|| raw.strip_prefix("*."))
|
||||
.unwrap_or(raw);
|
||||
ensure!(
|
||||
!host.contains("**"),
|
||||
"unsupported repeated wildcard: {pattern}"
|
||||
);
|
||||
if host.contains(['[', ']']) {
|
||||
let literal = host.strip_prefix('[').and_then(|s| s.split_once(']'));
|
||||
ensure!(
|
||||
literal.is_some_and(|(ip, suffix)| {
|
||||
let normalized = normalize_dns_host_or_ip_literal(ip);
|
||||
let unscoped = normalized.split('%').next().unwrap_or_default();
|
||||
!ip.contains(['[', ']'])
|
||||
&& (normalized == "*" || unscoped.parse::<Ipv6Addr>().is_ok())
|
||||
&& !suffix.contains(['[', ']'])
|
||||
&& (suffix.chars().all(|c| c == '.') || suffix.starts_with(':'))
|
||||
}),
|
||||
"brackets are only supported for IPv6 literals or [*]: {pattern}"
|
||||
);
|
||||
}
|
||||
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
|
||||
// - "api?.example.com": match exactly one Unicode scalar value after "api"
|
||||
// - "*": match every host when explicitly enabled for allowlist compilation
|
||||
for candidate in expand_domain_pattern(&pattern) {
|
||||
if !seen.insert(candidate.clone()) {
|
||||
continue;
|
||||
}
|
||||
ensure!(!candidate.is_empty(), "domain pattern is empty");
|
||||
compiled.push(candidate.chars().collect());
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
patterns: compiled.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_match(&self, host: impl AsRef<str>) -> bool {
|
||||
let host: Vec<char> = host.as_ref().chars().collect();
|
||||
self.patterns.iter().any(|pattern| matches(pattern, &host))
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize host fragments for policy matching (trim whitespace, strip ports/brackets, lowercase).
|
||||
pub fn normalize_host(host: &str) -> String {
|
||||
let host = host.trim();
|
||||
if host.starts_with('[')
|
||||
&& let Some(end) = host.find(']')
|
||||
{
|
||||
return normalize_dns_host_or_ip_literal(&host[1..end]);
|
||||
}
|
||||
|
||||
// The proxy stack should typically hand us a host without a port, but be
|
||||
// defensive and strip `:port` when there is exactly one `:`.
|
||||
if host.bytes().filter(|b| *b == b':').count() == 1 {
|
||||
let host = host.split(':').next().unwrap_or_default();
|
||||
return normalize_dns_host_or_ip_literal(host);
|
||||
}
|
||||
|
||||
// Avoid mangling unbracketed IPv6 literals, but strip trailing dots so fully qualified domain
|
||||
// names are treated the same as their dotless variants.
|
||||
normalize_dns_host_or_ip_literal(host)
|
||||
}
|
||||
|
||||
fn normalize_dns_host_or_ip_literal(host: &str) -> String {
|
||||
let host = host.to_ascii_lowercase();
|
||||
let host = host.trim_end_matches('.');
|
||||
if let Some(ip) = normalize_ip_literal(host) {
|
||||
return ip;
|
||||
}
|
||||
host.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn unscoped_ip_literal(host: &str) -> Option<&str> {
|
||||
let (ip, _) = host.split_once('%')?;
|
||||
ip.parse::<IpAddr>().ok()?;
|
||||
Some(ip)
|
||||
}
|
||||
|
||||
fn normalize_ip_literal(host: &str) -> Option<String> {
|
||||
if host.parse::<IpAddr>().is_ok() {
|
||||
return Some(host.to_string());
|
||||
}
|
||||
for delimiter in ["%25", "%"] {
|
||||
if let Some((ip, scope)) = host.split_once(delimiter)
|
||||
&& ip.parse::<IpAddr>().is_ok()
|
||||
{
|
||||
return Some(format!("{ip}%{scope}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_pattern(pattern: &str) -> String {
|
||||
let pattern = pattern.trim();
|
||||
if pattern == "*" {
|
||||
return "*".to_string();
|
||||
}
|
||||
|
||||
let (prefix, remainder) = if let Some(domain) = pattern.strip_prefix("**.") {
|
||||
("**.", domain)
|
||||
} else if let Some(domain) = pattern.strip_prefix("*.") {
|
||||
("*.", domain)
|
||||
} else {
|
||||
("", pattern)
|
||||
};
|
||||
|
||||
let remainder = normalize_host(remainder);
|
||||
if prefix.is_empty() {
|
||||
remainder
|
||||
} else {
|
||||
format!("{prefix}{remainder}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_global_wildcard_domain_pattern(pattern: &str) -> bool {
|
||||
let normalized = normalize_pattern(pattern);
|
||||
expand_domain_pattern(&normalized)
|
||||
.iter()
|
||||
.any(|candidate| candidate == "*")
|
||||
}
|
||||
|
||||
fn expand_domain_pattern(pattern: &str) -> Vec<String> {
|
||||
let pattern = pattern.trim();
|
||||
let Some(domain) = pattern
|
||||
.strip_prefix("**.")
|
||||
.or_else(|| pattern.strip_prefix("*."))
|
||||
else {
|
||||
return vec![pattern.to_string()];
|
||||
};
|
||||
let domain = domain.trim();
|
||||
if domain.is_empty() {
|
||||
return vec![String::new()];
|
||||
}
|
||||
if pattern.starts_with("**.") {
|
||||
vec![domain.to_string(), format!("?*.{domain}")]
|
||||
} else {
|
||||
vec![format!("?*.{domain}")]
|
||||
}
|
||||
}
|
||||
|
||||
// Only the latest star needs retrying: it can absorb any extension that an earlier star could.
|
||||
// Its retry position advances monotonically, bounding work by O(pattern length * host length).
|
||||
fn matches(pattern: &[char], host: &[char]) -> bool {
|
||||
let (mut p, mut h) = (0, 0);
|
||||
let mut star = None;
|
||||
while h < host.len() {
|
||||
match pattern.get(p) {
|
||||
Some('*') => {
|
||||
star = Some((p + 1, h));
|
||||
p += 1;
|
||||
}
|
||||
Some(c) if *c == '?' || c.eq_ignore_ascii_case(&host[h]) => {
|
||||
p += 1;
|
||||
h += 1;
|
||||
}
|
||||
_ => match &mut star {
|
||||
Some((resume, consumed)) => {
|
||||
*consumed += 1;
|
||||
p = *resume;
|
||||
h = *consumed;
|
||||
}
|
||||
None => return false,
|
||||
},
|
||||
}
|
||||
}
|
||||
pattern[p..].iter().all(|c| *c == '*')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "domain_matcher_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,225 @@
|
||||
use super::*;
|
||||
use crate::policy::compile_allowlist;
|
||||
use crate::policy::compile_denylist;
|
||||
use crate::policy::normalize_host;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn unicode_wildcards_count_scalars_not_bytes_or_graphemes() {
|
||||
for (pattern, host, expected) in [
|
||||
("api?.com", "apié.com", true),
|
||||
("api?.com", "api😀.com", true),
|
||||
("api??.com", "apié.com", false),
|
||||
("api?.com", "apie\u{301}.com", false),
|
||||
("api??.com", "apie\u{301}.com", true),
|
||||
("*.例?.com", "a.例子.com", true),
|
||||
("*.例?.com", "例子.com", false),
|
||||
("**.例?.com", "例子.com", true),
|
||||
("É.com", "é.com", false),
|
||||
("É.COM", "É.com", true),
|
||||
] {
|
||||
for compile in [compile_allowlist, compile_denylist] {
|
||||
assert_eq!(
|
||||
compile(&[pattern.to_string()])
|
||||
.unwrap()
|
||||
.is_match(normalize_host(host)),
|
||||
expected,
|
||||
"{pattern} / {host}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_rejects_unsupported_syntax_instead_of_dropping_rules() {
|
||||
for pattern in [
|
||||
"",
|
||||
" ",
|
||||
"*.",
|
||||
"**.",
|
||||
"**",
|
||||
"a**b.com",
|
||||
"[ab].com",
|
||||
"a[bc].com",
|
||||
"{a,b}.com",
|
||||
r"a\*.com",
|
||||
"a/b.com",
|
||||
"a b.com",
|
||||
"[::1]suffix",
|
||||
"[fe80::1%abc[def]",
|
||||
"[::1]:443[ab]",
|
||||
"example.com:{a,b}",
|
||||
] {
|
||||
for compile in [compile_allowlist, compile_denylist] {
|
||||
assert!(
|
||||
compile(&["valid.com".to_string(), pattern.to_string()]).is_err(),
|
||||
"{pattern}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for pattern in [
|
||||
"[::1]",
|
||||
"[::1]:443",
|
||||
"[fe80::1%25en0]:443",
|
||||
"region*.com",
|
||||
"**.api?.com",
|
||||
] {
|
||||
assert!(
|
||||
compile_allowlist(&[pattern.to_string()]).is_ok(),
|
||||
"{pattern}"
|
||||
);
|
||||
}
|
||||
for pattern in ["*", "**.*", "[*]", "**.[*]"] {
|
||||
assert!(
|
||||
compile_denylist(&[pattern.to_string()]).is_err(),
|
||||
"{pattern}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracketed_patterns_preserve_port_and_trailing_dot_normalization() {
|
||||
for (bracketed, plain) in [
|
||||
("[*]", "*"),
|
||||
("[*]:443", "*"),
|
||||
("[*.]", "*"),
|
||||
("[*].", "*"),
|
||||
("[::1]:443", "::1"),
|
||||
("[::1.]", "::1"),
|
||||
("[::1].", "::1"),
|
||||
("[fe80::1%25en0]:443", "fe80::1%en0"),
|
||||
] {
|
||||
for prefix in ["", "*.", "**."] {
|
||||
for compile in [compile_allowlist, compile_denylist] {
|
||||
let compiled_bracketed = compile(&[format!(" {prefix}{bracketed} ")]);
|
||||
let compiled_plain = compile(&[format!("{prefix}{plain}")]);
|
||||
match (compiled_bracketed, compiled_plain) {
|
||||
(Ok(bracketed), Ok(plain)) => assert_eq!(bracketed.patterns, plain.patterns),
|
||||
(Err(_), Err(_)) => {}
|
||||
_ => panic!("different compilation outcomes for {prefix}{bracketed:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_agrees_with_independent_dynamic_program_for_short_inputs() {
|
||||
fn words(alphabet: &[char], max_len: usize) -> Vec<Vec<char>> {
|
||||
let mut all = vec![vec![]];
|
||||
let mut level = vec![vec![]];
|
||||
for _ in 0..max_len {
|
||||
level = level
|
||||
.iter()
|
||||
.flat_map(|word| {
|
||||
alphabet.iter().map(move |c| {
|
||||
let mut next = word.clone();
|
||||
next.push(*c);
|
||||
next
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
all.extend(level.iter().cloned());
|
||||
}
|
||||
all
|
||||
}
|
||||
fn reference(pattern: &[char], host: &[char]) -> bool {
|
||||
let mut rows = vec![vec![false; host.len() + 1]; pattern.len() + 1];
|
||||
rows[0][0] = true;
|
||||
for (p, c) in pattern.iter().enumerate() {
|
||||
rows[p + 1][0] = *c == '*' && rows[p][0];
|
||||
for (h, candidate) in host.iter().enumerate() {
|
||||
rows[p + 1][h + 1] = if *c == '*' {
|
||||
rows[p][h + 1] || rows[p + 1][h]
|
||||
} else {
|
||||
rows[p][h] && (*c == '?' || c.eq_ignore_ascii_case(candidate))
|
||||
};
|
||||
}
|
||||
}
|
||||
rows[pattern.len()][host.len()]
|
||||
}
|
||||
for pattern in words(&['a', 'é', '?', '*'], /*max_len*/ 4) {
|
||||
for host in words(&['a', 'é'], /*max_len*/ 5) {
|
||||
assert_eq!(
|
||||
matches(&pattern, &host),
|
||||
reference(&pattern, &host),
|
||||
"{pattern:?} / {host:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adversarial_retries_and_empty_boundaries() {
|
||||
let pattern: Vec<char> = format!("*{}b", "a".repeat(1024)).chars().collect();
|
||||
let host = vec!['a'; 4096];
|
||||
assert!(!matches(&pattern, &host));
|
||||
let pattern: Vec<char> = format!("{}b", "*a".repeat(1024)).chars().collect();
|
||||
assert!(!matches(&pattern, &host));
|
||||
assert_eq!(
|
||||
(
|
||||
matches(&[], &[]),
|
||||
matches(&['*'], &[]),
|
||||
matches(&['?'], &[])
|
||||
),
|
||||
(true, true, false)
|
||||
);
|
||||
assert!(!compile_allowlist(&[]).unwrap().is_match("host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_loading_rejects_an_invalid_entry_in_either_list() {
|
||||
for denied in [false, true] {
|
||||
let mut config = crate::config::NetworkProxyConfig::default();
|
||||
config.set_allowed_domains(vec!["example.com".to_string()]);
|
||||
let invalid = vec!["valid.com".to_string(), "api[12].com".to_string()];
|
||||
if denied {
|
||||
config.set_denied_domains(invalid);
|
||||
} else {
|
||||
config.set_allowed_domains(invalid);
|
||||
}
|
||||
assert!(
|
||||
crate::state::build_config_state(config, Default::default(), crate::Platform::native())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_lowercases_and_trims() {
|
||||
assert_eq!(normalize_host(" ExAmPlE.CoM "), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_strips_port_for_host_port() {
|
||||
assert_eq!(normalize_host("example.com:1234"), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_preserves_unbracketed_ipv6() {
|
||||
assert_eq!(normalize_host("2001:db8::1"), "2001:db8::1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_strips_trailing_dot() {
|
||||
assert_eq!(normalize_host("example.com."), "example.com");
|
||||
assert_eq!(normalize_host("ExAmPlE.CoM."), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_strips_trailing_dot_with_port() {
|
||||
assert_eq!(normalize_host("example.com.:443"), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_strips_brackets_for_ipv6() {
|
||||
assert_eq!(normalize_host("[::1]"), "::1");
|
||||
assert_eq!(normalize_host("[::1]:443"), "::1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_preserves_ipv6_scope_ids() {
|
||||
assert_eq!(normalize_host("fe80::1%lo0"), "fe80::1%lo0");
|
||||
assert_eq!(normalize_host("[fe80::1%lo0]"), "fe80::1%lo0");
|
||||
assert_eq!(normalize_host("[fe80::1%25lo0]"), "fe80::1%lo0");
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod config;
|
||||
mod connect_policy;
|
||||
mod connection_lifecycle;
|
||||
mod credential_broker;
|
||||
mod domain_matcher;
|
||||
mod environment_policy;
|
||||
mod http_proxy;
|
||||
mod mitm;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
#[cfg(test)]
|
||||
use crate::config::NetworkMode;
|
||||
use anyhow::Context;
|
||||
use crate::domain_matcher::DomainPatternSet;
|
||||
use crate::domain_matcher::GlobalWildcard;
|
||||
pub(crate) use crate::domain_matcher::is_global_wildcard_domain_pattern;
|
||||
pub use crate::domain_matcher::normalize_host;
|
||||
pub(crate) use crate::domain_matcher::unscoped_ip_literal;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use anyhow::ensure;
|
||||
use globset::GlobBuilder;
|
||||
use globset::GlobSet;
|
||||
use globset::GlobSetBuilder;
|
||||
use std::collections::HashSet;
|
||||
use std::net::IpAddr;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::Ipv6Addr;
|
||||
@@ -120,133 +119,12 @@ fn is_non_public_ipv6(ip: Ipv6Addr) -> bool {
|
||||
|| ip.is_unicast_link_local()
|
||||
}
|
||||
|
||||
/// Normalize host fragments for policy matching (trim whitespace, strip ports/brackets, lowercase).
|
||||
pub fn normalize_host(host: &str) -> String {
|
||||
let host = host.trim();
|
||||
if host.starts_with('[')
|
||||
&& let Some(end) = host.find(']')
|
||||
{
|
||||
return normalize_dns_host_or_ip_literal(&host[1..end]);
|
||||
}
|
||||
|
||||
// The proxy stack should typically hand us a host without a port, but be
|
||||
// defensive and strip `:port` when there is exactly one `:`.
|
||||
if host.bytes().filter(|b| *b == b':').count() == 1 {
|
||||
let host = host.split(':').next().unwrap_or_default();
|
||||
return normalize_dns_host_or_ip_literal(host);
|
||||
}
|
||||
|
||||
// Avoid mangling unbracketed IPv6 literals, but strip trailing dots so fully qualified domain
|
||||
// names are treated the same as their dotless variants.
|
||||
normalize_dns_host_or_ip_literal(host)
|
||||
pub(crate) fn compile_allowlist(patterns: &[String]) -> Result<DomainPatternSet> {
|
||||
DomainPatternSet::new(patterns, GlobalWildcard::Allow)
|
||||
}
|
||||
|
||||
fn normalize_dns_host_or_ip_literal(host: &str) -> String {
|
||||
let host = host.to_ascii_lowercase();
|
||||
let host = host.trim_end_matches('.');
|
||||
if let Some(ip) = normalize_ip_literal(host) {
|
||||
return ip;
|
||||
}
|
||||
host.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn unscoped_ip_literal(host: &str) -> Option<&str> {
|
||||
let (ip, _) = host.split_once('%')?;
|
||||
ip.parse::<IpAddr>().ok()?;
|
||||
Some(ip)
|
||||
}
|
||||
|
||||
fn normalize_ip_literal(host: &str) -> Option<String> {
|
||||
if host.parse::<IpAddr>().is_ok() {
|
||||
return Some(host.to_string());
|
||||
}
|
||||
for delimiter in ["%25", "%"] {
|
||||
if let Some((ip, scope)) = host.split_once(delimiter)
|
||||
&& ip.parse::<IpAddr>().is_ok()
|
||||
{
|
||||
return Some(format!("{ip}%{scope}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_pattern(pattern: &str) -> String {
|
||||
let pattern = pattern.trim();
|
||||
if pattern == "*" {
|
||||
return "*".to_string();
|
||||
}
|
||||
|
||||
let (prefix, remainder) = if let Some(domain) = pattern.strip_prefix("**.") {
|
||||
("**.", domain)
|
||||
} else if let Some(domain) = pattern.strip_prefix("*.") {
|
||||
("*.", domain)
|
||||
} else {
|
||||
("", pattern)
|
||||
};
|
||||
|
||||
let remainder = normalize_host(remainder);
|
||||
if prefix.is_empty() {
|
||||
remainder
|
||||
} else {
|
||||
format!("{prefix}{remainder}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_global_wildcard_domain_pattern(pattern: &str) -> bool {
|
||||
let normalized = normalize_pattern(pattern);
|
||||
expand_domain_pattern(&normalized)
|
||||
.iter()
|
||||
.any(|candidate| candidate == "*")
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
) -> Result<GlobSet> {
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
let mut seen = HashSet::new();
|
||||
for pattern in patterns {
|
||||
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
|
||||
// - "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()) {
|
||||
continue;
|
||||
}
|
||||
let glob = GlobBuilder::new(&candidate)
|
||||
.case_insensitive(true)
|
||||
.build()
|
||||
.with_context(|| format!("invalid glob pattern: {candidate}"))?;
|
||||
builder.add(glob);
|
||||
}
|
||||
}
|
||||
Ok(builder.build()?)
|
||||
pub(crate) fn compile_denylist(patterns: &[String]) -> Result<DomainPatternSet> {
|
||||
DomainPatternSet::new(patterns, GlobalWildcard::Reject)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -257,24 +135,6 @@ pub(crate) enum DomainPattern {
|
||||
}
|
||||
|
||||
impl DomainPattern {
|
||||
/// Parse a policy pattern for constraint comparisons.
|
||||
///
|
||||
/// Validation of glob syntax happens when building the globset; here we only
|
||||
/// decode the wildcard prefixes to keep constraint checks lightweight.
|
||||
pub(crate) fn parse(input: &str) -> Self {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return Self::Exact(String::new());
|
||||
}
|
||||
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)
|
||||
} else {
|
||||
Self::Exact(input.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a policy pattern for constraint comparisons, validating domain parts with `url`.
|
||||
pub(crate) fn parse_for_constraints(input: &str) -> Self {
|
||||
let input = input.trim();
|
||||
@@ -290,14 +150,6 @@ impl DomainPattern {
|
||||
Self::Exact(parse_domain_for_constraints(input))
|
||||
}
|
||||
|
||||
fn parse_domain(domain: &str, build: impl FnOnce(String) -> Self) -> Self {
|
||||
let domain = domain.trim();
|
||||
if domain.is_empty() {
|
||||
return Self::Exact(String::new());
|
||||
}
|
||||
build(domain.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn allows(&self, candidate: &DomainPattern) -> bool {
|
||||
match self {
|
||||
DomainPattern::Exact(domain) => match candidate {
|
||||
@@ -345,18 +197,6 @@ fn parse_domain_for_constraints(domain: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_domain_pattern(pattern: &str) -> Vec<String> {
|
||||
match DomainPattern::parse(pattern) {
|
||||
DomainPattern::Exact(domain) => vec![domain],
|
||||
DomainPattern::SubdomainsOnly(domain) => {
|
||||
vec![format!("?*.{domain}")]
|
||||
}
|
||||
DomainPattern::ApexAndSubdomains(domain) => {
|
||||
vec![domain.clone(), format!("?*.{domain}")]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_domain(domain: &str) -> String {
|
||||
domain.trim_end_matches('.').to_ascii_lowercase()
|
||||
}
|
||||
@@ -404,7 +244,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn compile_globset_normalizes_trailing_dots() {
|
||||
let set = compile_denylist_globset(&["Example.COM.".to_string()]).unwrap();
|
||||
let set = compile_denylist(&["Example.COM.".to_string()]).unwrap();
|
||||
|
||||
assert_eq!(true, set.is_match("example.com"));
|
||||
assert_eq!(false, set.is_match("api.example.com"));
|
||||
@@ -412,7 +252,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn compile_globset_normalizes_wildcards() {
|
||||
let set = compile_denylist_globset(&["*.Example.COM.".to_string()]).unwrap();
|
||||
let set = compile_denylist(&["*.Example.COM.".to_string()]).unwrap();
|
||||
|
||||
assert_eq!(true, set.is_match("api.example.com"));
|
||||
assert_eq!(false, set.is_match("example.com"));
|
||||
@@ -420,7 +260,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn compile_globset_supports_mid_label_wildcards() {
|
||||
let set = compile_denylist_globset(&["region*.v2.argotunnel.com".to_string()]).unwrap();
|
||||
let set = compile_denylist(&["region*.v2.argotunnel.com".to_string()]).unwrap();
|
||||
|
||||
assert_eq!(true, set.is_match("region1.v2.argotunnel.com"));
|
||||
assert_eq!(true, set.is_match("region.v2.argotunnel.com"));
|
||||
@@ -454,10 +294,7 @@ mod tests {
|
||||
(" API?.EXAMPLE.COM. ", "API1.EXAMPLE.COM.", true),
|
||||
] {
|
||||
let patterns = [pattern.to_string()];
|
||||
for set in [
|
||||
compile_allowlist_globset(&patterns)?,
|
||||
compile_denylist_globset(&patterns)?,
|
||||
] {
|
||||
for set in [compile_allowlist(&patterns)?, compile_denylist(&patterns)?] {
|
||||
assert_eq!(
|
||||
set.is_match(normalize_host(host)),
|
||||
expected,
|
||||
@@ -470,7 +307,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn compile_globset_normalizes_apex_and_subdomains() {
|
||||
let set = compile_denylist_globset(&["**.Example.COM.".to_string()]).unwrap();
|
||||
let set = compile_denylist(&["**.Example.COM.".to_string()]).unwrap();
|
||||
|
||||
assert_eq!(true, set.is_match("example.com"));
|
||||
assert_eq!(true, set.is_match("api.example.com"));
|
||||
@@ -478,14 +315,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn compile_globset_normalizes_bracketed_ipv6_literals() {
|
||||
let set = compile_denylist_globset(&["[::1]".to_string()]).unwrap();
|
||||
let set = compile_denylist(&["[::1]".to_string()]).unwrap();
|
||||
|
||||
assert_eq!(true, set.is_match("::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_globset_preserves_scoped_ipv6_literals() {
|
||||
let set = compile_denylist_globset(&["[fe80::1%25lo0]".to_string()]).unwrap();
|
||||
let set = compile_denylist(&["[fe80::1%25lo0]".to_string()]).unwrap();
|
||||
|
||||
assert_eq!(true, set.is_match("fe80::1%lo0"));
|
||||
assert_eq!(false, set.is_match("fe80::1%lo1"));
|
||||
@@ -530,43 +367,4 @@ mod tests {
|
||||
assert!(is_non_public_ip("fe80::1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("fc00::1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_lowercases_and_trims() {
|
||||
assert_eq!(normalize_host(" ExAmPlE.CoM "), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_strips_port_for_host_port() {
|
||||
assert_eq!(normalize_host("example.com:1234"), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_preserves_unbracketed_ipv6() {
|
||||
assert_eq!(normalize_host("2001:db8::1"), "2001:db8::1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_strips_trailing_dot() {
|
||||
assert_eq!(normalize_host("example.com."), "example.com");
|
||||
assert_eq!(normalize_host("ExAmPlE.CoM."), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_strips_trailing_dot_with_port() {
|
||||
assert_eq!(normalize_host("example.com.:443"), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_strips_brackets_for_ipv6() {
|
||||
assert_eq!(normalize_host("[::1]"), "::1");
|
||||
assert_eq!(normalize_host("[::1]:443"), "::1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_preserves_ipv6_scope_ids() {
|
||||
assert_eq!(normalize_host("fe80::1%lo0"), "fe80::1%lo0");
|
||||
assert_eq!(normalize_host("[fe80::1%lo0]"), "fe80::1%lo0");
|
||||
assert_eq!(normalize_host("[fe80::1%25lo0]"), "fe80::1%lo0");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::config::NetworkMode;
|
||||
use crate::config::NetworkProxyConfig;
|
||||
use crate::config::ValidatedUnixSocketPath;
|
||||
use crate::credential_broker::CredentialBroker;
|
||||
use crate::domain_matcher::DomainPatternSet;
|
||||
use crate::mitm::MitmState;
|
||||
use crate::mitm_hook::HookEvaluation;
|
||||
use crate::mitm_hook::MitmHooksByHost;
|
||||
@@ -23,7 +24,6 @@ use crate::state::validate_policy_against_constraints;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use globset::GlobSet;
|
||||
use opentelemetry::trace::SpanContext;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -178,8 +178,8 @@ pub struct ConfigState {
|
||||
pub(crate) executor_os: crate::Platform,
|
||||
pub config: NetworkProxyConfig,
|
||||
pub(crate) brokerage_created_default_allowlist: bool,
|
||||
pub allow_set: GlobSet,
|
||||
pub deny_set: GlobSet,
|
||||
pub allow_set: DomainPatternSet,
|
||||
pub deny_set: DomainPatternSet,
|
||||
pub mitm: Option<Arc<MitmState>>,
|
||||
pub mitm_hooks: MitmHooksByHost,
|
||||
pub constraints: NetworkProxyConstraints,
|
||||
@@ -296,7 +296,7 @@ struct ExecutionAttribution {
|
||||
|
||||
impl std::fmt::Debug for NetworkProxyState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy
|
||||
// Avoid logging internal state (config contents, compiled domain patterns, etc.) which can be noisy
|
||||
// and may contain sensitive paths.
|
||||
f.debug_struct("NetworkProxyState").finish_non_exhaustive()
|
||||
}
|
||||
@@ -768,12 +768,12 @@ impl NetworkProxyState {
|
||||
// 1) explicit deny always wins
|
||||
// 2) local/private networking is opt-in (defense-in-depth)
|
||||
// 3) DNS requires an allowlist match or an approval
|
||||
if globset_matches_host_or_unscoped(&deny_set, host_str) {
|
||||
if domains_match_host_or_unscoped(&deny_set, host_str) {
|
||||
return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied));
|
||||
}
|
||||
|
||||
let is_authorized = authorization == HostAuthorization::Approved
|
||||
|| globset_matches_host_or_unscoped(&allow_set, host_str);
|
||||
|| domains_match_host_or_unscoped(&allow_set, host_str);
|
||||
if !allow_local_binding {
|
||||
// If the intent is "prevent access to local/internal networks", we must not rely solely
|
||||
// on string checks like `localhost` / `127.0.0.1`. Attackers can use DNS rebinding or
|
||||
@@ -1244,7 +1244,7 @@ fn log_domain_list_changes(list_name: &str, previous: &[String], next: &[String]
|
||||
}
|
||||
}
|
||||
|
||||
fn globset_matches_host_or_unscoped(set: &GlobSet, host: &str) -> bool {
|
||||
fn domains_match_host_or_unscoped(set: &DomainPatternSet, host: &str) -> bool {
|
||||
set.is_match(host) || unscoped_ip_literal(host).is_some_and(|ip| set.is_match(ip))
|
||||
}
|
||||
|
||||
@@ -1281,18 +1281,14 @@ pub(crate) fn network_proxy_state_for_policy(
|
||||
}
|
||||
let state = ConfigState {
|
||||
executor_os: crate::Platform::native(),
|
||||
allow_set: crate::policy::compile_allowlist_globset(
|
||||
&config.allowed_domains().unwrap_or_default(),
|
||||
)
|
||||
.unwrap(),
|
||||
allow_set: crate::policy::compile_allowlist(&config.allowed_domains().unwrap_or_default())
|
||||
.unwrap(),
|
||||
blocked: VecDeque::new(),
|
||||
blocked_total: 0,
|
||||
brokerage_created_default_allowlist,
|
||||
constraints: NetworkProxyConstraints::default(),
|
||||
deny_set: crate::policy::compile_denylist_globset(
|
||||
&config.denied_domains().unwrap_or_default(),
|
||||
)
|
||||
.unwrap(),
|
||||
deny_set: crate::policy::compile_denylist(&config.denied_domains().unwrap_or_default())
|
||||
.unwrap(),
|
||||
mitm: None,
|
||||
mitm_hooks: crate::mitm_hook::compile_mitm_hooks(&config).unwrap(),
|
||||
config,
|
||||
@@ -1324,8 +1320,8 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::config::NetworkProxyConfig;
|
||||
use crate::policy::compile_allowlist_globset;
|
||||
use crate::policy::compile_denylist_globset;
|
||||
use crate::policy::compile_allowlist;
|
||||
use crate::policy::compile_denylist;
|
||||
use crate::state::NetworkProxyConstraints;
|
||||
use crate::state::build_config_state;
|
||||
use crate::state::validate_policy_against_constraints;
|
||||
@@ -2214,7 +2210,7 @@ mod tests {
|
||||
#[test]
|
||||
fn compile_globset_is_case_insensitive() {
|
||||
let patterns = vec!["ExAmPle.CoM".to_string()];
|
||||
let set = compile_denylist_globset(&patterns).unwrap();
|
||||
let set = compile_denylist(&patterns).unwrap();
|
||||
assert!(set.is_match("example.com"));
|
||||
assert!(set.is_match("EXAMPLE.COM"));
|
||||
}
|
||||
@@ -2222,7 +2218,7 @@ mod tests {
|
||||
#[test]
|
||||
fn compile_globset_excludes_apex_for_subdomain_patterns() {
|
||||
let patterns = vec!["*.openai.com".to_string()];
|
||||
let set = compile_denylist_globset(&patterns).unwrap();
|
||||
let set = compile_denylist(&patterns).unwrap();
|
||||
assert!(set.is_match("api.openai.com"));
|
||||
assert!(!set.is_match("openai.com"));
|
||||
assert!(!set.is_match("evilopenai.com"));
|
||||
@@ -2231,7 +2227,7 @@ mod tests {
|
||||
#[test]
|
||||
fn compile_globset_includes_apex_for_double_wildcard_patterns() {
|
||||
let patterns = vec!["**.openai.com".to_string()];
|
||||
let set = compile_denylist_globset(&patterns).unwrap();
|
||||
let set = compile_denylist(&patterns).unwrap();
|
||||
assert!(set.is_match("openai.com"));
|
||||
assert!(set.is_match("api.openai.com"));
|
||||
assert!(!set.is_match("evilopenai.com"));
|
||||
@@ -2240,13 +2236,13 @@ mod tests {
|
||||
#[test]
|
||||
fn compile_globset_rejects_global_wildcard() {
|
||||
let patterns = vec!["*".to_string()];
|
||||
assert!(compile_denylist_globset(&patterns).is_err());
|
||||
assert!(compile_denylist(&patterns).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_globset_allows_global_wildcard_when_enabled() {
|
||||
let patterns = vec!["*".to_string()];
|
||||
let set = compile_allowlist_globset(&patterns).unwrap();
|
||||
let set = compile_allowlist(&patterns).unwrap();
|
||||
assert!(set.is_match("example.com"));
|
||||
assert!(set.is_match("api.openai.com"));
|
||||
assert!(set.is_match("localhost"));
|
||||
@@ -2255,19 +2251,19 @@ mod tests {
|
||||
#[test]
|
||||
fn compile_globset_rejects_bracketed_global_wildcard() {
|
||||
let patterns = vec!["[*]".to_string()];
|
||||
assert!(compile_denylist_globset(&patterns).is_err());
|
||||
assert!(compile_denylist(&patterns).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_globset_rejects_double_wildcard_bracketed_global_wildcard() {
|
||||
let patterns = vec!["**.[*]".to_string()];
|
||||
assert!(compile_denylist_globset(&patterns).is_err());
|
||||
assert!(compile_denylist(&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_denylist_globset(&patterns).unwrap();
|
||||
let set = compile_denylist(&patterns).unwrap();
|
||||
assert!(set.is_match("example.com"));
|
||||
assert!(set.is_match("EXAMPLE.COM"));
|
||||
assert!(!set.is_match("not-example.com"));
|
||||
@@ -2276,7 +2272,7 @@ mod tests {
|
||||
#[test]
|
||||
fn compile_globset_rejects_invalid_patterns() {
|
||||
let patterns = vec!["[".to_string()];
|
||||
assert!(compile_denylist_globset(&patterns).is_err());
|
||||
assert!(compile_denylist(&patterns).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,8 +8,8 @@ use crate::mitm_hook::MitmHookConfig;
|
||||
use crate::mitm_hook::compile_mitm_hooks;
|
||||
use crate::mitm_hook::validate_mitm_hook_config;
|
||||
use crate::policy::DomainPattern;
|
||||
use crate::policy::compile_allowlist_globset;
|
||||
use crate::policy::compile_denylist_globset;
|
||||
use crate::policy::compile_allowlist;
|
||||
use crate::policy::compile_denylist;
|
||||
use crate::policy::is_global_wildcard_domain_pattern;
|
||||
use crate::runtime::ConfigState;
|
||||
use serde::Deserialize;
|
||||
@@ -90,8 +90,8 @@ pub fn build_config_state(
|
||||
let denied_domains = config.denied_domains().unwrap_or_default();
|
||||
validate_non_global_wildcard_domain_patterns("network.denied_domains", &denied_domains)
|
||||
.map_err(NetworkProxyConstraintError::into_anyhow)?;
|
||||
let deny_set = compile_denylist_globset(&denied_domains)?;
|
||||
let allow_set = compile_allowlist_globset(&allowed_domains)?;
|
||||
let deny_set = compile_denylist(&denied_domains)?;
|
||||
let allow_set = compile_allowlist(&allowed_domains)?;
|
||||
let mitm_hooks = compile_mitm_hooks(&config)?;
|
||||
let mitm = if config.mitm {
|
||||
Some(Arc::new(MitmState::new(MitmUpstreamConfig {
|
||||
|
||||
Reference in New Issue
Block a user