From ec1f43d3c9e4cd39dd6f47e4a97645ef94a90ef0 Mon Sep 17 00:00:00 2001 From: divesh Date: Sat, 31 Oct 2026 19:47:34 -0700 Subject: [PATCH 1/3] fix(network): normalize IPv4-mapped transparent TCP destinations Signed-off-by: divesh --- .../openshell-supervisor-network/src/proxy.rs | 6 +- .../src/proxy/tests/ipv4_mapped.rs | 145 ++++++++++++++++++ docs/how-it-works/policies/network-rules.mdx | 4 + e2e/rust/tests/transparent_tcp.rs | 6 + 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 crates/openshell-supervisor-network/src/proxy/tests/ipv4_mapped.rs diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 7560a3aeaf..4f5fd127f8 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -638,12 +638,14 @@ async fn preauthorize_transparent_open( let PendingTcpOpen { stream, binary_identity, - destination, + mut destination, socket: _, policy_generation: _, timing, decision: completion, } = connection; + // Use the IPv4 identity for mapped destinations throughout routing and policy. + destination.set_ip(destination.ip().to_canonical()); let _timing = NetworkOpenTimingGuard { timing, operation: "tcp", @@ -7200,6 +7202,8 @@ fn is_benign_relay_error(err: &miette::Report) -> bool { reason = "Test code: test fixtures and explicit control-flow markers are idiomatic in tests." )] mod tests { + mod ipv4_mapped; + #[test] fn proxy_authorization_rejects_missing_wrong_cross_and_duplicate_credentials() { let expected = "Basic sandbox-a-generation"; diff --git a/crates/openshell-supervisor-network/src/proxy/tests/ipv4_mapped.rs b/crates/openshell-supervisor-network/src/proxy/tests/ipv4_mapped.rs new file mode 100644 index 0000000000..81bf372cb7 --- /dev/null +++ b/crates/openshell-supervisor-network/src/proxy/tests/ipv4_mapped.rs @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! IPv4-mapped destinations must retain their IPv4 policy and DNS identity. + +use super::*; + +fn engine_for(host: &str) -> OpaEngine { + native_identity_engine( + include_str!("../../../data/sandbox-policy.rego"), + &policy_with_native_executable_paths(&POLICY_DNS_OPEN_POLICY.replace("db.example", host)), + ) + .unwrap() +} + +async fn probe( + destination: SocketAddr, + store: Option<&Arc>, + engine: &OpaEngine, +) -> ( + Option, + TcpOpenDecision, + Option, +) { + let (open, completion) = staged_curl_open(destination, engine.current_generation()); + let (denial_tx, mut denial_rx) = mpsc::unbounded_channel(); + let accepted = preauthorize_transparent_open( + open, + store, + engine, + &BinaryIdentityCache::new(), + None, + None, + false, + Some(&denial_tx), + None, + ) + .await; + let decision = completion.await.unwrap(); + assert_eq!(accepted.is_some(), decision == TcpOpenDecision::RelayReady); + ( + accepted.and_then(|(_, _, _, transparent)| transparent), + decision, + denial_rx.try_recv().ok(), + ) +} + +#[tokio::test] +async fn allowed_ipv4_endpoint_accepts_mapped_form() { + let engine = engine_for("203.0.113.7"); + let store = policy_dns_test_store(); + for dns in [None, Some(&store)] { + for destination in ["203.0.113.7:5432", "[::ffff:203.0.113.7]:5432"] { + let (transparent, decision, denial) = + probe(destination.parse().unwrap(), dns, &engine).await; + assert_eq!(decision, TcpOpenDecision::RelayReady, "{destination}"); + assert_eq!( + transparent.unwrap().destination, + "203.0.113.7:5432".parse().unwrap() + ); + assert!(denial.is_none()); + } + } +} + +#[tokio::test] +async fn policy_dns_recovers_hostname_for_mapped_form() { + let engine = engine_for("db.example"); + let store = policy_dns_test_store(); + let record = publish_mapping( + &store, + "db.example", + "database", + 5432, + engine.current_generation(), + ); + let IpAddr::V4(ipv4) = record.synthetic_address else { + panic!("test publishes an IPv4 mapping"); + }; + for ip in [IpAddr::V4(ipv4), IpAddr::V6(ipv4.to_ipv6_mapped())] { + let destination = SocketAddr::new(ip, 5432); + let (transparent, decision, denial) = probe(destination, Some(&store), &engine).await; + assert_eq!(decision, TcpOpenDecision::RelayReady, "{destination}"); + assert!(denial.is_none()); + let transparent = transparent.unwrap(); + assert_eq!( + transparent.destination, + SocketAddr::new(IpAddr::V4(ipv4), 5432) + ); + let (decision, connector) = transparent.authorization.unwrap(); + assert_eq!(decision.intent.destination.host, "db.example"); + assert_eq!(connector.addrs(), &["203.0.113.8:5432".parse().unwrap()]); + } +} + +#[tokio::test] +async fn denial_reports_canonical_ipv4_host() { + let engine = engine_for("203.0.113.7"); + let store = policy_dns_test_store(); + let mut hosts = Vec::new(); + for destination in ["203.0.113.8:5432", "[::ffff:203.0.113.8]:5432"] { + let (_, decision, denial) = + probe(destination.parse().unwrap(), Some(&store), &engine).await; + assert_eq!( + decision, + TcpOpenDecision::Denied(TcpOpenDenial::PolicyDenied) + ); + let denial = denial.expect("unapproved endpoint produces a policy proposal"); + assert_eq!(denial.denial_stage, "transparent_tcp_connect"); + hosts.push(denial.host); + } + assert_eq!(hosts, ["203.0.113.8", "203.0.113.8"]); +} + +#[tokio::test] +async fn metadata_stays_blocked_in_both_forms() { + let store = policy_dns_test_store(); + for policy_host in ["169.254.169.254", "::ffff:169.254.169.254"] { + let engine = engine_for(policy_host); + for destination in ["169.254.169.254:5432", "[::ffff:169.254.169.254]:5432"] { + let (_, decision, denial) = + probe(destination.parse().unwrap(), Some(&store), &engine).await; + assert!(matches!(decision, TcpOpenDecision::Denied(_))); + assert!( + denial.is_none(), + "blocked metadata must not become a proposal" + ); + } + } +} + +#[tokio::test] +async fn native_ipv6_preserves_scope_and_flowinfo() { + let engine = engine_for("2001:db8::7"); + let destination = SocketAddr::V6(std::net::SocketAddrV6::new( + "2001:db8::7".parse().unwrap(), + 5432, + 42, + 7, + )); + let (transparent, decision, denial) = probe(destination, None, &engine).await; + assert_eq!(decision, TcpOpenDecision::RelayReady); + assert_eq!(transparent.unwrap().destination, destination); + assert!(denial.is_none()); +} diff --git a/docs/how-it-works/policies/network-rules.mdx b/docs/how-it-works/policies/network-rules.mdx index 811d920922..a66b60a04a 100644 --- a/docs/how-it-works/policies/network-rules.mdx +++ b/docs/how-it-works/policies/network-rules.mdx @@ -89,6 +89,10 @@ OpenShell checks network traffic in two stages: For example, with `protocol: rest`, a rule can allow `GET` requests to an API while blocking `POST` and `DELETE` requests. +Transparent TCP connections to IPv4-mapped IPv6 addresses, such as +`::ffff:203.0.113.7`, use the same destination rules as the equivalent IPv4 +address, `203.0.113.7`. + The second stage is called request inspection, and an endpoint that uses it is an inspected endpoint. The `protocol` field selects what OpenShell inspects: diff --git a/e2e/rust/tests/transparent_tcp.rs b/e2e/rust/tests/transparent_tcp.rs index 1046145118..d3e1c0843b 100644 --- a/e2e/rust/tests/transparent_tcp.rs +++ b/e2e/rust/tests/transparent_tcp.rs @@ -486,6 +486,12 @@ assert any(ip.startswith('198.18.') or ip.startswith('198.19.') for ip in synthe with socket.create_connection(({host:?}, {port}), timeout=10) as conn: conn.sendall(b'probe') assert conn.recv(1024) == b'native-tcp-ok:probe' +mapped = next('::ffff:' + ip for ip in synthetic if ip.startswith(('198.18.', '198.19.'))) +with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as conn: + conn.settimeout(10) + conn.connect((mapped, {port})) + conn.sendall(b'mapped-probe') + assert conn.recv(1024) == b'native-tcp-ok:mapped-probe' with socket.create_connection(({host:?}, {tcp_dns_port}), timeout=10) as conn: conn.sendall(b'tcp-53-probe') assert conn.recv(1024) == b'native-tcp-ok:tcp-53-probe' From fbd1f78d4b6b13bbaef23584cf3bc328a0f879d8 Mon Sep 17 00:00:00 2001 From: divesh Date: Wed, 4 Nov 2026 15:35:07 -0800 Subject: [PATCH 2/3] test(network): cover mapped reserved destinations Signed-off-by: divesh --- .../src/contract.rs | 1 + .../openshell-supervisor-network/src/proxy.rs | 70 ++++++++++--------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index b16dfdd724..de2a01975f 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -583,6 +583,7 @@ pub struct PendingTcpOpen { /// Executable identity, resolved by the backend for this connection. pub binary_identity: Result, /// Original external destination captured by the backend before mediation. + /// The supervisor may canonicalize IPv4-mapped IPv6 addresses before routing. pub destination: SocketAddr, /// Native socket evidence, when the backend mediates a kernel socket. /// Guests without native sockets supply authoritative executable identity diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 4f5fd127f8..771698fadf 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -8882,40 +8882,46 @@ process: #[tokio::test] async fn reserved_destination_is_served_without_policy_evaluation() { - let engine = reserved_test_engine(); - let (reserved, mut served_rx, calls) = reserved_relay(true); - let (open, decision, mut peer) = - staged_open(&engine, openshell_core::sandbox_env::OTLP_RELAY_ADDR); + let addr = *OTLP_RELAY_ADDR; + for destination in [ + addr.to_string(), + format!("[::ffff:{}]:{}", addr.ip(), addr.port()), + ] { + let engine = reserved_test_engine(); + let (reserved, mut served_rx, calls) = reserved_relay(true); + let (open, decision, mut peer) = staged_open(&engine, &destination); - assert!( - preauthorize_transparent_open( - open, - None, - &engine, - &BinaryIdentityCache::new(), - None, - None, - false, - None, - Some(&reserved) - ) - .await - .is_none(), - "a reserved open never becomes a proxy connection" - ); - assert_eq!(decision.await.unwrap(), TcpOpenDecision::RelayReady); - assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!( + preauthorize_transparent_open( + open, + None, + &engine, + &BinaryIdentityCache::new(), + None, + None, + false, + None, + Some(&reserved) + ) + .await + .is_none(), + "a reserved open never becomes a proxy connection" + ); + assert_eq!(decision.await.unwrap(), TcpOpenDecision::RelayReady); + assert_eq!(calls.load(Ordering::SeqCst), 1); - // The handler holds the workload's stream: bytes written by the - // workload side arrive at the handler. - let mut stream = tokio::time::timeout(std::time::Duration::from_secs(1), served_rx.recv()) - .await - .expect("handler future ran") - .expect("handler received the stream"); - peer.write_all(b"POST /v1/traces").await.unwrap(); - let mut head = [0u8; 15]; - stream.read_exact(&mut head).await.unwrap(); - assert_eq!(&head, b"POST /v1/traces"); + // The handler holds the workload's stream: bytes written by the + // workload side arrive at the handler. + let mut stream = + tokio::time::timeout(std::time::Duration::from_secs(1), served_rx.recv()) + .await + .expect("handler future ran") + .expect("handler received the stream"); + peer.write_all(b"POST /v1/traces").await.unwrap(); + let mut head = [0u8; 15]; + stream.read_exact(&mut head).await.unwrap(); + assert_eq!(&head, b"POST /v1/traces"); + } } #[tokio::test] From bd0ec398d63b5ed76704b9b8b6733d0b92e43add Mon Sep 17 00:00:00 2001 From: divesh Date: Wed, 4 Nov 2026 17:18:47 -0800 Subject: [PATCH 3/3] test(network): cover mapped local service destinations Signed-off-by: divesh --- .../openshell-supervisor-network/src/proxy.rs | 25 +++++++++++++------ docs/how-it-works/policies/network-rules.mdx | 7 +++--- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 771698fadf..6f64d91ee2 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -7901,18 +7901,22 @@ process: { run_as_user: sandbox, run_as_group: sandbox } #[tokio::test] async fn metadata_staging_requires_verified_identity_without_egress_rules() { + for destination in [ + openshell_core::google_cloud::METADATA_LOOPBACK_ADDR, + "[::ffff:127.0.0.1]:8174", + ] { + assert_metadata_staging(destination.parse().unwrap()).await; + } + } + + async fn assert_metadata_staging(destination: SocketAddr) { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), "network_policies: {}", ) .unwrap(); for valid_identity in [true, false] { - let (mut open, completion) = staged_curl_open( - openshell_core::google_cloud::METADATA_LOOPBACK_ADDR - .parse() - .unwrap(), - engine.current_generation(), - ); + let (mut open, completion) = staged_curl_open(destination, engine.current_generation()); if !valid_identity { open.binary_identity = Err(ResolveError::Failed("unavailable".into())); } @@ -8347,6 +8351,13 @@ process: {{ run_as_user: sandbox, run_as_group: sandbox }} #[tokio::test] async fn staged_policy_local_open_reaches_the_sandbox_scoped_api() { + let addr = crate::policy_dns::POLICY_LOCAL_ADDRESS; + for ip in [IpAddr::V4(addr), IpAddr::V6(addr.to_ipv6_mapped())] { + assert_policy_local_open_served(SocketAddr::new(ip, 80)).await; + } + } + + async fn assert_policy_local_open_served(destination: SocketAddr) { let engine = Arc::new( native_identity_engine( include_str!("../data/sandbox-policy.rego"), @@ -8368,7 +8379,7 @@ process: {{ run_as_user: sandbox, run_as_group: sandbox }} let pending = PendingTcpOpen { stream: Box::new(stream), binary_identity: Ok(identity.clone()), - destination: SocketAddr::from((crate::policy_dns::POLICY_LOCAL_ADDRESS, 80)), + destination, socket: Some( openshell_isolation_interface::contract::NetworkSocketMetadata { socket_cookie: 7, diff --git a/docs/how-it-works/policies/network-rules.mdx b/docs/how-it-works/policies/network-rules.mdx index a66b60a04a..a3d4f5245c 100644 --- a/docs/how-it-works/policies/network-rules.mdx +++ b/docs/how-it-works/policies/network-rules.mdx @@ -89,10 +89,6 @@ OpenShell checks network traffic in two stages: For example, with `protocol: rest`, a rule can allow `GET` requests to an API while blocking `POST` and `DELETE` requests. -Transparent TCP connections to IPv4-mapped IPv6 addresses, such as -`::ffff:203.0.113.7`, use the same destination rules as the equivalent IPv4 -address, `203.0.113.7`. - The second stage is called request inspection, and an endpoint that uses it is an inspected endpoint. The `protocol` field selects what OpenShell inspects: @@ -106,6 +102,9 @@ an inspected endpoint. The `protocol` field selects what OpenShell inspects: | `tcp` | No request rules. Use it for clients that speak a protocol other than HTTP, such as databases. | [Native TCP](#allow-native-tcp) | | Omitted | No request rules. | -- | +Connections to IPv4-mapped IPv6 addresses, such as `::ffff:203.0.113.7`, use the +same destination rules as the equivalent IPv4 address, `203.0.113.7`. + When `protocol` is omitted, OpenShell allows any method and path. Unless the endpoint sets `tls: skip`, it still terminates TLS and rejects HTTP requests that are addressed to another host or have malformed paths, such as paths that