From dedaee7ad10732f524c2e2fbcf2eec760db4f9bb Mon Sep 17 00:00:00 2001 From: divesh Date: Sat, 31 Oct 2026 19:47:34 -0700 Subject: [PATCH] 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'