diff --git a/crates/openshell-policy/src/ambiguity.rs b/crates/openshell-policy/src/ambiguity.rs index efb846a9a1..4dfcc1fe0b 100644 --- a/crates/openshell-policy/src/ambiguity.rs +++ b/crates/openshell-policy/src/ambiguity.rs @@ -89,6 +89,13 @@ pub fn find_endpoint_ambiguities(policy: &SandboxPolicy) -> Vec NetworkEndpoint { + NetworkEndpoint { + host: String::new(), + port: 443, + allowed_ips: vec!["10.10.0.0/24".to_string()], + protocol: protocol.to_string(), + enforcement: NetworkEnforcementMode::Enforce as i32, + ..Default::default() + } + } + + #[test] + fn named_and_hostless_endpoints_are_different_destination_classes() { + // A hostless allowed_ips endpoint authorizes raw-IP connections in + // its range; a named endpoint authorizes requests to its hostname. + // They never compete for the same request's config, so the pair + // mirrors plain_l4_endpoint_does_not_compete_with_l7_endpoint_metadata. + assert!(find_endpoint_ambiguities(&policy_with( + hostless_cidr("rest"), + endpoint("example.com", 443) + )) + .is_empty()); + } + + #[test] + fn named_endpoint_metadata_conflicts_do_not_spill_into_hostless_pairs() { + let mut tls_skip = hostless_cidr("rest"); + tls_skip.tls = NetworkTlsMode::Skip as i32; + let mut named_tcp = endpoint("example.com", 443); + named_tcp.protocol = "tcp".to_string(); + named_tcp.tls = NetworkTlsMode::Skip as i32; + + assert!(find_endpoint_ambiguities(&policy_with(tls_skip, named_tcp)).is_empty()); + } + + #[test] + fn hostless_endpoints_still_compete_on_raw_ip_connections() { + // Two hostless endpoints authorize the SAME raw-IP connection class, + // so differing allowed_ips remain ambiguous. + let mut other_range = hostless_cidr("rest"); + other_range.allowed_ips = vec!["192.168.0.0/16".to_string()]; + + let ambiguities = find_endpoint_ambiguities(&policy_with(hostless_cidr("rest"), other_range)); + assert_eq!(ambiguities.len(), 1); + assert!( + ambiguities[0] + .conflicts + .iter() + .any(|field| field.contains("allowed_ips")) + ); + } + #[test] fn explicit_tcp_and_omitted_protocol_are_ambiguous_for_native_tcp_eligibility() { let mut explicit_tcp = endpoint("api.example.com", 443); diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index 3c98c7c2e9..969dd6fa0b 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -124,15 +124,44 @@ endpoint_allowed(policy, network) if { endpoint.ports[_] == network.port } -# Endpoint matching: hostless with allowed_ips — match any host on port. -# When an endpoint has allowed_ips but no host, it matches any hostname on the -# given port. The actual IP validation happens in Rust post-DNS-resolution. +# Endpoint matching: hostless with allowed_ips — raw-IP destinations only. +# The allowed_ips list is the destination selector for these endpoints, so +# they authorize connections addressed by IP literal on the given port. +# DNS-mediated hostname connections are governed by their named endpoints; +# the range check itself still happens in Rust after the pin. Raw-IPv6 +# destinations carry colons; a hostname always carries letters, so the +# literal-shaped check cannot match a mediated name. endpoint_allowed(policy, network) if { some endpoint endpoint := policy.endpoints[_] object.get(endpoint, "host", "") == "" count(object.get(endpoint, "allowed_ips", [])) > 0 endpoint.ports[_] == network.port + raw_ip_destination(network.host) +} + +raw_ip_destination(host) if { + parts := split(host, ".") + count(parts) == 4 + every part in parts { + ipv4_label(part) + } +} + +raw_ip_destination(host) if { + contains(host, ":") +} + +ipv4_label(s) if { + glob.match("[0-9]", [], s) +} + +ipv4_label(s) if { + glob.match("[0-9][0-9]", [], s) +} + +ipv4_label(s) if { + glob.match("[0-9][0-9][0-9]", [], s) } # Binary matching can be relaxed by trusted runtime configuration. In that @@ -1067,11 +1096,15 @@ endpoint_matches_request(ep, network) if { ep.ports[_] == network.port } -# Hostless endpoint with allowed_ips: match on port only. +# Hostless endpoint with allowed_ips: raw-IP destinations on port only. +# Same destination-class separation as the admission matcher: DNS-mediated +# names are governed by named endpoints, so a hostless CIDR endpoint never +# constrains a hostname's pinned resolutions. endpoint_matches_request(ep, network) if { object.get(ep, "host", "") == "" count(object.get(ep, "allowed_ips", [])) > 0 ep.ports[_] == network.port + raw_ip_destination(network.host) } endpoint_matches_l7_request(ep, network, request) if { diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 2d1a73a949..152ffb7f60 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -9845,21 +9845,36 @@ network_policies: } #[test] - fn allowed_ips_mode3_hostless_allows_any_domain() { + fn allowed_ips_mode3_matches_raw_ip_and_not_dns_names() { let engine = allowed_ips_engine(); - // Any hostname on port 9443 should match the private_network policy - let input = NetworkInput { - host: "anything.example.com".into(), + // Hostless endpoints authorize IP-literal destinations on the port; + // DNS-mediated names are governed by named endpoints instead. + let raw = NetworkInput { + host: "172.16.9.9".into(), port: 9443, binary_path: PathBuf::from("/usr/bin/curl"), binary_sha256: "unused".into(), ancestors: vec![], cmdline_paths: vec![], }; - let decision = engine.evaluate_network(&input).unwrap(); + let decision = engine.evaluate_network(&raw).unwrap(); assert!( decision.allowed, - "Mode 3 (IPs only) should allow any domain on matching port: {}", + "Mode 3 (IPs only) should allow raw-IP destinations on matching port: {}", + decision.reason + ); + let named = NetworkInput { + host: "anything.example.com".into(), + port: 9443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&named).unwrap(); + assert!( + !decision.allowed, + "Mode 3 (IPs only) must not admit DNS-mediated names: {}", decision.reason ); } @@ -9868,7 +9883,7 @@ network_policies: fn allowed_ips_mode3_returns_allowed_ips() { let engine = allowed_ips_engine(); let input = NetworkInput { - host: "anything.example.com".into(), + host: "172.16.9.9".into(), port: 9443, binary_path: PathBuf::from("/usr/bin/curl"), binary_sha256: "unused".into(), @@ -10217,7 +10232,7 @@ network_policies: let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); // Port 80 let input80 = NetworkInput { - host: "anything.internal".into(), + host: "10.1.2.3".into(), port: 80, binary_path: PathBuf::from("/usr/bin/curl"), binary_sha256: "unused".into(), @@ -10232,7 +10247,7 @@ network_policies: ); // Port 443 let input443 = NetworkInput { - host: "anything.internal".into(), + host: "10.1.2.3".into(), port: 443, binary_path: PathBuf::from("/usr/bin/curl"), binary_sha256: "unused".into(),