Skip to content

Commit 5d2f13d

Browse files
committed
fix(sandbox): harden seccomp denylist, SSRF protection, and inference policy enforcement
- Remove seccomp skip in NetworkMode::Allow so baseline syscall restrictions apply regardless of network mode - Block cross-process manipulation syscalls (process_vm_writev, pidfd_open, pidfd_getfd, pidfd_send_signal) symmetric with existing ptrace and process_vm_readv blocks - Block clone/clone3 with CLONE_NEWUSER flag, new mount API syscalls (fsopen, fsconfig, fsmount, fspick, move_mount, open_tree), and namespace manipulation (setns, umount2, pivot_root) - Block userfaultfd and perf_event_open consistent with Docker default seccomp profile - Deny and close keep-alive inference connections after a non-inference request instead of silently continuing the loop - Add CGNAT (100.64.0.0/10), benchmarking (198.18.0.0/15), and other special-use IP ranges to SSRF protection in both proxy and mechanistic mapper
1 parent 2ca553a commit 5d2f13d

File tree

4 files changed

+223
-65
lines changed

4 files changed

+223
-65
lines changed

crates/openshell-core/src/net.rs

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,15 @@ pub fn is_always_blocked_net(net: ipnet::IpNet) -> bool {
113113
/// or unspecified).
114114
///
115115
/// This is a broader check than [`is_always_blocked_ip`] — it includes RFC 1918
116-
/// private ranges (`10/8`, `172.16/12`, `192.168/16`) and IPv6 ULA (`fc00::/7`)
117-
/// which are allowable via `allowed_ips` but blocked by default without one.
116+
/// private ranges (`10/8`, `172.16/12`, `192.168/16`), CGNAT (`100.64.0.0/10`,
117+
/// RFC 6598), other special-use ranges, and IPv6 ULA (`fc00::/7`) which are
118+
/// allowable via `allowed_ips` but blocked by default without one.
118119
///
119120
/// Used by the proxy's default SSRF path and the mechanistic mapper to detect
120121
/// when `allowed_ips` should be populated in proposals.
121122
pub fn is_internal_ip(ip: IpAddr) -> bool {
122123
match ip {
123-
IpAddr::V4(v4) => {
124-
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
125-
}
124+
IpAddr::V4(v4) => is_internal_v4(&v4),
126125
IpAddr::V6(v6) => {
127126
if v6.is_loopback() || v6.is_unspecified() {
128127
return true;
@@ -137,16 +136,44 @@ pub fn is_internal_ip(ip: IpAddr) -> bool {
137136
}
138137
// Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
139138
if let Some(v4) = v6.to_ipv4_mapped() {
140-
return v4.is_loopback()
141-
|| v4.is_private()
142-
|| v4.is_link_local()
143-
|| v4.is_unspecified();
139+
return is_internal_v4(&v4);
144140
}
145141
false
146142
}
147143
}
148144
}
149145

146+
/// IPv4 internal address check covering RFC 1918, CGNAT (RFC 6598), and other
147+
/// special-use ranges that should never be reachable from sandbox egress.
148+
fn is_internal_v4(v4: &Ipv4Addr) -> bool {
149+
if v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() {
150+
return true;
151+
}
152+
let octets = v4.octets();
153+
// 100.64.0.0/10 — CGNAT / shared address space (RFC 6598). Commonly used by
154+
// cloud VPC peering, Tailscale, and similar overlay networks.
155+
if octets[0] == 100 && (octets[1] & 0xC0) == 64 {
156+
return true;
157+
}
158+
// 192.0.0.0/24 — IETF protocol assignments (RFC 6890)
159+
if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 {
160+
return true;
161+
}
162+
// 198.18.0.0/15 — benchmarking (RFC 2544)
163+
if octets[0] == 198 && (octets[1] & 0xFE) == 18 {
164+
return true;
165+
}
166+
// 198.51.100.0/24 — TEST-NET-2 (RFC 5737)
167+
if octets[0] == 198 && octets[1] == 51 && octets[2] == 100 {
168+
return true;
169+
}
170+
// 203.0.113.0/24 — TEST-NET-3 (RFC 5737)
171+
if octets[0] == 203 && octets[1] == 0 && octets[2] == 113 {
172+
return true;
173+
}
174+
false
175+
}
176+
150177
#[cfg(test)]
151178
mod tests {
152179
use super::*;
@@ -358,4 +385,38 @@ mod tests {
358385
let v6_public = Ipv4Addr::new(8, 8, 8, 8).to_ipv6_mapped();
359386
assert!(!is_internal_ip(IpAddr::V6(v6_public)));
360387
}
388+
389+
#[test]
390+
fn test_internal_ip_cgnat() {
391+
// 100.64.0.0/10 — CGNAT / shared address space (RFC 6598)
392+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
393+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 100, 50, 3))));
394+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(
395+
100, 127, 255, 255
396+
))));
397+
// Just outside the /10 boundary
398+
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 128, 0, 1))));
399+
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(
400+
100, 63, 255, 255
401+
))));
402+
}
403+
404+
#[test]
405+
fn test_internal_ip_special_use_ranges() {
406+
// 192.0.0.0/24 — IETF protocol assignments
407+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(192, 0, 0, 1))));
408+
// 198.18.0.0/15 — benchmarking
409+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1))));
410+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 19, 255, 255))));
411+
// 198.51.100.0/24 — TEST-NET-2
412+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1))));
413+
// 203.0.113.0/24 — TEST-NET-3
414+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))));
415+
}
416+
417+
#[test]
418+
fn test_internal_ip_ipv6_mapped_cgnat() {
419+
let v6 = Ipv4Addr::new(100, 64, 0, 1).to_ipv6_mapped();
420+
assert!(is_internal_ip(IpAddr::V6(v6)));
421+
}
361422
}

