fix: support managed network allowlist controls (#12752)
## Summary - treat `requirements.toml` `allowed_domains` and `denied_domains` as managed network baselines for the proxy - in restricted modes by default, build the effective runtime policy from the managed baseline plus user-configured allowlist and denylist entries, so common hosts can be pre-approved without blocking later user expansion - add `experimental_network.managed_allowed_domains_only = true` to pin the effective allowlist to managed entries, ignore user allowlist additions, and hard-deny non-managed domains without prompting - apply `managed_allowed_domains_only` anywhere managed network enforcement is active, including full access, while continuing to respect denied domains from all sources - add regression coverage for merged-baseline behavior, managed-only behavior, and full-access managed-only enforcement ## Behavior Assuming `requirements.toml` defines both `experimental_network.allowed_domains` and `experimental_network.denied_domains`. ### Default mode - By default, the effective allowlist is `experimental_network.allowed_domains` plus user or persisted allowlist additions. - By default, the effective denylist is `experimental_network.denied_domains` plus user or persisted denylist additions. - Allowlist misses can go through the network approval flow. - Explicit denylist hits and local or private-network blocks are still hard-denied. - When `experimental_network.managed_allowed_domains_only = true`, only managed `allowed_domains` are respected, user allowlist additions are ignored, and non-managed domains are hard-denied without prompting. - Denied domains continue to be respected from all sources. ### Full access - With managed requirements present, the effective allowlist is pinned to `experimental_network.allowed_domains`. - With managed requirements present, the effective denylist is pinned to `experimental_network.denied_domains`. - There is no allowlist-miss approval path in full access. - Explicit denylist hits are hard-denied. - `experimental_network.managed_allowed_domains_only = true` now also applies in full access, so managed-only behavior remains in effect anywhere managed network enforcement is active.
This commit is contained in:
parent
5deaf9409b
commit
25fa974166
9 changed files with 520 additions and 46 deletions
|
|
@ -231,6 +231,7 @@ mod tests {
|
|||
dangerously_allow_non_loopback_proxy: Some(false),
|
||||
dangerously_allow_all_unix_sockets: Some(true),
|
||||
allowed_domains: Some(vec!["api.openai.com".to_string()]),
|
||||
managed_allowed_domains_only: Some(false),
|
||||
denied_domains: Some(vec!["example.com".to_string()]),
|
||||
allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]),
|
||||
allow_local_binding: Some(true),
|
||||
|
|
|
|||
|
|
@ -138,6 +138,9 @@ pub struct NetworkRequirementsToml {
|
|||
pub dangerously_allow_non_loopback_proxy: Option<bool>,
|
||||
pub dangerously_allow_all_unix_sockets: Option<bool>,
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
/// When true, only managed `allowed_domains` are respected while managed
|
||||
/// network enforcement is active. User allowlist entries are ignored.
|
||||
pub managed_allowed_domains_only: Option<bool>,
|
||||
pub denied_domains: Option<Vec<String>>,
|
||||
pub allow_unix_sockets: Option<Vec<String>>,
|
||||
pub allow_local_binding: Option<bool>,
|
||||
|
|
@ -153,6 +156,9 @@ pub struct NetworkConstraints {
|
|||
pub dangerously_allow_non_loopback_proxy: Option<bool>,
|
||||
pub dangerously_allow_all_unix_sockets: Option<bool>,
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
/// When true, only managed `allowed_domains` are respected while managed
|
||||
/// network enforcement is active. User allowlist entries are ignored.
|
||||
pub managed_allowed_domains_only: Option<bool>,
|
||||
pub denied_domains: Option<Vec<String>>,
|
||||
pub allow_unix_sockets: Option<Vec<String>>,
|
||||
pub allow_local_binding: Option<bool>,
|
||||
|
|
@ -168,6 +174,7 @@ impl From<NetworkRequirementsToml> for NetworkConstraints {
|
|||
dangerously_allow_non_loopback_proxy,
|
||||
dangerously_allow_all_unix_sockets,
|
||||
allowed_domains,
|
||||
managed_allowed_domains_only,
|
||||
denied_domains,
|
||||
allow_unix_sockets,
|
||||
allow_local_binding,
|
||||
|
|
@ -180,6 +187,7 @@ impl From<NetworkRequirementsToml> for NetworkConstraints {
|
|||
dangerously_allow_non_loopback_proxy,
|
||||
dangerously_allow_all_unix_sockets,
|
||||
allowed_domains,
|
||||
managed_allowed_domains_only,
|
||||
denied_domains,
|
||||
allow_unix_sockets,
|
||||
allow_local_binding,
|
||||
|
|
@ -1118,6 +1126,7 @@ mod tests {
|
|||
allow_upstream_proxy = false
|
||||
dangerously_allow_all_unix_sockets = true
|
||||
allowed_domains = ["api.example.com", "*.openai.com"]
|
||||
managed_allowed_domains_only = true
|
||||
denied_domains = ["blocked.example.com"]
|
||||
allow_unix_sockets = ["/tmp/example.sock"]
|
||||
allow_local_binding = false
|
||||
|
|
@ -1146,6 +1155,10 @@ mod tests {
|
|||
"*.openai.com".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
sourced_network.value.managed_allowed_domains_only,
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
sourced_network.value.denied_domains.as_ref(),
|
||||
Some(&vec!["blocked.example.com".to_string()])
|
||||
|
|
|
|||
|
|
@ -2308,6 +2308,7 @@ impl Config {
|
|||
let network = NetworkProxySpec::from_config_and_constraints(
|
||||
configured_network_proxy_config,
|
||||
network_requirements,
|
||||
constrained_sandbox_policy.get(),
|
||||
)
|
||||
.map_err(|err| {
|
||||
if let Some(source) = network_requirements_source.as_ref() {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ use std::sync::Arc;
|
|||
pub struct NetworkProxySpec {
|
||||
config: NetworkProxyConfig,
|
||||
constraints: NetworkProxyConstraints,
|
||||
hard_deny_allowlist_misses: bool,
|
||||
}
|
||||
|
||||
pub struct StartedNetworkProxy {
|
||||
|
|
@ -83,9 +84,18 @@ impl NetworkProxySpec {
|
|||
pub(crate) fn from_config_and_constraints(
|
||||
config: NetworkProxyConfig,
|
||||
requirements: Option<NetworkConstraints>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
) -> std::io::Result<Self> {
|
||||
let hard_deny_allowlist_misses = requirements
|
||||
.as_ref()
|
||||
.is_some_and(Self::managed_allowed_domains_only);
|
||||
let (config, constraints) = if let Some(requirements) = requirements {
|
||||
Self::apply_requirements(config, &requirements)
|
||||
Self::apply_requirements(
|
||||
config,
|
||||
&requirements,
|
||||
sandbox_policy,
|
||||
hard_deny_allowlist_misses,
|
||||
)
|
||||
} else {
|
||||
(config, NetworkProxyConstraints::default())
|
||||
};
|
||||
|
|
@ -98,6 +108,7 @@ impl NetworkProxySpec {
|
|||
Ok(Self {
|
||||
config,
|
||||
constraints,
|
||||
hard_deny_allowlist_misses,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +123,7 @@ impl NetworkProxySpec {
|
|||
let state = self.build_state_with_audit_metadata(audit_metadata)?;
|
||||
let mut builder = NetworkProxy::builder().state(Arc::new(state));
|
||||
if enable_network_approval_flow
|
||||
&& !self.hard_deny_allowlist_misses
|
||||
&& matches!(
|
||||
sandbox_policy,
|
||||
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
|
||||
|
|
@ -158,8 +170,13 @@ impl NetworkProxySpec {
|
|||
fn apply_requirements(
|
||||
mut config: NetworkProxyConfig,
|
||||
requirements: &NetworkConstraints,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
hard_deny_allowlist_misses: bool,
|
||||
) -> (NetworkProxyConfig, NetworkProxyConstraints) {
|
||||
let mut constraints = NetworkProxyConstraints::default();
|
||||
let allowlist_expansion_enabled =
|
||||
Self::allowlist_expansion_enabled(sandbox_policy, hard_deny_allowlist_misses);
|
||||
let denylist_expansion_enabled = Self::denylist_expansion_enabled(sandbox_policy);
|
||||
|
||||
if let Some(enabled) = requirements.enabled {
|
||||
config.network.enabled = enabled;
|
||||
|
|
@ -190,13 +207,31 @@ impl NetworkProxySpec {
|
|||
constraints.dangerously_allow_all_unix_sockets =
|
||||
Some(dangerously_allow_all_unix_sockets);
|
||||
}
|
||||
if let Some(allowed_domains) = requirements.allowed_domains.clone() {
|
||||
config.network.allowed_domains = allowed_domains.clone();
|
||||
let managed_allowed_domains = if hard_deny_allowlist_misses {
|
||||
Some(requirements.allowed_domains.clone().unwrap_or_default())
|
||||
} else {
|
||||
requirements.allowed_domains.clone()
|
||||
};
|
||||
if let Some(allowed_domains) = managed_allowed_domains {
|
||||
// Managed requirements seed the baseline allowlist. User additions
|
||||
// can extend that baseline unless managed-only mode pins the
|
||||
// effective allowlist to the managed set.
|
||||
config.network.allowed_domains = if allowlist_expansion_enabled {
|
||||
Self::merge_domain_lists(allowed_domains.clone(), &config.network.allowed_domains)
|
||||
} else {
|
||||
allowed_domains.clone()
|
||||
};
|
||||
constraints.allowed_domains = Some(allowed_domains);
|
||||
constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled);
|
||||
}
|
||||
if let Some(denied_domains) = requirements.denied_domains.clone() {
|
||||
config.network.denied_domains = denied_domains.clone();
|
||||
config.network.denied_domains = if denylist_expansion_enabled {
|
||||
Self::merge_domain_lists(denied_domains.clone(), &config.network.denied_domains)
|
||||
} else {
|
||||
denied_domains.clone()
|
||||
};
|
||||
constraints.denied_domains = Some(denied_domains);
|
||||
constraints.denylist_expansion_enabled = Some(denylist_expansion_enabled);
|
||||
}
|
||||
if let Some(allow_unix_sockets) = requirements.allow_unix_sockets.clone() {
|
||||
config.network.allow_unix_sockets = allow_unix_sockets.clone();
|
||||
|
|
@ -209,6 +244,39 @@ impl NetworkProxySpec {
|
|||
|
||||
(config, constraints)
|
||||
}
|
||||
|
||||
fn allowlist_expansion_enabled(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
hard_deny_allowlist_misses: bool,
|
||||
) -> bool {
|
||||
matches!(
|
||||
sandbox_policy,
|
||||
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
|
||||
) && !hard_deny_allowlist_misses
|
||||
}
|
||||
|
||||
fn managed_allowed_domains_only(requirements: &NetworkConstraints) -> bool {
|
||||
requirements.managed_allowed_domains_only.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn denylist_expansion_enabled(sandbox_policy: &SandboxPolicy) -> bool {
|
||||
matches!(
|
||||
sandbox_policy,
|
||||
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_domain_lists(mut managed: Vec<String>, user_entries: &[String]) -> Vec<String> {
|
||||
for entry in user_entries {
|
||||
if !managed
|
||||
.iter()
|
||||
.any(|managed_entry| managed_entry.eq_ignore_ascii_case(entry))
|
||||
{
|
||||
managed.push(entry.clone());
|
||||
}
|
||||
}
|
||||
managed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -221,6 +289,7 @@ mod tests {
|
|||
let spec = NetworkProxySpec {
|
||||
config: NetworkProxyConfig::default(),
|
||||
constraints: NetworkProxyConstraints::default(),
|
||||
hard_deny_allowlist_misses: false,
|
||||
};
|
||||
let metadata = NetworkProxyAuditMetadata {
|
||||
conversation_id: Some("conversation-1".to_string()),
|
||||
|
|
@ -234,4 +303,184 @@ mod tests {
|
|||
.expect("state should build");
|
||||
assert_eq!(state.audit_metadata(), &metadata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
config.network.allowed_domains = vec!["api.example.com".to_string()];
|
||||
let requirements = NetworkConstraints {
|
||||
allowed_domains: Some(vec!["*.example.com".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
)
|
||||
.expect("config should stay within the managed allowlist");
|
||||
|
||||
assert_eq!(
|
||||
spec.config.network.allowed_domains,
|
||||
vec!["*.example.com".to_string(), "api.example.com".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
spec.constraints.allowed_domains,
|
||||
Some(vec!["*.example.com".to_string()])
|
||||
);
|
||||
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
config.network.allowed_domains = vec!["evil.com".to_string()];
|
||||
config.network.denied_domains = vec!["more-blocked.example.com".to_string()];
|
||||
let requirements = NetworkConstraints {
|
||||
allowed_domains: Some(vec!["*.example.com".to_string()]),
|
||||
denied_domains: Some(vec!["blocked.example.com".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.expect("yolo mode should pin the effective policy to the managed baseline");
|
||||
|
||||
assert_eq!(
|
||||
spec.config.network.allowed_domains,
|
||||
vec!["*.example.com".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
spec.config.network.denied_domains,
|
||||
vec!["blocked.example.com".to_string()]
|
||||
);
|
||||
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
|
||||
assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
config.network.allowed_domains = vec!["api.example.com".to_string()];
|
||||
let requirements = NetworkConstraints {
|
||||
allowed_domains: Some(vec!["*.example.com".to_string()]),
|
||||
managed_allowed_domains_only: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&SandboxPolicy::new_workspace_write_policy(),
|
||||
)
|
||||
.expect("managed baseline should still load");
|
||||
|
||||
assert_eq!(
|
||||
spec.config.network.allowed_domains,
|
||||
vec!["*.example.com".to_string()]
|
||||
);
|
||||
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
config.network.allowed_domains = vec!["api.example.com".to_string()];
|
||||
let requirements = NetworkConstraints {
|
||||
allowed_domains: Some(vec!["managed.example.com".to_string()]),
|
||||
managed_allowed_domains_only: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&SandboxPolicy::new_workspace_write_policy(),
|
||||
)
|
||||
.expect("managed-only allowlist should still load");
|
||||
|
||||
assert_eq!(
|
||||
spec.config.network.allowed_domains,
|
||||
vec!["managed.example.com".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
spec.constraints.allowed_domains,
|
||||
Some(vec!["managed.example.com".to_string()])
|
||||
);
|
||||
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
|
||||
assert!(spec.hard_deny_allowlist_misses);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domains() {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
config.network.allowed_domains = vec!["api.example.com".to_string()];
|
||||
let requirements = NetworkConstraints {
|
||||
managed_allowed_domains_only: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&SandboxPolicy::new_workspace_write_policy(),
|
||||
)
|
||||
.expect("managed-only mode should treat missing managed allowlist as empty");
|
||||
|
||||
assert!(spec.config.network.allowed_domains.is_empty());
|
||||
assert_eq!(spec.constraints.allowed_domains, Some(Vec::new()));
|
||||
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
|
||||
assert!(spec.hard_deny_allowlist_misses);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_managed_list() {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
config.network.allowed_domains = vec!["api.example.com".to_string()];
|
||||
let requirements = NetworkConstraints {
|
||||
managed_allowed_domains_only: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.expect("managed-only mode should treat missing managed allowlist as empty");
|
||||
|
||||
assert!(spec.config.network.allowed_domains.is_empty());
|
||||
assert_eq!(spec.constraints.allowed_domains, Some(Vec::new()));
|
||||
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
|
||||
assert!(spec.hard_deny_allowlist_misses);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requirements_denied_domains_are_a_baseline_for_default_mode() {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
config.network.denied_domains = vec!["blocked.example.com".to_string()];
|
||||
let requirements = NetworkConstraints {
|
||||
denied_domains: Some(vec!["managed-blocked.example.com".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&SandboxPolicy::new_workspace_write_policy(),
|
||||
)
|
||||
.expect("default mode should merge managed and user deny entries");
|
||||
|
||||
assert_eq!(
|
||||
spec.config.network.denied_domains,
|
||||
vec![
|
||||
"managed-blocked.example.com".to_string(),
|
||||
"blocked.example.com".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ denied_domains = ["evil.example"]
|
|||
# If false, local/private networking is rejected. Explicit allowlisting of local IP literals
|
||||
# (or `localhost`) is required to permit them.
|
||||
# Hostnames that resolve to local/private IPs are still blocked even if allowlisted.
|
||||
allow_local_binding = true
|
||||
allow_local_binding = false
|
||||
|
||||
# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`.
|
||||
allow_unix_sockets = ["/tmp/example.sock"]
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ impl Default for NetworkProxySettings {
|
|||
allowed_domains: Vec::new(),
|
||||
denied_domains: Vec::new(),
|
||||
allow_unix_sockets: Vec::new(),
|
||||
allow_local_binding: true,
|
||||
allow_local_binding: false,
|
||||
mitm: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -374,7 +374,7 @@ mod tests {
|
|||
allowed_domains: Vec::new(),
|
||||
denied_domains: Vec::new(),
|
||||
allow_unix_sockets: Vec::new(),
|
||||
allow_local_binding: true,
|
||||
allow_local_binding: false,
|
||||
mitm: false,
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -894,6 +894,98 @@ 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()
|
||||
},
|
||||
};
|
||||
let constraints = NetworkProxyConstraints {
|
||||
allowed_domains: Some(vec!["managed.example.com".to_string()]),
|
||||
allowlist_expansion_enabled: Some(true),
|
||||
..NetworkProxyConstraints::default()
|
||||
};
|
||||
let state = NetworkProxyState::with_reloader(
|
||||
build_config_state(config, constraints).unwrap(),
|
||||
Arc::new(NoopReloader),
|
||||
);
|
||||
|
||||
state.add_allowed_domain("user.example.com").await.unwrap();
|
||||
|
||||
let (allowed, denied) = state.current_patterns().await.unwrap();
|
||||
assert_eq!(
|
||||
allowed,
|
||||
vec![
|
||||
"managed.example.com".to_string(),
|
||||
"user.example.com".to_string()
|
||||
]
|
||||
);
|
||||
assert!(denied.is_empty());
|
||||
}
|
||||
|
||||
#[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()
|
||||
},
|
||||
};
|
||||
let constraints = NetworkProxyConstraints {
|
||||
allowed_domains: Some(vec!["managed.example.com".to_string()]),
|
||||
allowlist_expansion_enabled: Some(false),
|
||||
..NetworkProxyConstraints::default()
|
||||
};
|
||||
let state = NetworkProxyState::with_reloader(
|
||||
build_config_state(config, constraints).unwrap(),
|
||||
Arc::new(NoopReloader),
|
||||
);
|
||||
|
||||
let err = state
|
||||
.add_allowed_domain("user.example.com")
|
||||
.await
|
||||
.expect_err("managed baseline should reject allowlist expansion");
|
||||
|
||||
assert!(
|
||||
format!("{err:#}").contains("network.allowed_domains constrained by managed config"),
|
||||
"unexpected error: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[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()
|
||||
},
|
||||
};
|
||||
let constraints = NetworkProxyConstraints {
|
||||
denied_domains: Some(vec!["managed.example.com".to_string()]),
|
||||
denylist_expansion_enabled: Some(false),
|
||||
..NetworkProxyConstraints::default()
|
||||
};
|
||||
let state = NetworkProxyState::with_reloader(
|
||||
build_config_state(config, constraints).unwrap(),
|
||||
Arc::new(NoopReloader),
|
||||
);
|
||||
|
||||
let err = state
|
||||
.add_denied_domain("user.example.com")
|
||||
.await
|
||||
.expect_err("managed baseline should reject denylist expansion");
|
||||
|
||||
assert!(
|
||||
format!("{err:#}").contains("network.denied_domains constrained by managed config"),
|
||||
"unexpected error: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocked_snapshot_does_not_consume_entries() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
||||
|
|
@ -1117,6 +1209,25 @@ mod tests {
|
|||
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_policy_against_constraints_allows_expanding_allowed_domains_when_enabled() {
|
||||
let constraints = NetworkProxyConstraints {
|
||||
allowed_domains: Some(vec!["example.com".to_string()]),
|
||||
allowlist_expansion_enabled: Some(true),
|
||||
..NetworkProxyConstraints::default()
|
||||
};
|
||||
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
allowed_domains: vec!["example.com".to_string(), "api.openai.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
},
|
||||
};
|
||||
|
||||
assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_policy_against_constraints_disallows_widening_mode() {
|
||||
let constraints = NetworkProxyConstraints {
|
||||
|
|
@ -1245,6 +1356,25 @@ mod tests {
|
|||
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_policy_against_constraints_disallows_expanding_denied_domains_when_fixed() {
|
||||
let constraints = NetworkProxyConstraints {
|
||||
denied_domains: Some(vec!["evil.com".to_string()]),
|
||||
denylist_expansion_enabled: Some(false),
|
||||
..NetworkProxyConstraints::default()
|
||||
};
|
||||
|
||||
let config = NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
enabled: true,
|
||||
denied_domains: vec!["evil.com".to_string(), "more-evil.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
},
|
||||
};
|
||||
|
||||
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_policy_against_constraints_disallows_enabling_when_managed_disabled() {
|
||||
let constraints = NetworkProxyConstraints {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ pub struct NetworkProxyConstraints {
|
|||
pub dangerously_allow_non_loopback_proxy: Option<bool>,
|
||||
pub dangerously_allow_all_unix_sockets: Option<bool>,
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
pub allowlist_expansion_enabled: Option<bool>,
|
||||
pub denied_domains: Option<Vec<String>>,
|
||||
pub denylist_expansion_enabled: Option<bool>,
|
||||
pub allow_unix_sockets: Option<Vec<String>>,
|
||||
pub allow_local_binding: Option<bool>,
|
||||
}
|
||||
|
|
@ -207,31 +209,82 @@ pub fn validate_policy_against_constraints(
|
|||
|
||||
if let Some(allowed_domains) = &constraints.allowed_domains {
|
||||
validate_domain_patterns("network.allowed_domains", allowed_domains)?;
|
||||
let managed_patterns: Vec<DomainPattern> = allowed_domains
|
||||
.iter()
|
||||
.map(|entry| DomainPattern::parse_for_constraints(entry))
|
||||
.collect();
|
||||
validate(config.network.allowed_domains.clone(), move |candidate| {
|
||||
let mut invalid = Vec::new();
|
||||
for entry in candidate {
|
||||
let candidate_pattern = DomainPattern::parse_for_constraints(entry);
|
||||
if !managed_patterns
|
||||
match constraints.allowlist_expansion_enabled {
|
||||
Some(true) => {
|
||||
let required_set: HashSet<String> = allowed_domains
|
||||
.iter()
|
||||
.any(|managed| managed.allows(&candidate_pattern))
|
||||
{
|
||||
invalid.push(entry.clone());
|
||||
}
|
||||
.map(|entry| entry.to_ascii_lowercase())
|
||||
.collect();
|
||||
validate(config.network.allowed_domains.clone(), move |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))
|
||||
.cloned()
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid_value(
|
||||
"network.allowed_domains",
|
||||
"missing managed allowed_domains entries",
|
||||
format!("{missing:?}"),
|
||||
))
|
||||
}
|
||||
})?;
|
||||
}
|
||||
if invalid.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid_value(
|
||||
"network.allowed_domains",
|
||||
format!("{invalid:?}"),
|
||||
"subset of managed allowed_domains",
|
||||
))
|
||||
Some(false) => {
|
||||
let required_set: HashSet<String> = allowed_domains
|
||||
.iter()
|
||||
.map(|entry| entry.to_ascii_lowercase())
|
||||
.collect();
|
||||
validate(config.network.allowed_domains.clone(), move |candidate| {
|
||||
let candidate_set: HashSet<String> = candidate
|
||||
.iter()
|
||||
.map(|entry| entry.to_ascii_lowercase())
|
||||
.collect();
|
||||
if candidate_set == required_set {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid_value(
|
||||
"network.allowed_domains",
|
||||
format!("{candidate:?}"),
|
||||
"must match managed allowed_domains",
|
||||
))
|
||||
}
|
||||
})?;
|
||||
}
|
||||
})?;
|
||||
None => {
|
||||
let managed_patterns: Vec<DomainPattern> = allowed_domains
|
||||
.iter()
|
||||
.map(|entry| DomainPattern::parse_for_constraints(entry))
|
||||
.collect();
|
||||
validate(config.network.allowed_domains.clone(), move |candidate| {
|
||||
let mut invalid = Vec::new();
|
||||
for entry in candidate {
|
||||
let candidate_pattern = DomainPattern::parse_for_constraints(entry);
|
||||
if !managed_patterns
|
||||
.iter()
|
||||
.any(|managed| managed.allows(&candidate_pattern))
|
||||
{
|
||||
invalid.push(entry.clone());
|
||||
}
|
||||
}
|
||||
if invalid.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid_value(
|
||||
"network.allowed_domains",
|
||||
format!("{invalid:?}"),
|
||||
"subset of managed allowed_domains",
|
||||
))
|
||||
}
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(denied_domains) = &constraints.denied_domains {
|
||||
|
|
@ -240,24 +293,45 @@ pub fn validate_policy_against_constraints(
|
|||
.iter()
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.collect();
|
||||
validate(config.network.denied_domains.clone(), move |candidate| {
|
||||
let candidate_set: HashSet<String> =
|
||||
candidate.iter().map(|s| s.to_ascii_lowercase()).collect();
|
||||
let missing: Vec<String> = required_set
|
||||
.iter()
|
||||
.filter(|entry| !candidate_set.contains(*entry))
|
||||
.cloned()
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid_value(
|
||||
"network.denied_domains",
|
||||
"missing managed denied_domains entries",
|
||||
format!("{missing:?}"),
|
||||
))
|
||||
match constraints.denylist_expansion_enabled {
|
||||
Some(false) => {
|
||||
validate(config.network.denied_domains.clone(), move |candidate| {
|
||||
let candidate_set: HashSet<String> = candidate
|
||||
.iter()
|
||||
.map(|entry| entry.to_ascii_lowercase())
|
||||
.collect();
|
||||
if candidate_set == required_set {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid_value(
|
||||
"network.denied_domains",
|
||||
format!("{candidate:?}"),
|
||||
"must match managed denied_domains",
|
||||
))
|
||||
}
|
||||
})?;
|
||||
}
|
||||
})?;
|
||||
Some(true) | None => {
|
||||
validate(config.network.denied_domains.clone(), move |candidate| {
|
||||
let candidate_set: HashSet<String> =
|
||||
candidate.iter().map(|s| s.to_ascii_lowercase()).collect();
|
||||
let missing: Vec<String> = required_set
|
||||
.iter()
|
||||
.filter(|entry| !candidate_set.contains(*entry))
|
||||
.cloned()
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid_value(
|
||||
"network.denied_domains",
|
||||
"missing managed denied_domains entries",
|
||||
format!("{missing:?}"),
|
||||
))
|
||||
}
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets {
|
||||
|
|
|
|||
|
|
@ -331,6 +331,7 @@ fn format_network_constraints(network: &NetworkConstraints) -> String {
|
|||
dangerously_allow_non_loopback_proxy,
|
||||
dangerously_allow_all_unix_sockets,
|
||||
allowed_domains,
|
||||
managed_allowed_domains_only,
|
||||
denied_domains,
|
||||
allow_unix_sockets,
|
||||
allow_local_binding,
|
||||
|
|
@ -361,6 +362,11 @@ fn format_network_constraints(network: &NetworkConstraints) -> String {
|
|||
if let Some(allowed_domains) = allowed_domains {
|
||||
parts.push(format!("allowed_domains=[{}]", allowed_domains.join(", ")));
|
||||
}
|
||||
if let Some(managed_allowed_domains_only) = managed_allowed_domains_only {
|
||||
parts.push(format!(
|
||||
"managed_allowed_domains_only={managed_allowed_domains_only}"
|
||||
));
|
||||
}
|
||||
if let Some(denied_domains) = denied_domains {
|
||||
parts.push(format!("denied_domains=[{}]", denied_domains.join(", ")));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue