Skip to content

Commit ef0bb64

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 dafb799 commit ef0bb64

File tree

3 files changed

+224
-70
lines changed

3 files changed

+224
-70
lines changed

crates/openshell-sandbox/src/mechanistic_mapper.rs

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use openshell_core::proto::{
1616
DenialSummary, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, PolicyChunk,
1717
};
1818
use std::collections::HashMap;
19-
use std::net::IpAddr;
19+
use std::net::{IpAddr, Ipv4Addr};
2020

2121
/// Well-known ports that get higher confidence scores.
2222
const WELL_KNOWN_PORTS: &[(u16, &str)] = &[
@@ -409,11 +409,12 @@ fn short_binary_name(path: &str) -> String {
409409
/// Check if an IP address is in private/internal space.
410410
///
411411
/// Matches the same ranges as the proxy's `is_internal_ip`: loopback,
412-
/// RFC 1918 private, link-local (IPv4), plus loopback, link-local, and
413-
/// ULA (IPv6). IPv4-mapped IPv6 addresses are unwrapped and checked.
412+
/// RFC 1918 private, CGNAT (RFC 6598), link-local (IPv4), plus loopback,
413+
/// link-local, and ULA (IPv6). IPv4-mapped IPv6 addresses are unwrapped
414+
/// and checked.
414415
fn is_internal_ip(ip: IpAddr) -> bool {
415416
match ip {
416-
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
417+
IpAddr::V4(v4) => is_internal_v4(&v4),
417418
IpAddr::V6(v6) => {
418419
if v6.is_loopback() {
419420
return true;
@@ -428,13 +429,43 @@ fn is_internal_ip(ip: IpAddr) -> bool {
428429
}
429430
// Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
430431
if let Some(v4) = v6.to_ipv4_mapped() {
431-
return v4.is_loopback() || v4.is_private() || v4.is_link_local();
432+
return is_internal_v4(&v4);
432433
}
433434
false
434435
}
435436
}
436437
}
437438

439+
/// IPv4 internal address check covering RFC 1918, CGNAT (RFC 6598), and other
440+
/// special-use ranges that should never be reachable from sandbox egress.
441+
fn is_internal_v4(v4: &Ipv4Addr) -> bool {
442+
if v4.is_loopback() || v4.is_private() || v4.is_link_local() {
443+
return true;
444+
}
445+
let octets = v4.octets();
446+
// 100.64.0.0/10 — CGNAT / shared address space (RFC 6598)
447+
if octets[0] == 100 && (octets[1] & 0xC0) == 64 {
448+
return true;
449+
}
450+
// 192.0.0.0/24 — IETF protocol assignments (RFC 6890)
451+
if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 {
452+
return true;
453+
}
454+
// 198.18.0.0/15 — benchmarking (RFC 2544)
455+
if octets[0] == 198 && (octets[1] & 0xFE) == 18 {
456+
return true;
457+
}
458+
// 198.51.100.0/24 — TEST-NET-2 (RFC 5737)
459+
if octets[0] == 198 && octets[1] == 51 && octets[2] == 100 {
460+
return true;
461+
}
462+
// 203.0.113.0/24 — TEST-NET-3 (RFC 5737)
463+
if octets[0] == 203 && octets[1] == 0 && octets[2] == 113 {
464+
return true;
465+
}
466+
false
467+
}
468+
438469
/// Resolve a hostname and return the IPs as `allowed_ips` strings only if any
439470
/// resolved address is in private IP space.
440471
///
@@ -671,6 +702,25 @@ mod tests {
671702
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))));
672703
}
673704

705+
#[test]
706+
fn test_is_internal_ip_cgnat() {
707+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
708+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 100, 50, 3))));
709+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(
710+
100, 127, 255, 255
711+
))));
712+
// Just outside the /10 boundary
713+
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 128, 0, 1))));
714+
}
715+
716+
#[test]
717+
fn test_is_internal_ip_special_use() {
718+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(192, 0, 0, 1))));
719+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1))));
720+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1))));
721+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))));
722+
}
723+
674724
#[test]
675725
fn test_is_internal_ip_v6() {
676726
use std::net::Ipv6Addr;

crates/openshell-sandbox/src/proxy.rs

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use openshell_ocsf::{
1414
ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest,
1515
NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit,
1616
};
17-
use std::net::{IpAddr, SocketAddr};
17+
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1818
use std::path::PathBuf;
1919
use std::sync::Arc;
2020
use std::sync::atomic::AtomicU32;
@@ -1067,7 +1067,10 @@ async fn handle_inference_interception(
10671067
let was_routed = route_inference_request(&request, ctx, &mut tls_client).await?;
10681068
if was_routed {
10691069
routed_any = true;
1070-
} else if !routed_any {
1070+
} else {
1071+
// Deny and close: a non-inference request must not be silently
1072+
// ignored on a keep-alive connection that previously routed
1073+
// inference traffic.
10711074
return Ok(InferenceOutcome::Denied {
10721075
reason: "connection not allowed by policy".to_string(),
10731076
});
@@ -1442,18 +1445,18 @@ fn query_tls_mode(
14421445
}
14431446
}
14441447

1445-
/// Check if an IP address is internal (loopback, private RFC1918, link-local, or unspecified).
1448+
/// Check if an IP address is internal (loopback, private RFC1918, CGNAT, link-local, or unspecified).
14461449
///
14471450
/// This is a defense-in-depth check to prevent SSRF via the CONNECT proxy.
14481451
/// It covers:
14491452
/// - IPv4 loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), unspecified (`0.0.0.0`)
1453+
/// - CGNAT / shared address space (100.64.0.0/10, RFC 6598)
1454+
/// - Documentation / benchmarking (192.0.0.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24)
14501455
/// - IPv6 loopback (`::1`), link-local (`fe80::/10`), ULA (`fc00::/7`), unspecified (`::`)
14511456
/// - IPv4-mapped IPv6 addresses (`::ffff:x.x.x.x`) are unwrapped and checked as IPv4
14521457
fn is_internal_ip(ip: IpAddr) -> bool {
14531458
match ip {
1454-
IpAddr::V4(v4) => {
1455-
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
1456-
}
1459+
IpAddr::V4(v4) => is_internal_v4(&v4),
14571460
IpAddr::V6(v6) => {
14581461
if v6.is_loopback() || v6.is_unspecified() {
14591462
return true;
@@ -1468,16 +1471,44 @@ fn is_internal_ip(ip: IpAddr) -> bool {
14681471
}
14691472
// Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
14701473
if let Some(v4) = v6.to_ipv4_mapped() {
1471-
return v4.is_loopback()
1472-
|| v4.is_private()
1473-
|| v4.is_link_local()
1474-
|| v4.is_unspecified();
1474+
return is_internal_v4(&v4);
14751475
}
14761476
false
14771477
}
14781478
}
14791479
}
14801480

1481+
/// IPv4 internal address check covering RFC 1918, CGNAT (RFC 6598), and other
1482+
/// special-use ranges that should never be reachable from sandbox egress.
1483+
fn is_internal_v4(v4: &Ipv4Addr) -> bool {
1484+
if v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() {
1485+
return true;
1486+
}
1487+
let octets = v4.octets();
1488+
// 100.64.0.0/10 — CGNAT / shared address space (RFC 6598). Commonly used by
1489+
// cloud VPC peering, Tailscale, and similar overlay networks.
1490+
if octets[0] == 100 && (octets[1] & 0xC0) == 64 {
1491+
return true;
1492+
}
1493+
// 192.0.0.0/24 — IETF protocol assignments (RFC 6890)
1494+
if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 {
1495+
return true;
1496+
}
1497+
// 198.18.0.0/15 — benchmarking (RFC 2544)
1498+
if octets[0] == 198 && (octets[1] & 0xFE) == 18 {
1499+
return true;
1500+
}
1501+
// 198.51.100.0/24 — TEST-NET-2 (RFC 5737)
1502+
if octets[0] == 198 && octets[1] == 51 && octets[2] == 100 {
1503+
return true;
1504+
}
1505+
// 203.0.113.0/24 — TEST-NET-3 (RFC 5737)
1506+
if octets[0] == 203 && octets[1] == 0 && octets[2] == 113 {
1507+
return true;
1508+
}
1509+
false
1510+
}
1511+
14811512
/// When the policy endpoint host is a literal IP address, the user has
14821513
/// explicitly declared intent to allow that destination. Synthesize an
14831514
/// `allowed_ips` entry so the existing allowlist-validation path is used
@@ -2495,6 +2526,41 @@ mod tests {
24952526
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
24962527
}
24972528

2529+
#[test]
2530+
fn test_rejects_ipv4_cgnat() {
2531+
// 100.64.0.0/10 — CGNAT / shared address space (RFC 6598)
2532+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
2533+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 100, 50, 3))));
2534+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(
2535+
100, 127, 255, 255
2536+
))));
2537+
// Just outside the /10 boundary
2538+
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 128, 0, 1))));
2539+
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(
2540+
100, 63, 255, 255
2541+
))));
2542+
}
2543+
2544+
#[test]
2545+
fn test_rejects_ipv4_special_use_ranges() {
2546+
// 192.0.0.0/24 — IETF protocol assignments
2547+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(192, 0, 0, 1))));
2548+
// 198.18.0.0/15 — benchmarking
2549+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1))));
2550+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 19, 255, 255))));
2551+
// 198.51.100.0/24 — TEST-NET-2
2552+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1))));
2553+
// 203.0.113.0/24 — TEST-NET-3
2554+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))));
2555+
}
2556+
2557+
#[test]
2558+
fn test_rejects_ipv6_mapped_cgnat() {
2559+
// ::ffff:100.64.0.1 should be caught via IPv4-mapped unwrapping
2560+
let v6 = Ipv4Addr::new(100, 64, 0, 1).to_ipv6_mapped();
2561+
assert!(is_internal_ip(IpAddr::V6(v6)));
2562+
}
2563+
24982564
#[test]
24992565
fn test_allows_ipv4_public() {
25002566
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));

0 commit comments

Comments
 (0)