mirror of
https://github.com/openai/codex.git
synced 2026-04-30 01:16:54 +00:00
chore: refactor network permissions to use explicit domain and unix socket rule maps (#15120)
## Summary This PR replaces the legacy network allow/deny list model with explicit rule maps for domains and unix sockets across managed requirements, permissions profiles, the network proxy config, and the app server protocol. Concretely, it: - introduces typed domain (`allow` / `deny`) and unix socket permission (`allow` / `none`) entries instead of separate `allowed_domains`, `denied_domains`, and `allow_unix_sockets` lists - updates config loading, managed requirements merging, and exec-policy overlays to read and upsert rule entries consistently - exposes the new shape through protocol/schema outputs, debug surfaces, and app-server config APIs - rejects the legacy list-based keys and updates docs/tests to reflect the new config format ## Why The previous representation split related network policy across multiple parallel lists, which made merging and overriding rules harder to reason about. Moving to explicit keyed permission maps gives us a single source of truth per host/socket entry, makes allow/deny precedence clearer, and gives protocol consumers access to the full rule state instead of derived projections only. ## Backward Compatibility ### Backward compatible - Managed requirements still accept the legacy `experimental_network.allowed_domains`, `experimental_network.denied_domains`, and `experimental_network.allow_unix_sockets` fields. They are normalized into the new canonical `domains` and `unix_sockets` maps internally. - App-server v2 still deserializes legacy `allowedDomains`, `deniedDomains`, and `allowUnixSockets` payloads, so older clients can continue reading managed network requirements. - App-server v2 responses still populate `allowedDomains`, `deniedDomains`, and `allowUnixSockets` as legacy compatibility views derived from the canonical maps. - `managed_allowed_domains_only` keeps the same behavior after normalization. Legacy managed allowlists still participate in the same enforcement path as canonical `domains` entries. ### Not backward compatible - Permissions profiles under `[permissions.<profile>.network]` no longer accept the legacy list-based keys. Those configs must use the canonical `[domains]` and `[unix_sockets]` tables instead of `allowed_domains`, `denied_domains`, or `allow_unix_sockets`. - Managed `experimental_network` config cannot mix canonical and legacy forms in the same block. For example, `domains` cannot be combined with `allowed_domains` or `denied_domains`, and `unix_sockets` cannot be combined with `allow_unix_sockets`. - The canonical format can express explicit `"none"` entries for unix sockets, but those entries do not round-trip through the legacy compatibility fields because the legacy fields only represent allow/deny lists. ## Testing `/target/debug/codex sandbox macos --log-denials /bin/zsh -c 'curl https://www.example.com' ` gives 200 with config ``` [permissions.workspace.network.domains] "www.example.com" = "allow" ``` and fails when set to deny: `curl: (56) CONNECT tunnel failed, response 403`. Also tested backward compatibility path by verifying that adding the following to `/etc/codex/requirements.toml` works: ``` [experimental_network] allowed_domains = ["www.example.com"] ```
This commit is contained in:
@@ -3,7 +3,10 @@ use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
use serde::Serializer;
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::IpAddr;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
@@ -16,6 +19,101 @@ pub struct NetworkProxyConfig {
|
||||
pub network: NetworkProxySettings,
|
||||
}
|
||||
|
||||
/// Variant order encodes effective precedence for duplicate patterns:
|
||||
/// `None < Allow < Deny`, so deny wins over allow when entries conflict.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NetworkDomainPermission {
|
||||
None,
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NetworkDomainPermissionEntry {
|
||||
pub pattern: String,
|
||||
pub permission: NetworkDomainPermission,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct NetworkDomainPermissions {
|
||||
pub entries: Vec<NetworkDomainPermissionEntry>,
|
||||
}
|
||||
|
||||
impl Serialize for NetworkDomainPermissions {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.effective_entries()
|
||||
.into_iter()
|
||||
.map(|entry| (entry.pattern, entry.permission))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for NetworkDomainPermissions {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let entries = BTreeMap::<String, NetworkDomainPermission>::deserialize(deserializer)?
|
||||
.into_iter()
|
||||
.map(|(pattern, permission)| NetworkDomainPermissionEntry {
|
||||
pattern,
|
||||
permission,
|
||||
})
|
||||
.collect();
|
||||
Ok(Self { entries })
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkDomainPermissions {
|
||||
fn effective_entries(&self) -> Vec<NetworkDomainPermissionEntry> {
|
||||
let mut order = Vec::new();
|
||||
let mut effective_permissions = BTreeMap::new();
|
||||
|
||||
for entry in &self.entries {
|
||||
if !effective_permissions.contains_key(&entry.pattern) {
|
||||
order.push(entry.pattern.clone());
|
||||
}
|
||||
|
||||
let permission = effective_permissions
|
||||
.entry(entry.pattern.clone())
|
||||
.or_insert(entry.permission);
|
||||
if entry.permission > *permission {
|
||||
*permission = entry.permission;
|
||||
}
|
||||
}
|
||||
|
||||
order
|
||||
.into_iter()
|
||||
.filter_map(|pattern| {
|
||||
effective_permissions.remove(&pattern).map(|permission| {
|
||||
NetworkDomainPermissionEntry {
|
||||
pattern,
|
||||
permission,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NetworkUnixSocketPermission {
|
||||
Allow,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct NetworkUnixSocketPermissions {
|
||||
#[serde(flatten)]
|
||||
pub entries: BTreeMap<String, NetworkUnixSocketPermission>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct NetworkProxySettings {
|
||||
@@ -35,11 +133,9 @@ pub struct NetworkProxySettings {
|
||||
#[serde(default)]
|
||||
pub mode: NetworkMode,
|
||||
#[serde(default)]
|
||||
pub allowed_domains: Vec<String>,
|
||||
pub domains: Option<NetworkDomainPermissions>,
|
||||
#[serde(default)]
|
||||
pub denied_domains: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allow_unix_sockets: Vec<String>,
|
||||
pub unix_sockets: Option<NetworkUnixSocketPermissions>,
|
||||
pub allow_local_binding: bool,
|
||||
#[serde(default)]
|
||||
pub mitm: bool,
|
||||
@@ -57,15 +153,119 @@ impl Default for NetworkProxySettings {
|
||||
dangerously_allow_non_loopback_proxy: false,
|
||||
dangerously_allow_all_unix_sockets: false,
|
||||
mode: NetworkMode::default(),
|
||||
allowed_domains: Vec::new(),
|
||||
denied_domains: Vec::new(),
|
||||
allow_unix_sockets: Vec::new(),
|
||||
domains: None,
|
||||
unix_sockets: None,
|
||||
allow_local_binding: false,
|
||||
mitm: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkProxySettings {
|
||||
pub fn allowed_domains(&self) -> Option<Vec<String>> {
|
||||
self.domain_entries(NetworkDomainPermission::Allow)
|
||||
}
|
||||
|
||||
pub fn denied_domains(&self) -> Option<Vec<String>> {
|
||||
self.domain_entries(NetworkDomainPermission::Deny)
|
||||
}
|
||||
|
||||
fn domain_entries(&self, permission: NetworkDomainPermission) -> Option<Vec<String>> {
|
||||
self.domains
|
||||
.as_ref()
|
||||
.map(|domains| {
|
||||
domains
|
||||
.effective_entries()
|
||||
.iter()
|
||||
.filter(|entry| entry.permission == permission)
|
||||
.map(|entry| entry.pattern.clone())
|
||||
.collect()
|
||||
})
|
||||
.filter(|entries: &Vec<String>| !entries.is_empty())
|
||||
}
|
||||
|
||||
pub fn allow_unix_sockets(&self) -> Vec<String> {
|
||||
self.unix_sockets
|
||||
.as_ref()
|
||||
.map(|unix_sockets| {
|
||||
unix_sockets
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, permission)| {
|
||||
matches!(permission, NetworkUnixSocketPermission::Allow)
|
||||
})
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set_allowed_domains(&mut self, allowed_domains: Vec<String>) {
|
||||
self.set_domain_entries(allowed_domains, NetworkDomainPermission::Allow);
|
||||
}
|
||||
|
||||
pub fn set_denied_domains(&mut self, denied_domains: Vec<String>) {
|
||||
self.set_domain_entries(denied_domains, NetworkDomainPermission::Deny);
|
||||
}
|
||||
|
||||
pub fn upsert_domain_permission(
|
||||
&mut self,
|
||||
host: String,
|
||||
permission: NetworkDomainPermission,
|
||||
normalize: impl Fn(&str) -> String,
|
||||
) {
|
||||
let mut domains = self.domains.take().unwrap_or_default();
|
||||
let normalized_host = normalize(&host);
|
||||
domains
|
||||
.entries
|
||||
.retain(|entry| normalize(&entry.pattern) != normalized_host);
|
||||
domains.entries.push(NetworkDomainPermissionEntry {
|
||||
pattern: host,
|
||||
permission,
|
||||
});
|
||||
self.domains = (!domains.entries.is_empty()).then_some(domains);
|
||||
}
|
||||
|
||||
pub fn set_allow_unix_sockets(&mut self, allow_unix_sockets: Vec<String>) {
|
||||
self.set_unix_socket_entries(allow_unix_sockets, NetworkUnixSocketPermission::Allow);
|
||||
}
|
||||
|
||||
fn set_domain_entries(&mut self, entries: Vec<String>, permission: NetworkDomainPermission) {
|
||||
let mut domains = self.domains.take().unwrap_or_default();
|
||||
domains
|
||||
.entries
|
||||
.retain(|entry| entry.permission != permission);
|
||||
for entry in entries {
|
||||
if !domains
|
||||
.entries
|
||||
.iter()
|
||||
.any(|existing| existing.pattern == entry && existing.permission == permission)
|
||||
{
|
||||
domains.entries.push(NetworkDomainPermissionEntry {
|
||||
pattern: entry,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.domains = (!domains.entries.is_empty()).then_some(domains);
|
||||
}
|
||||
|
||||
fn set_unix_socket_entries(
|
||||
&mut self,
|
||||
entries: Vec<String>,
|
||||
permission: NetworkUnixSocketPermission,
|
||||
) {
|
||||
let mut unix_sockets = self.unix_sockets.take().unwrap_or_default();
|
||||
unix_sockets
|
||||
.entries
|
||||
.retain(|_, existing| *existing != permission);
|
||||
for entry in entries {
|
||||
unix_sockets.entries.insert(entry, permission);
|
||||
}
|
||||
self.unix_sockets = (!unix_sockets.entries.is_empty()).then_some(unix_sockets);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NetworkMode {
|
||||
@@ -136,7 +336,7 @@ pub(crate) fn clamp_bind_addrs(
|
||||
"SOCKS5 proxy",
|
||||
"dangerously_allow_non_loopback_proxy",
|
||||
);
|
||||
if cfg.allow_unix_sockets.is_empty() && !cfg.dangerously_allow_all_unix_sockets {
|
||||
if cfg.allow_unix_sockets().is_empty() && !cfg.dangerously_allow_all_unix_sockets {
|
||||
return (http_addr, socks_addr);
|
||||
}
|
||||
|
||||
@@ -198,7 +398,7 @@ impl ValidatedUnixSocketPath {
|
||||
}
|
||||
|
||||
pub(crate) fn validate_unix_socket_allowlist_paths(cfg: &NetworkProxyConfig) -> Result<()> {
|
||||
for (index, socket_path) in cfg.network.allow_unix_sockets.iter().enumerate() {
|
||||
for (index, socket_path) in cfg.network.allow_unix_sockets().iter().enumerate() {
|
||||
ValidatedUnixSocketPath::parse(socket_path)
|
||||
.with_context(|| format!("invalid network.allow_unix_sockets[{index}]"))?;
|
||||
}
|
||||
@@ -357,6 +557,19 @@ mod tests {
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn settings_with_unix_sockets(unix_sockets: &[&str]) -> NetworkProxySettings {
|
||||
let mut settings = NetworkProxySettings::default();
|
||||
if !unix_sockets.is_empty() {
|
||||
settings.set_allow_unix_sockets(
|
||||
unix_sockets
|
||||
.iter()
|
||||
.map(|path| (*path).to_string())
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_proxy_settings_default_matches_local_use_baseline() {
|
||||
assert_eq!(
|
||||
@@ -371,9 +584,8 @@ mod tests {
|
||||
dangerously_allow_non_loopback_proxy: false,
|
||||
dangerously_allow_all_unix_sockets: false,
|
||||
mode: NetworkMode::Full,
|
||||
allowed_domains: Vec::new(),
|
||||
denied_domains: Vec::new(),
|
||||
allow_unix_sockets: Vec::new(),
|
||||
domains: None,
|
||||
unix_sockets: None,
|
||||
allow_local_binding: false,
|
||||
mitm: false,
|
||||
}
|
||||
@@ -398,6 +610,53 @@ mod tests {
|
||||
assert_eq!(config.network, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_allowed_domains_preserves_existing_deny_for_same_pattern() {
|
||||
let mut settings = NetworkProxySettings::default();
|
||||
settings.set_denied_domains(vec!["example.com".to_string()]);
|
||||
|
||||
settings.set_allowed_domains(vec!["example.com".to_string()]);
|
||||
|
||||
assert_eq!(settings.allowed_domains(), None);
|
||||
assert_eq!(
|
||||
settings.denied_domains(),
|
||||
Some(vec!["example.com".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_domain_permissions_serialize_to_effective_map_shape() {
|
||||
let mut settings = NetworkProxySettings::default();
|
||||
settings.set_denied_domains(vec!["example.com".to_string()]);
|
||||
settings.set_allowed_domains(vec!["example.com".to_string()]);
|
||||
let config = NetworkProxyConfig { network: settings };
|
||||
|
||||
let value = serde_json::to_value(&config).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
value,
|
||||
serde_json::json!({
|
||||
"network": {
|
||||
"enabled": false,
|
||||
"proxy_url": "http://127.0.0.1:3128",
|
||||
"enable_socks5": true,
|
||||
"socks_url": "http://127.0.0.1:8081",
|
||||
"enable_socks5_udp": true,
|
||||
"allow_upstream_proxy": true,
|
||||
"dangerously_allow_non_loopback_proxy": false,
|
||||
"dangerously_allow_all_unix_sockets": false,
|
||||
"mode": "full",
|
||||
"domains": {
|
||||
"example.com": "deny",
|
||||
},
|
||||
"unix_sockets": null,
|
||||
"allow_local_binding": false,
|
||||
"mitm": false,
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_host_port_defaults_for_empty_string() {
|
||||
assert!(parse_host_port("", 1234).is_err());
|
||||
@@ -536,10 +795,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn clamp_bind_addrs_forces_loopback_when_unix_sockets_enabled() {
|
||||
let cfg = NetworkProxySettings {
|
||||
dangerously_allow_non_loopback_proxy: true,
|
||||
allow_unix_sockets: vec!["/tmp/docker.sock".to_string()],
|
||||
..Default::default()
|
||||
let cfg = {
|
||||
let mut settings = settings_with_unix_sockets(&["/tmp/docker.sock"]);
|
||||
settings.dangerously_allow_non_loopback_proxy = true;
|
||||
settings
|
||||
};
|
||||
let http_addr = "0.0.0.0:3128".parse::<SocketAddr>().unwrap();
|
||||
let socks_addr = "0.0.0.0:8081".parse::<SocketAddr>().unwrap();
|
||||
@@ -569,10 +828,7 @@ mod tests {
|
||||
#[test]
|
||||
fn resolve_runtime_rejects_relative_allow_unix_sockets_entries() {
|
||||
let cfg = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
allow_unix_sockets: vec!["relative.sock".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
},
|
||||
network: settings_with_unix_sockets(&["relative.sock"]),
|
||||
};
|
||||
|
||||
let err = match resolve_runtime(&cfg) {
|
||||
@@ -591,10 +847,7 @@ mod tests {
|
||||
#[test]
|
||||
fn resolve_runtime_accepts_unix_style_absolute_allow_unix_sockets_entries() {
|
||||
let cfg = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
allow_unix_sockets: vec!["/private/tmp/example.sock".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
},
|
||||
network: settings_with_unix_sockets(&["/private/tmp/example.sock"]),
|
||||
};
|
||||
|
||||
assert!(
|
||||
|
||||
@@ -1002,9 +1002,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_connect_accept_blocks_in_limited_mode() {
|
||||
let policy = NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
..Default::default()
|
||||
let policy = {
|
||||
let mut policy = NetworkProxySettings::default();
|
||||
policy.set_allowed_domains(vec!["example.com".to_string()]);
|
||||
policy
|
||||
};
|
||||
let state = Arc::new(network_proxy_state_for_policy(policy));
|
||||
state.set_network_mode(NetworkMode::Limited).await.unwrap();
|
||||
@@ -1027,9 +1028,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_connect_accept_allows_allowlisted_host_in_full_mode() {
|
||||
let policy = NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
..Default::default()
|
||||
let policy = {
|
||||
let mut policy = NetworkProxySettings::default();
|
||||
policy.set_allowed_domains(vec!["example.com".to_string()]);
|
||||
policy
|
||||
};
|
||||
let state = Arc::new(network_proxy_state_for_policy(policy));
|
||||
|
||||
@@ -1062,10 +1064,11 @@ mod tests {
|
||||
let _ = timeout(Duration::from_secs(1), stream.read(&mut buf)).await;
|
||||
});
|
||||
|
||||
let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["127.0.0.1".to_string()],
|
||||
allow_local_binding: true,
|
||||
..NetworkProxySettings::default()
|
||||
let state = Arc::new(network_proxy_state_for_policy({
|
||||
let mut network = NetworkProxySettings::default();
|
||||
network.set_allowed_domains(vec!["127.0.0.1".to_string()]);
|
||||
network.allow_local_binding = true;
|
||||
network
|
||||
}));
|
||||
let listener =
|
||||
StdTcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("proxy listener should bind");
|
||||
@@ -1161,9 +1164,10 @@ mod tests {
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn http_plain_proxy_attempts_allowed_unix_socket_proxy() {
|
||||
let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allow_unix_sockets: vec!["/tmp/test.sock".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
let state = Arc::new(network_proxy_state_for_policy({
|
||||
let mut network = NetworkProxySettings::default();
|
||||
network.set_allow_unix_sockets(vec!["/tmp/test.sock".to_string()]);
|
||||
network
|
||||
}));
|
||||
|
||||
let mut req = Request::builder()
|
||||
@@ -1180,10 +1184,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_connect_accept_denies_denylisted_host() {
|
||||
let policy = NetworkProxySettings {
|
||||
allowed_domains: vec!["**.openai.com".to_string()],
|
||||
denied_domains: vec!["api.openai.com".to_string()],
|
||||
..Default::default()
|
||||
let policy = {
|
||||
let mut policy = NetworkProxySettings::default();
|
||||
policy.set_allowed_domains(vec!["**.openai.com".to_string()]);
|
||||
policy.set_denied_domains(vec!["api.openai.com".to_string()]);
|
||||
policy
|
||||
};
|
||||
let state = Arc::new(network_proxy_state_for_policy(policy));
|
||||
|
||||
|
||||
@@ -14,8 +14,13 @@ mod socks5;
|
||||
mod state;
|
||||
mod upstream;
|
||||
|
||||
pub use config::NetworkDomainPermission;
|
||||
pub use config::NetworkDomainPermissionEntry;
|
||||
pub use config::NetworkDomainPermissions;
|
||||
pub use config::NetworkMode;
|
||||
pub use config::NetworkProxyConfig;
|
||||
pub use config::NetworkUnixSocketPermission;
|
||||
pub use config::NetworkUnixSocketPermissions;
|
||||
pub use config::host_and_port_from_network_addr;
|
||||
pub use network_policy::NetworkDecision;
|
||||
pub use network_policy::NetworkDecisionSource;
|
||||
|
||||
@@ -26,9 +26,10 @@ fn policy_ctx(
|
||||
|
||||
#[tokio::test]
|
||||
async fn mitm_policy_blocks_disallowed_method_and_records_telemetry() {
|
||||
let app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
let app_state = Arc::new(network_proxy_state_for_policy({
|
||||
let mut network = NetworkProxySettings::default();
|
||||
network.set_allowed_domains(vec!["example.com".to_string()]);
|
||||
network
|
||||
}));
|
||||
let ctx = policy_ctx(app_state.clone(), NetworkMode::Limited, "example.com", 443);
|
||||
let req = Request::builder()
|
||||
@@ -59,9 +60,10 @@ async fn mitm_policy_blocks_disallowed_method_and_records_telemetry() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn mitm_policy_rejects_host_mismatch() {
|
||||
let app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
let app_state = Arc::new(network_proxy_state_for_policy({
|
||||
let mut network = NetworkProxySettings::default();
|
||||
network.set_allowed_domains(vec!["example.com".to_string()]);
|
||||
network
|
||||
}));
|
||||
let ctx = policy_ctx(app_state.clone(), NetworkMode::Full, "example.com", 443);
|
||||
let req = Request::builder()
|
||||
@@ -82,10 +84,11 @@ 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!["example.com".to_string()],
|
||||
allow_local_binding: false,
|
||||
..NetworkProxySettings::default()
|
||||
let app_state = Arc::new(network_proxy_state_for_policy({
|
||||
let mut network = NetworkProxySettings::default();
|
||||
network.set_allowed_domains(vec!["example.com".to_string()]);
|
||||
network.allow_local_binding = false;
|
||||
network
|
||||
}));
|
||||
let ctx = policy_ctx(app_state.clone(), NetworkMode::Full, "10.0.0.1", 443);
|
||||
let req = Request::builder()
|
||||
|
||||
@@ -676,10 +676,11 @@ mod tests {
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn evaluate_host_policy_emits_domain_event_for_baseline_deny() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
denied_domains: vec!["blocked.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
let state = network_proxy_state_for_policy({
|
||||
let mut network = NetworkProxySettings::default();
|
||||
network.set_allowed_domains(vec!["example.com".to_string()]);
|
||||
network.set_denied_domains(vec!["blocked.com".to_string()]);
|
||||
network
|
||||
});
|
||||
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
|
||||
protocol: NetworkProtocol::Http,
|
||||
@@ -850,10 +851,11 @@ mod tests {
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn evaluate_host_policy_still_denies_not_allowed_local_without_decider_override() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
allow_local_binding: false,
|
||||
..NetworkProxySettings::default()
|
||||
let state = network_proxy_state_for_policy({
|
||||
let mut network = NetworkProxySettings::default();
|
||||
network.set_allowed_domains(vec!["example.com".to_string()]);
|
||||
network.allow_local_binding = false;
|
||||
network
|
||||
});
|
||||
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
|
||||
protocol: NetworkProtocol::Http,
|
||||
|
||||
@@ -220,7 +220,7 @@ impl NetworkProxyBuilder {
|
||||
socks_addr,
|
||||
socks_enabled: current_cfg.network.enable_socks5,
|
||||
allow_local_binding: current_cfg.network.allow_local_binding,
|
||||
allow_unix_sockets: current_cfg.network.allow_unix_sockets.clone(),
|
||||
allow_unix_sockets: current_cfg.network.allow_unix_sockets(),
|
||||
dangerously_allow_all_unix_sockets: current_cfg
|
||||
.network
|
||||
.dangerously_allow_all_unix_sockets,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::config::NetworkDomainPermission;
|
||||
use crate::config::NetworkMode;
|
||||
use crate::config::NetworkProxyConfig;
|
||||
use crate::config::ValidatedUnixSocketPath;
|
||||
@@ -295,8 +296,8 @@ impl NetworkProxyState {
|
||||
self.reload_if_needed().await?;
|
||||
let guard = self.state.read().await;
|
||||
Ok((
|
||||
guard.config.network.allowed_domains.clone(),
|
||||
guard.config.network.denied_domains.clone(),
|
||||
guard.config.network.allowed_domains().unwrap_or_default(),
|
||||
guard.config.network.denied_domains().unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -340,16 +341,18 @@ impl NetworkProxyState {
|
||||
Ok(host) => host,
|
||||
Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)),
|
||||
};
|
||||
let (deny_set, allow_set, allow_local_binding, allowed_domains_empty, allowed_domains) = {
|
||||
let (deny_set, allow_set, allow_local_binding, allowed_domains) = {
|
||||
let guard = self.state.read().await;
|
||||
let allowed_domains = guard.config.network.allowed_domains();
|
||||
(
|
||||
guard.deny_set.clone(),
|
||||
guard.allow_set.clone(),
|
||||
guard.config.network.allow_local_binding,
|
||||
guard.config.network.allowed_domains.is_empty(),
|
||||
guard.config.network.allowed_domains.clone(),
|
||||
allowed_domains,
|
||||
)
|
||||
};
|
||||
let allowed_domains_empty = allowed_domains.is_none();
|
||||
let allowed_domains = allowed_domains.unwrap_or_default();
|
||||
|
||||
let host_str = host.as_str();
|
||||
|
||||
@@ -481,7 +484,7 @@ impl NetworkProxyState {
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok();
|
||||
for allowed in &guard.config.network.allow_unix_sockets {
|
||||
for allowed in &guard.config.network.allow_unix_sockets() {
|
||||
let allowed_path = match ValidatedUnixSocketPath::parse(allowed) {
|
||||
Ok(ValidatedUnixSocketPath::Native(path)) => path,
|
||||
Ok(ValidatedUnixSocketPath::UnixStyleAbsolute(_)) => continue,
|
||||
@@ -585,7 +588,8 @@ impl NetworkProxyState {
|
||||
};
|
||||
|
||||
let mut candidate = previous_cfg.clone();
|
||||
let (target_entries, opposite_entries) = candidate.split_domain_lists_mut(target);
|
||||
let target_entries = target.entries(&candidate.network);
|
||||
let opposite_entries = target.opposite_entries(&candidate.network);
|
||||
let target_contains = target_entries
|
||||
.iter()
|
||||
.any(|entry| normalize_host(entry) == normalized_host);
|
||||
@@ -596,9 +600,11 @@ impl NetworkProxyState {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
target_entries.retain(|entry| normalize_host(entry) != normalized_host);
|
||||
target_entries.push(normalized_host.clone());
|
||||
opposite_entries.retain(|entry| normalize_host(entry) != normalized_host);
|
||||
candidate.network.upsert_domain_permission(
|
||||
normalized_host.clone(),
|
||||
target.permission(),
|
||||
normalize_host,
|
||||
);
|
||||
|
||||
validate_policy_against_constraints(&candidate, &constraints)
|
||||
.map_err(NetworkProxyConstraintError::into_anyhow)
|
||||
@@ -669,22 +675,25 @@ impl DomainListKind {
|
||||
Self::Deny => "network.denied_domains",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkProxyConfig {
|
||||
fn split_domain_lists_mut(
|
||||
&mut self,
|
||||
target: DomainListKind,
|
||||
) -> (&mut Vec<String>, &mut Vec<String>) {
|
||||
match target {
|
||||
DomainListKind::Allow => (
|
||||
&mut self.network.allowed_domains,
|
||||
&mut self.network.denied_domains,
|
||||
),
|
||||
DomainListKind::Deny => (
|
||||
&mut self.network.denied_domains,
|
||||
&mut self.network.allowed_domains,
|
||||
),
|
||||
fn permission(self) -> NetworkDomainPermission {
|
||||
match self {
|
||||
Self::Allow => NetworkDomainPermission::Allow,
|
||||
Self::Deny => NetworkDomainPermission::Deny,
|
||||
}
|
||||
}
|
||||
|
||||
fn entries(self, network: &crate::config::NetworkProxySettings) -> Vec<String> {
|
||||
match self {
|
||||
Self::Allow => network.allowed_domains().unwrap_or_default(),
|
||||
Self::Deny => network.denied_domains().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn opposite_entries(self, network: &crate::config::NetworkProxySettings) -> Vec<String> {
|
||||
match self {
|
||||
Self::Allow => network.denied_domains().unwrap_or_default(),
|
||||
Self::Deny => network.allowed_domains().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -726,16 +735,16 @@ async fn host_resolves_to_non_public_ip(host: &str, port: u16) -> bool {
|
||||
}
|
||||
|
||||
fn log_policy_changes(previous: &NetworkProxyConfig, next: &NetworkProxyConfig) {
|
||||
let previous_allowed_domains = previous.network.allowed_domains().unwrap_or_default();
|
||||
let next_allowed_domains = next.network.allowed_domains().unwrap_or_default();
|
||||
log_domain_list_changes(
|
||||
"allowlist",
|
||||
&previous.network.allowed_domains,
|
||||
&next.network.allowed_domains,
|
||||
);
|
||||
log_domain_list_changes(
|
||||
"denylist",
|
||||
&previous.network.denied_domains,
|
||||
&next.network.denied_domains,
|
||||
&previous_allowed_domains,
|
||||
&next_allowed_domains,
|
||||
);
|
||||
let previous_denied_domains = previous.network.denied_domains().unwrap_or_default();
|
||||
let next_denied_domains = next.network.denied_domains().unwrap_or_default();
|
||||
log_domain_list_changes("denylist", &previous_denied_domains, &next_denied_domains);
|
||||
}
|
||||
|
||||
fn log_domain_list_changes(list_name: &str, previous: &[String], next: &[String]) {
|
||||
@@ -836,13 +845,37 @@ mod tests {
|
||||
use crate::state::validate_policy_against_constraints;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn strings(entries: &[&str]) -> Vec<String> {
|
||||
entries.iter().map(|entry| (*entry).to_string()).collect()
|
||||
}
|
||||
|
||||
fn network_settings(allowed_domains: &[&str], denied_domains: &[&str]) -> NetworkProxySettings {
|
||||
let mut network = NetworkProxySettings::default();
|
||||
if !allowed_domains.is_empty() {
|
||||
network.set_allowed_domains(strings(allowed_domains));
|
||||
}
|
||||
if !denied_domains.is_empty() {
|
||||
network.set_denied_domains(strings(denied_domains));
|
||||
}
|
||||
network
|
||||
}
|
||||
|
||||
fn network_settings_with_unix_sockets(
|
||||
allowed_domains: &[&str],
|
||||
denied_domains: &[&str],
|
||||
unix_sockets: &[String],
|
||||
) -> NetworkProxySettings {
|
||||
let mut network = network_settings(allowed_domains, denied_domains);
|
||||
if !unix_sockets.is_empty() {
|
||||
network.set_allow_unix_sockets(unix_sockets.to_vec());
|
||||
}
|
||||
network
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_blocked_denied_wins_over_allowed() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
denied_domains: vec!["example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state =
|
||||
network_proxy_state_for_policy(network_settings(&["example.com"], &["example.com"]));
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("example.com", 80).await.unwrap(),
|
||||
@@ -852,10 +885,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_blocked_requires_allowlist_match() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("example.com", 80).await.unwrap(),
|
||||
@@ -871,10 +901,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_allowed_domain_removes_matching_deny_entry() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
denied_domains: vec!["example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings(&[], &["example.com"]));
|
||||
|
||||
state.add_allowed_domain("ExAmPlE.CoM").await.unwrap();
|
||||
|
||||
@@ -889,10 +916,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_denied_domain_removes_matching_allow_entry() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
|
||||
|
||||
state.add_denied_domain("EXAMPLE.COM").await.unwrap();
|
||||
|
||||
@@ -907,10 +931,7 @@ 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()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings(&["*"], &[]));
|
||||
|
||||
assert_eq!(
|
||||
// Use a public IP literal to avoid relying on ambient DNS behavior.
|
||||
@@ -932,10 +953,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn add_allowed_domain_succeeds_when_managed_baseline_allows_expansion() {
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
allowed_domains: vec!["managed.example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
network: {
|
||||
let mut network = network_settings(&["managed.example.com"], &[]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
let constraints = NetworkProxyConstraints {
|
||||
@@ -964,10 +985,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn add_allowed_domain_rejects_expansion_when_managed_baseline_is_fixed() {
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
allowed_domains: vec!["managed.example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
network: {
|
||||
let mut network = network_settings(&["managed.example.com"], &[]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
let constraints = NetworkProxyConstraints {
|
||||
@@ -994,10 +1015,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn add_denied_domain_rejects_expansion_when_managed_baseline_is_fixed() {
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
denied_domains: vec!["managed.example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
network: {
|
||||
let mut network = network_settings(&[], &["managed.example.com"]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
let constraints = NetworkProxyConstraints {
|
||||
@@ -1109,10 +1130,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_blocked_subdomain_wildcards_exclude_apex() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["*.openai.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings(&["*.openai.com"], &[]));
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("api.openai.com", 80).await.unwrap(),
|
||||
@@ -1126,11 +1144,7 @@ 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()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings(&["*"], &["evil.example"]));
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("example.com", 80).await.unwrap(),
|
||||
@@ -1148,11 +1162,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_blocked_rejects_loopback_when_local_binding_disabled() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
allow_local_binding: false,
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("127.0.0.1", 80).await.unwrap(),
|
||||
@@ -1166,11 +1176,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["localhost".to_string()],
|
||||
allow_local_binding: false,
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings(&["localhost"], &[]));
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("localhost", 80).await.unwrap(),
|
||||
@@ -1180,11 +1186,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_blocked_allows_private_ip_literal_when_explicitly_allowlisted() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["10.0.0.1".to_string()],
|
||||
allow_local_binding: false,
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings(&["10.0.0.1"], &[]));
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("10.0.0.1", 80).await.unwrap(),
|
||||
@@ -1194,11 +1196,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_blocked_rejects_scoped_ipv6_literal_when_not_allowlisted() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
allow_local_binding: false,
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("fe80::1%lo0", 80).await.unwrap(),
|
||||
@@ -1208,11 +1206,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_blocked_allows_scoped_ipv6_literal_when_explicitly_allowlisted() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["fe80::1%lo0".to_string()],
|
||||
allow_local_binding: false,
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings(&["fe80::1%lo0"], &[]));
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("fe80::1%lo0", 80).await.unwrap(),
|
||||
@@ -1222,11 +1216,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_blocked_rejects_private_ip_literals_when_local_binding_disabled() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
allow_local_binding: false,
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("10.0.0.1", 80).await.unwrap(),
|
||||
@@ -1236,11 +1226,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_blocked_rejects_loopback_when_allowlist_empty() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec![],
|
||||
allow_local_binding: false,
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("127.0.0.1", 80).await.unwrap(),
|
||||
@@ -1250,11 +1236,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_blocked_rejects_allowlisted_hostname_when_dns_lookup_fails() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["does-not-resolve.invalid".to_string()],
|
||||
allow_local_binding: false,
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let mut network = NetworkProxySettings::default();
|
||||
network.set_allowed_domains(vec!["does-not-resolve.invalid".to_string()]);
|
||||
let state = network_proxy_state_for_policy(network);
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
@@ -1273,10 +1257,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
allowed_domains: vec!["example.com".to_string(), "evil.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
network: {
|
||||
let mut network = network_settings(&["example.com", "evil.com"], &[]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1292,10 +1276,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
allowed_domains: vec!["example.com".to_string(), "api.openai.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
network: {
|
||||
let mut network = network_settings(&["example.com", "api.openai.com"], &[]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1328,10 +1312,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
allowed_domains: vec!["api.example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
network: {
|
||||
let mut network = network_settings(&["api.example.com"], &[]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1346,10 +1330,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
allowed_domains: vec!["**.example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
network: {
|
||||
let mut network = network_settings(&["**.example.com"], &[]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1364,10 +1348,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
allowed_domains: vec!["api.example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
network: {
|
||||
let mut network = network_settings(&["api.example.com"], &[]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1383,10 +1367,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
allowed_domains: vec!["api.example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
network: {
|
||||
let mut network = network_settings(&["api.example.com"], &[]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1402,10 +1386,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
allowed_domains: vec!["api.example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
network: {
|
||||
let mut network = network_settings(&["api.example.com"], &[]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1422,7 +1406,6 @@ mod tests {
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
denied_domains: vec![],
|
||||
..NetworkProxySettings::default()
|
||||
},
|
||||
};
|
||||
@@ -1439,10 +1422,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
denied_domains: vec!["evil.com".to_string(), "more-evil.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
network: {
|
||||
let mut network = network_settings(&[], &["evil.com", "more-evil.com"]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1626,10 +1609,10 @@ mod tests {
|
||||
#[test]
|
||||
fn build_config_state_allows_global_wildcard_allowed_domains() {
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
allowed_domains: vec!["*".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
network: {
|
||||
let mut network = network_settings(&["*"], &[]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1639,10 +1622,10 @@ mod tests {
|
||||
#[test]
|
||||
fn build_config_state_allows_bracketed_global_wildcard_allowed_domains() {
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
allowed_domains: vec!["[*]".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
network: {
|
||||
let mut network = network_settings(&["[*]"], &[]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1652,11 +1635,10 @@ mod tests {
|
||||
#[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()
|
||||
network: {
|
||||
let mut network = network_settings(&["example.com"], &["*"]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1666,11 +1648,10 @@ mod tests {
|
||||
#[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()
|
||||
network: {
|
||||
let mut network = network_settings(&["example.com"], &["[*]"]);
|
||||
network.enabled = true;
|
||||
network
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1681,11 +1662,11 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn unix_socket_allowlist_is_respected_on_macos() {
|
||||
let socket_path = "/tmp/example.sock".to_string();
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
allow_unix_sockets: vec![socket_path.clone()],
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings_with_unix_sockets(
|
||||
&["example.com"],
|
||||
&[],
|
||||
std::slice::from_ref(&socket_path),
|
||||
));
|
||||
|
||||
assert!(state.is_unix_socket_allowed(&socket_path).await.unwrap());
|
||||
assert!(
|
||||
@@ -1716,11 +1697,11 @@ mod tests {
|
||||
let real_s = real.to_str().unwrap().to_string();
|
||||
let link_s = link.to_str().unwrap().to_string();
|
||||
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
allow_unix_sockets: vec![real_s],
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let state = network_proxy_state_for_policy(network_settings_with_unix_sockets(
|
||||
&["example.com"],
|
||||
&[],
|
||||
std::slice::from_ref(&real_s),
|
||||
));
|
||||
|
||||
assert!(state.is_unix_socket_allowed(&link_s).await.unwrap());
|
||||
}
|
||||
@@ -1728,10 +1709,10 @@ mod tests {
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn unix_socket_allow_all_flag_bypasses_allowlist() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
dangerously_allow_all_unix_sockets: true,
|
||||
..NetworkProxySettings::default()
|
||||
let state = network_proxy_state_for_policy({
|
||||
let mut network = network_settings(&["example.com"], &[]);
|
||||
network.dangerously_allow_all_unix_sockets = true;
|
||||
network
|
||||
});
|
||||
|
||||
assert!(state.is_unix_socket_allowed("/tmp/any.sock").await.unwrap());
|
||||
@@ -1742,11 +1723,14 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn unix_socket_allowlist_is_rejected_on_non_macos() {
|
||||
let socket_path = "/tmp/example.sock".to_string();
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
allow_unix_sockets: vec![socket_path.clone()],
|
||||
dangerously_allow_all_unix_sockets: true,
|
||||
..NetworkProxySettings::default()
|
||||
let state = network_proxy_state_for_policy({
|
||||
let mut network = network_settings_with_unix_sockets(
|
||||
&["example.com"],
|
||||
&[],
|
||||
std::slice::from_ref(&socket_path),
|
||||
);
|
||||
network.dangerously_allow_all_unix_sockets = true;
|
||||
network
|
||||
});
|
||||
|
||||
assert!(!state.is_unix_socket_allowed(&socket_path).await.unwrap());
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::config::NetworkDomainPermissions;
|
||||
use crate::config::NetworkMode;
|
||||
use crate::config::NetworkProxyConfig;
|
||||
use crate::config::NetworkUnixSocketPermissions;
|
||||
use crate::mitm::MitmState;
|
||||
use crate::policy::DomainPattern;
|
||||
use crate::policy::compile_allowlist_globset;
|
||||
@@ -46,12 +48,9 @@ pub struct PartialNetworkConfig {
|
||||
pub dangerously_allow_non_loopback_proxy: Option<bool>,
|
||||
pub dangerously_allow_all_unix_sockets: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub denied_domains: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub allow_unix_sockets: Option<Vec<String>>,
|
||||
pub domains: Option<NetworkDomainPermissions>,
|
||||
#[serde(default)]
|
||||
pub unix_sockets: Option<NetworkUnixSocketPermissions>,
|
||||
pub allow_local_binding: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -60,10 +59,12 @@ pub fn build_config_state(
|
||||
constraints: NetworkProxyConstraints,
|
||||
) -> anyhow::Result<ConfigState> {
|
||||
crate::config::validate_unix_socket_allowlist_paths(&config)?;
|
||||
validate_denylist_domain_patterns("network.denied_domains", &config.network.denied_domains)
|
||||
let allowed_domains = config.network.allowed_domains().unwrap_or_default();
|
||||
let denied_domains = config.network.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(&config.network.denied_domains)?;
|
||||
let allow_set = compile_allowlist_globset(&config.network.allowed_domains)?;
|
||||
let deny_set = compile_denylist_globset(&denied_domains)?;
|
||||
let allow_set = compile_allowlist_globset(&allowed_domains)?;
|
||||
let mitm = if config.network.mitm {
|
||||
Some(Arc::new(MitmState::new(
|
||||
config.network.allow_upstream_proxy,
|
||||
@@ -106,7 +107,14 @@ pub fn validate_policy_against_constraints(
|
||||
}
|
||||
|
||||
let enabled = config.network.enabled;
|
||||
validate_denylist_domain_patterns("network.denied_domains", &config.network.denied_domains)?;
|
||||
let config_allowed_domains = config.network.allowed_domains().unwrap_or_default();
|
||||
let config_denied_domains = config.network.denied_domains().unwrap_or_default();
|
||||
let denied_domain_overrides: HashSet<String> = config_denied_domains
|
||||
.iter()
|
||||
.map(|entry| entry.to_ascii_lowercase())
|
||||
.collect();
|
||||
let config_allow_unix_sockets = config.network.allow_unix_sockets();
|
||||
validate_non_global_wildcard_domain_patterns("network.denied_domains", &config_denied_domains)?;
|
||||
if let Some(max_enabled) = constraints.enabled {
|
||||
validate(enabled, move |candidate| {
|
||||
if *candidate && !max_enabled {
|
||||
@@ -206,20 +214,24 @@ pub fn validate_policy_against_constraints(
|
||||
}
|
||||
|
||||
if let Some(allowed_domains) = &constraints.allowed_domains {
|
||||
validate_non_global_wildcard_domain_patterns("network.allowed_domains", allowed_domains)?;
|
||||
match constraints.allowlist_expansion_enabled {
|
||||
Some(true) => {
|
||||
let required_set: HashSet<String> = allowed_domains
|
||||
.iter()
|
||||
.map(|entry| entry.to_ascii_lowercase())
|
||||
.collect();
|
||||
validate(config.network.allowed_domains.clone(), move |candidate| {
|
||||
validate(config_allowed_domains, |candidate| {
|
||||
let candidate_set: HashSet<String> = candidate
|
||||
.iter()
|
||||
.map(|entry| entry.to_ascii_lowercase())
|
||||
.collect();
|
||||
let missing: Vec<String> = required_set
|
||||
.iter()
|
||||
.filter(|entry| !candidate_set.contains(*entry))
|
||||
.filter(|entry| {
|
||||
!candidate_set.contains(*entry)
|
||||
&& !denied_domain_overrides.contains(*entry)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
@@ -238,12 +250,16 @@ pub fn validate_policy_against_constraints(
|
||||
.iter()
|
||||
.map(|entry| entry.to_ascii_lowercase())
|
||||
.collect();
|
||||
validate(config.network.allowed_domains.clone(), move |candidate| {
|
||||
validate(config_allowed_domains, |candidate| {
|
||||
let candidate_set: HashSet<String> = candidate
|
||||
.iter()
|
||||
.map(|entry| entry.to_ascii_lowercase())
|
||||
.collect();
|
||||
if candidate_set == required_set {
|
||||
let expected_set: HashSet<String> = required_set
|
||||
.difference(&denied_domain_overrides)
|
||||
.cloned()
|
||||
.collect();
|
||||
if candidate_set == expected_set {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid_value(
|
||||
@@ -259,7 +275,7 @@ pub fn validate_policy_against_constraints(
|
||||
.iter()
|
||||
.map(|entry| DomainPattern::parse_for_constraints(entry))
|
||||
.collect();
|
||||
validate(config.network.allowed_domains.clone(), move |candidate| {
|
||||
validate(config_allowed_domains, move |candidate| {
|
||||
let mut invalid = Vec::new();
|
||||
for entry in candidate {
|
||||
let candidate_pattern = DomainPattern::parse_for_constraints(entry);
|
||||
@@ -285,14 +301,14 @@ pub fn validate_policy_against_constraints(
|
||||
}
|
||||
|
||||
if let Some(denied_domains) = &constraints.denied_domains {
|
||||
validate_denylist_domain_patterns("network.denied_domains", denied_domains)?;
|
||||
validate_non_global_wildcard_domain_patterns("network.denied_domains", denied_domains)?;
|
||||
let required_set: HashSet<String> = denied_domains
|
||||
.iter()
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.collect();
|
||||
match constraints.denylist_expansion_enabled {
|
||||
Some(false) => {
|
||||
validate(config.network.denied_domains.clone(), move |candidate| {
|
||||
validate(config_denied_domains, move |candidate| {
|
||||
let candidate_set: HashSet<String> = candidate
|
||||
.iter()
|
||||
.map(|entry| entry.to_ascii_lowercase())
|
||||
@@ -309,7 +325,7 @@ pub fn validate_policy_against_constraints(
|
||||
})?;
|
||||
}
|
||||
Some(true) | None => {
|
||||
validate(config.network.denied_domains.clone(), move |candidate| {
|
||||
validate(config_denied_domains, move |candidate| {
|
||||
let candidate_set: HashSet<String> =
|
||||
candidate.iter().map(|s| s.to_ascii_lowercase()).collect();
|
||||
let missing: Vec<String> = required_set
|
||||
@@ -336,32 +352,29 @@ pub fn validate_policy_against_constraints(
|
||||
.iter()
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.collect();
|
||||
validate(
|
||||
config.network.allow_unix_sockets.clone(),
|
||||
move |candidate| {
|
||||
let mut invalid = Vec::new();
|
||||
for entry in candidate {
|
||||
if !allowed_set.contains(&entry.to_ascii_lowercase()) {
|
||||
invalid.push(entry.clone());
|
||||
}
|
||||
validate(config_allow_unix_sockets, move |candidate| {
|
||||
let mut invalid = Vec::new();
|
||||
for entry in candidate {
|
||||
if !allowed_set.contains(&entry.to_ascii_lowercase()) {
|
||||
invalid.push(entry.clone());
|
||||
}
|
||||
if invalid.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid_value(
|
||||
"network.allow_unix_sockets",
|
||||
format!("{invalid:?}"),
|
||||
"subset of managed allow_unix_sockets",
|
||||
))
|
||||
}
|
||||
},
|
||||
)?;
|
||||
}
|
||||
if invalid.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid_value(
|
||||
"network.allow_unix_sockets",
|
||||
format!("{invalid:?}"),
|
||||
"subset of managed allow_unix_sockets",
|
||||
))
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_denylist_domain_patterns(
|
||||
fn validate_non_global_wildcard_domain_patterns(
|
||||
field_name: &'static str,
|
||||
patterns: &[String],
|
||||
) -> Result<(), NetworkProxyConstraintError> {
|
||||
@@ -401,3 +414,6 @@ fn network_mode_rank(mode: NetworkMode) -> u8 {
|
||||
NetworkMode::Full => 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {}
|
||||
|
||||
Reference in New Issue
Block a user