Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions crates/openshell-policy/src/ambiguity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ pub fn find_endpoint_ambiguities(policy: &SandboxPolicy) -> Vec<EndpointAmbiguit
{
continue;
}
// PROTOTYPE: named vs hostless endpoints authorize different
// destination classes (named requests vs raw-IP connections);
// they never compete for the same request's config. Mirror of
// plain_l4_endpoint_does_not_compete_with_l7_endpoint_metadata.
if left.endpoint.host.is_empty() != right.endpoint.host.is_empty() {
continue;
}
Comment on lines +96 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve ambiguity checks for IP-literal host selectors

For non-TCP proxy endpoints, a nonempty host can still be an IP literal, so an endpoint such as host: 10.10.0.5 is not disjoint from a hostless endpoint on the same port. Both the exact-host and hostless branches of endpoint_matches_request match that raw-IP request, but this new shortcut permits them to disagree on tls, allowed_ips, and L7 metadata. The proxy's query_tls_mode and query_allowed_ips still take the first matching config, so a previously rejected ambiguous policy can now activate and apply the wrong TLS mode or deny an explicitly allowed IP using the other endpoint's CIDR. Preserve the overlap checks for host selectors that can match IP literals.

Useful? React with 👍 / 👎.


let mut conflicts = connection_conflicts(left.endpoint, right.endpoint);
if endpoint_contributes_request_pipeline_metadata(left.endpoint)
Expand Down Expand Up @@ -1055,6 +1062,58 @@ mod tests {
);
}

fn hostless_cidr(protocol: &str) -> 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);
Expand Down
41 changes: 37 additions & 4 deletions crates/openshell-supervisor-network/data/sandbox-policy.rego
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update hostless-policy guidance for raw-IP-only matching

For DNS-named private services, the installable policy-generation skill still recommends an allowed_ips-only endpoint because it permits “any domain on the port” (skills/generate-sandbox-policy/SKILL.md:359), and skills/generate-sandbox-policy/examples.md:646-668 generates exactly that policy. This new guard makes those policies deny hostname requests even when their resolved addresses fall within the CIDR, so following the shipped skill now produces non-working access policies. Update the skill and example, and document the raw-IP-only limitation in the published policy documentation, alongside this behavior change; the AGENTS.md maintenance guidance requires reviewing the affected skills.

AGENTS.md reference: AGENTS.md:L225-L225

Useful? React with 👍 / 👎.

}

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
Expand Down Expand Up @@ -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 {
Expand Down
33 changes: 24 additions & 9 deletions crates/openshell-supervisor-network/src/opa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
Loading