crates/openshell-sandbox/src/mechanistic_mapper.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,27 @@ mod tests {
690690
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))));
691691
}
692692

693+
#[test]
694+
fn test_is_internal_ip_cgnat() {
695+
use std::net::Ipv4Addr;
696+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
697+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 100, 50, 3))));
698+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(
699+
100, 127, 255, 255
700+
))));
701+
// Just outside the /10 boundary
702+
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 128, 0, 1))));
703+
}
704+
705+
#[test]
706+
fn test_is_internal_ip_special_use() {
707+
use std::net::Ipv4Addr;
708+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(192, 0, 0, 1))));
709+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1))));
710+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1))));
711+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))));
712+
}
713+
693714
#[test]
694715
fn test_is_internal_ip_v6() {
695716
use std::net::Ipv6Addr;

crates/openshell-sandbox/src/proxy.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1068,7 +1068,10 @@ async fn handle_inference_interception(
10681068
let was_routed = route_inference_request(&request, ctx, &mut tls_client).await?;
10691069
if was_routed {
10701070
routed_any = true;
1071-
} else if !routed_any {
1071+
} else {
1072+
// Deny and close: a non-inference request must not be silently
1073+
// ignored on a keep-alive connection that previously routed
1074+
// inference traffic.
10721075
return Ok(InferenceOutcome::Denied {
10731076
reason: "connection not allowed by policy".to_string(),
10741077
});
@@ -2467,6 +2470,41 @@ mod tests {
24672470
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
24682471
}
24692472

2473+
#[test]
2474+
fn test_rejects_ipv4_cgnat() {
2475+
// 100.64.0.0/10 — CGNAT / shared address space (RFC 6598)
2476+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
2477+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 100, 50, 3))));
2478+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(
2479+
100, 127, 255, 255
2480+
))));
2481+
// Just outside the /10 boundary
2482+
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 128, 0, 1))));
2483+
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(
2484+
100, 63, 255, 255
2485+
))));
2486+
}
2487+
2488+
#[test]
2489+
fn test_rejects_ipv4_special_use_ranges() {
2490+
// 192.0.0.0/24 — IETF protocol assignments
2491+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(192, 0, 0, 1))));
2492+
// 198.18.0.0/15 — benchmarking
2493+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1))));
2494+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 19, 255, 255))));
2495+
// 198.51.100.0/24 — TEST-NET-2
2496+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1))));
2497+
// 203.0.113.0/24 — TEST-NET-3
2498+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))));
2499+
}
2500+
2501+
#[test]
2502+
fn test_rejects_ipv6_mapped_cgnat() {
2503+
// ::ffff:100.64.0.1 should be caught via IPv4-mapped unwrapping
2504+
let v6 = Ipv4Addr::new(100, 64, 0, 1).to_ipv6_mapped();
2505+
assert!(is_internal_ip(IpAddr::V6(v6)));
2506+
}
2507+
24702508
#[test]
24712509
fn test_allows_ipv4_public() {
24722510
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));

0 commit comments

Comments
 (0)