Skip to content

Commit 7954300

Browse files
authored
fix(network): normalize IPv4-mapped transparent TCP destinations (#5101)
* fix(network): normalize IPv4-mapped transparent TCP destinations Signed-off-by: divesh <dgude@nvidia.com> * test(network): cover mapped reserved destinations Signed-off-by: divesh <dgude@nvidia.com> * test(network): cover mapped local service destinations Signed-off-by: divesh <dgude@nvidia.com> --------- Signed-off-by: divesh <dgude@nvidia.com>
1 parent 10ace4f commit 7954300

5 files changed

Lines changed: 216 additions & 40 deletions

File tree

‎crates/openshell-isolation-interface/src/contract.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,7 @@ pub struct PendingTcpOpen {
583583
/// Executable identity, resolved by the backend for this connection.
584584
pub binary_identity: Result<BinaryIdentity, ResolveError>,
585585
/// Original external destination captured by the backend before mediation.
586+
/// The supervisor may canonicalize IPv4-mapped IPv6 addresses before routing.
586587
pub destination: SocketAddr,
587588
/// Native socket evidence, when the backend mediates a kernel socket.
588589
/// Guests without native sockets supply authoritative executable identity

‎crates/openshell-supervisor-network/src/proxy.rs‎

Lines changed: 61 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -638,12 +638,14 @@ async fn preauthorize_transparent_open(
638638
let PendingTcpOpen {
639639
stream,
640640
binary_identity,
641-
destination,
641+
mut destination,
642642
socket: _,
643643
policy_generation: _,
644644
timing,
645645
decision: completion,
646646
} = connection;
647+
// Use the IPv4 identity for mapped destinations throughout routing and policy.
648+
destination.set_ip(destination.ip().to_canonical());
647649
let _timing = NetworkOpenTimingGuard {
648650
timing,
649651
operation: "tcp",
@@ -7200,6 +7202,8 @@ fn is_benign_relay_error(err: &miette::Report) -> bool {
72007202
reason = "Test code: test fixtures and explicit control-flow markers are idiomatic in tests."
72017203
)]
72027204
mod tests {
7205+
mod ipv4_mapped;
7206+
72037207
#[test]
72047208
fn proxy_authorization_rejects_missing_wrong_cross_and_duplicate_credentials() {
72057209
let expected = "Basic sandbox-a-generation";
@@ -7897,18 +7901,22 @@ process: { run_as_user: sandbox, run_as_group: sandbox }
78977901

78987902
#[tokio::test]
78997903
async fn metadata_staging_requires_verified_identity_without_egress_rules() {
7904+
for destination in [
7905+
openshell_core::google_cloud::METADATA_LOOPBACK_ADDR,
7906+
"[::ffff:127.0.0.1]:8174",
7907+
] {
7908+
assert_metadata_staging(destination.parse().unwrap()).await;
7909+
}
7910+
}
7911+
7912+
async fn assert_metadata_staging(destination: SocketAddr) {
79007913
let engine = OpaEngine::from_strings(
79017914
include_str!("../data/sandbox-policy.rego"),
79027915
"network_policies: {}",
79037916
)
79047917
.unwrap();
79057918
for valid_identity in [true, false] {
7906-
let (mut open, completion) = staged_curl_open(
7907-
openshell_core::google_cloud::METADATA_LOOPBACK_ADDR
7908-
.parse()
7909-
.unwrap(),
7910-
engine.current_generation(),
7911-
);
7919+
let (mut open, completion) = staged_curl_open(destination, engine.current_generation());
79127920
if !valid_identity {
79137921
open.binary_identity = Err(ResolveError::Failed("unavailable".into()));
79147922
}
@@ -8343,6 +8351,13 @@ process: {{ run_as_user: sandbox, run_as_group: sandbox }}
83438351

83448352
#[tokio::test]
83458353
async fn staged_policy_local_open_reaches_the_sandbox_scoped_api() {
8354+
let addr = crate::policy_dns::POLICY_LOCAL_ADDRESS;
8355+
for ip in [IpAddr::V4(addr), IpAddr::V6(addr.to_ipv6_mapped())] {
8356+
assert_policy_local_open_served(SocketAddr::new(ip, 80)).await;
8357+
}
8358+
}
8359+
8360+
async fn assert_policy_local_open_served(destination: SocketAddr) {
83468361
let engine = Arc::new(
83478362
native_identity_engine(
83488363
include_str!("../data/sandbox-policy.rego"),
@@ -8364,7 +8379,7 @@ process: {{ run_as_user: sandbox, run_as_group: sandbox }}
83648379
let pending = PendingTcpOpen {
83658380
stream: Box::new(stream),
83668381
binary_identity: Ok(identity.clone()),
8367-
destination: SocketAddr::from((crate::policy_dns::POLICY_LOCAL_ADDRESS, 80)),
8382+
destination,
83688383
socket: Some(
83698384
openshell_isolation_interface::contract::NetworkSocketMetadata {
83708385
socket_cookie: 7,
@@ -8878,40 +8893,46 @@ process:
88788893

88798894
#[tokio::test]
88808895
async fn reserved_destination_is_served_without_policy_evaluation() {
8881-
let engine = reserved_test_engine();
8882-
let (reserved, mut served_rx, calls) = reserved_relay(true);
8883-
let (open, decision, mut peer) =
8884-
staged_open(&engine, openshell_core::sandbox_env::OTLP_RELAY_ADDR);
8896+
let addr = *OTLP_RELAY_ADDR;
8897+
for destination in [
8898+
addr.to_string(),
8899+
format!("[::ffff:{}]:{}", addr.ip(), addr.port()),
8900+
] {
8901+
let engine = reserved_test_engine();
8902+
let (reserved, mut served_rx, calls) = reserved_relay(true);
8903+
let (open, decision, mut peer) = staged_open(&engine, &destination);
88858904

8886-
assert!(
8887-
preauthorize_transparent_open(
8888-
open,
8889-
None,
8890-
&engine,
8891-
&BinaryIdentityCache::new(),
8892-
None,
8893-
None,
8894-
false,
8895-
None,
8896-
Some(&reserved)
8897-
)
8898-
.await
8899-
.is_none(),
8900-
"a reserved open never becomes a proxy connection"
8901-
);
8902-
assert_eq!(decision.await.unwrap(), TcpOpenDecision::RelayReady);
8903-
assert_eq!(calls.load(Ordering::SeqCst), 1);
8905+
assert!(
8906+
preauthorize_transparent_open(
8907+
open,
8908+
None,
8909+
&engine,
8910+
&BinaryIdentityCache::new(),
8911+
None,
8912+
None,
8913+
false,
8914+
None,
8915+
Some(&reserved)
8916+
)
8917+
.await
8918+
.is_none(),
8919+
"a reserved open never becomes a proxy connection"
8920+
);
8921+
assert_eq!(decision.await.unwrap(), TcpOpenDecision::RelayReady);
8922+
assert_eq!(calls.load(Ordering::SeqCst), 1);
89048923

8905-
// The handler holds the workload's stream: bytes written by the
8906-
// workload side arrive at the handler.
8907-
let mut stream = tokio::time::timeout(std::time::Duration::from_secs(1), served_rx.recv())
8908-
.await
8909-
.expect("handler future ran")
8910-
.expect("handler received the stream");
8911-
peer.write_all(b"POST /v1/traces").await.unwrap();
8912-
let mut head = [0u8; 15];
8913-
stream.read_exact(&mut head).await.unwrap();
8914-
assert_eq!(&head, b"POST /v1/traces");
8924+
// The handler holds the workload's stream: bytes written by the
8925+
// workload side arrive at the handler.
8926+
let mut stream =
8927+
tokio::time::timeout(std::time::Duration::from_secs(1), served_rx.recv())
8928+
.await
8929+
.expect("handler future ran")
8930+
.expect("handler received the stream");
8931+
peer.write_all(b"POST /v1/traces").await.unwrap();
8932+
let mut head = [0u8; 15];
8933+
stream.read_exact(&mut head).await.unwrap();
8934+
assert_eq!(&head, b"POST /v1/traces");
8935+
}
89158936
}
89168937

89178938
#[tokio::test]
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! IPv4-mapped destinations must retain their IPv4 policy and DNS identity.
5+
6+
use super::*;
7+
8+
fn engine_for(host: &str) -> OpaEngine {
9+
native_identity_engine(
10+
include_str!("../../../data/sandbox-policy.rego"),
11+
&policy_with_native_executable_paths(&POLICY_DNS_OPEN_POLICY.replace("db.example", host)),
12+
)
13+
.unwrap()
14+
}
15+
16+
async fn probe(
17+
destination: SocketAddr,
18+
store: Option<&Arc<ResolvedEndpointStore>>,
19+
engine: &OpaEngine,
20+
) -> (
21+
Option<TransparentOpen>,
22+
TcpOpenDecision,
23+
Option<DenialEvent>,
24+
) {
25+
let (open, completion) = staged_curl_open(destination, engine.current_generation());
26+
let (denial_tx, mut denial_rx) = mpsc::unbounded_channel();
27+
let accepted = preauthorize_transparent_open(
28+
open,
29+
store,
30+
engine,
31+
&BinaryIdentityCache::new(),
32+
None,
33+
None,
34+
false,
35+
Some(&denial_tx),
36+
None,
37+
)
38+
.await;
39+
let decision = completion.await.unwrap();
40+
assert_eq!(accepted.is_some(), decision == TcpOpenDecision::RelayReady);
41+
(
42+
accepted.and_then(|(_, _, _, transparent)| transparent),
43+
decision,
44+
denial_rx.try_recv().ok(),
45+
)
46+
}
47+
48+
#[tokio::test]
49+
async fn allowed_ipv4_endpoint_accepts_mapped_form() {
50+
let engine = engine_for("203.0.113.7");
51+
let store = policy_dns_test_store();
52+
for dns in [None, Some(&store)] {
53+
for destination in ["203.0.113.7:5432", "[::ffff:203.0.113.7]:5432"] {
54+
let (transparent, decision, denial) =
55+
probe(destination.parse().unwrap(), dns, &engine).await;
56+
assert_eq!(decision, TcpOpenDecision::RelayReady, "{destination}");
57+
assert_eq!(
58+
transparent.unwrap().destination,
59+
"203.0.113.7:5432".parse().unwrap()
60+
);
61+
assert!(denial.is_none());
62+
}
63+
}
64+
}
65+
66+
#[tokio::test]
67+
async fn policy_dns_recovers_hostname_for_mapped_form() {
68+
let engine = engine_for("db.example");
69+
let store = policy_dns_test_store();
70+
let record = publish_mapping(
71+
&store,
72+
"db.example",
73+
"database",
74+
5432,
75+
engine.current_generation(),
76+
);
77+
let IpAddr::V4(ipv4) = record.synthetic_address else {
78+
panic!("test publishes an IPv4 mapping");
79+
};
80+
for ip in [IpAddr::V4(ipv4), IpAddr::V6(ipv4.to_ipv6_mapped())] {
81+
let destination = SocketAddr::new(ip, 5432);
82+
let (transparent, decision, denial) = probe(destination, Some(&store), &engine).await;
83+
assert_eq!(decision, TcpOpenDecision::RelayReady, "{destination}");
84+
assert!(denial.is_none());
85+
let transparent = transparent.unwrap();
86+
assert_eq!(
87+
transparent.destination,
88+
SocketAddr::new(IpAddr::V4(ipv4), 5432)
89+
);
90+
let (decision, connector) = transparent.authorization.unwrap();
91+
assert_eq!(decision.intent.destination.host, "db.example");
92+
assert_eq!(connector.addrs(), &["203.0.113.8:5432".parse().unwrap()]);
93+
}
94+
}
95+
96+
#[tokio::test]
97+
async fn denial_reports_canonical_ipv4_host() {
98+
let engine = engine_for("203.0.113.7");
99+
let store = policy_dns_test_store();
100+
let mut hosts = Vec::new();
101+
for destination in ["203.0.113.8:5432", "[::ffff:203.0.113.8]:5432"] {
102+
let (_, decision, denial) =
103+
probe(destination.parse().unwrap(), Some(&store), &engine).await;
104+
assert_eq!(
105+
decision,
106+
TcpOpenDecision::Denied(TcpOpenDenial::PolicyDenied)
107+
);
108+
let denial = denial.expect("unapproved endpoint produces a policy proposal");
109+
assert_eq!(denial.denial_stage, "transparent_tcp_connect");
110+
hosts.push(denial.host);
111+
}
112+
assert_eq!(hosts, ["203.0.113.8", "203.0.113.8"]);
113+
}
114+
115+
#[tokio::test]
116+
async fn metadata_stays_blocked_in_both_forms() {
117+
let store = policy_dns_test_store();
118+
for policy_host in ["169.254.169.254", "::ffff:169.254.169.254"] {
119+
let engine = engine_for(policy_host);
120+
for destination in ["169.254.169.254:5432", "[::ffff:169.254.169.254]:5432"] {
121+
let (_, decision, denial) =
122+
probe(destination.parse().unwrap(), Some(&store), &engine).await;
123+
assert!(matches!(decision, TcpOpenDecision::Denied(_)));
124+
assert!(
125+
denial.is_none(),
126+
"blocked metadata must not become a proposal"
127+
);
128+
}
129+
}
130+
}
131+
132+
#[tokio::test]
133+
async fn native_ipv6_preserves_scope_and_flowinfo() {
134+
let engine = engine_for("2001:db8::7");
135+
let destination = SocketAddr::V6(std::net::SocketAddrV6::new(
136+
"2001:db8::7".parse().unwrap(),
137+
5432,
138+
42,
139+
7,
140+
));
141+
let (transparent, decision, denial) = probe(destination, None, &engine).await;
142+
assert_eq!(decision, TcpOpenDecision::RelayReady);
143+
assert_eq!(transparent.unwrap().destination, destination);
144+
assert!(denial.is_none());
145+
}

‎docs/how-it-works/policies/network-rules.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ an inspected endpoint. The `protocol` field selects what OpenShell inspects:
102102
| `tcp` | No request rules. Use it for clients that speak a protocol other than HTTP, such as databases. | [Native TCP](#allow-native-tcp) |
103103
| Omitted | No request rules. | -- |
104104

105+
Connections to IPv4-mapped IPv6 addresses, such as `::ffff:203.0.113.7`, use the
106+
same destination rules as the equivalent IPv4 address, `203.0.113.7`.
107+
105108
When `protocol` is omitted, OpenShell allows any method and path. Unless the
106109
endpoint sets `tls: skip`, it still terminates TLS and rejects HTTP requests
107110
that are addressed to another host or have malformed paths, such as paths that

‎e2e/rust/tests/transparent_tcp.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,12 @@ assert any(ip.startswith('198.18.') or ip.startswith('198.19.') for ip in synthe
486486
with socket.create_connection(({host:?}, {port}), timeout=10) as conn:
487487
conn.sendall(b'probe')
488488
assert conn.recv(1024) == b'native-tcp-ok:probe'
489+
mapped = next('::ffff:' + ip for ip in synthetic if ip.startswith(('198.18.', '198.19.')))
490+
with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as conn:
491+
conn.settimeout(10)
492+
conn.connect((mapped, {port}))
493+
conn.sendall(b'mapped-probe')
494+
assert conn.recv(1024) == b'native-tcp-ok:mapped-probe'
489495
with socket.create_connection(({host:?}, {tcp_dns_port}), timeout=10) as conn:
490496
conn.sendall(b'tcp-53-probe')
491497
assert conn.recv(1024) == b'native-tcp-ok:tcp-53-probe'

0 commit comments

Comments
 (0)