Skip to content
Merged
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
1 change: 1 addition & 0 deletions crates/openshell-isolation-interface/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ pub struct PendingTcpOpen {
/// Executable identity, resolved by the backend for this connection.
pub binary_identity: Result<BinaryIdentity, ResolveError>,
/// 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
Expand Down
101 changes: 61 additions & 40 deletions crates/openshell-supervisor-network/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Comment thread
FrostGod marked this conversation as resolved.
Resolved
Comment thread
FrostGod marked this conversation as resolved.
Resolved
let _timing = NetworkOpenTimingGuard {
timing,
operation: "tcp",
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -7897,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()));
}
Expand Down Expand Up @@ -8343,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"),
Expand All @@ -8364,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,
Expand Down Expand Up @@ -8878,40 +8893,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]
Expand Down
145 changes: 145 additions & 0 deletions crates/openshell-supervisor-network/src/proxy/tests/ipv4_mapped.rs
Original file line number Diff line number Diff line change
@@ -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<ResolvedEndpointStore>>,
engine: &OpaEngine,
) -> (
Option<TransparentOpen>,
TcpOpenDecision,
Option<DenialEvent>,
) {
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() {
Comment thread
FrostGod marked this conversation as resolved.
Resolved
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());
}
3 changes: 3 additions & 0 deletions docs/how-it-works/policies/network-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,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
Expand Down
6 changes: 6 additions & 0 deletions e2e/rust/tests/transparent_tcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.')))
Comment thread
FrostGod marked this conversation as resolved.
Resolved
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'
Expand Down
Loading