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
6 changes: 5 additions & 1 deletion 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());
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
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() {
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());
}
4 changes: 4 additions & 0 deletions docs/how-it-works/policies/network-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

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.')))
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