Skip to content

Commit 84834ab

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 84834ab

4 files changed

Lines changed: 491 additions & 74 deletions

File tree

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: 141 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ struct ConnectDecision {
5151
///
5252
/// Returned by [`handle_inference_interception`] so the call site can emit
5353
/// a structured CONNECT deny log when the connection is not successfully routed.
54+
#[derive(Debug)]
5455
enum InferenceOutcome {
5556
/// At least one request was successfully routed to a local inference backend.
5657
Routed,
@@ -1009,8 +1010,6 @@ async fn handle_inference_interception(
10091010
tls_state: Option<&Arc<ProxyTlsState>>,
10101011
inference_ctx: Option<&Arc<InferenceContext>>,
10111012
) -> Result<InferenceOutcome> {
1012-
use crate::l7::inference::{ParseResult, format_http_response, try_parse_http_request};
1013-
10141013
let Some(ctx) = inference_ctx else {
10151014
return Ok(InferenceOutcome::Denied {
10161015
reason: "cluster inference context not configured".to_string(),
@@ -1033,15 +1032,28 @@ async fn handle_inference_interception(
10331032
}
10341033
};
10351034

1036-
// Read and process HTTP requests from the tunnel.
1037-
// Track whether any request was successfully routed so that a late denial
1038-
// on a keep-alive connection still counts as "routed".
1035+
process_inference_keepalive(&mut tls_client, ctx, port).await
1036+
}
1037+
1038+
/// Read and process HTTP requests from a TLS-terminated inference connection.
1039+
///
1040+
/// Each request is matched against inference patterns and routed locally.
1041+
/// Any non-inference request is immediately denied and the connection is closed,
1042+
/// even if previous requests on the same keep-alive connection were routed
1043+
/// successfully.
1044+
async fn process_inference_keepalive<S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin>(
1045+
stream: &mut S,
1046+
ctx: &InferenceContext,
1047+
port: u16,
1048+
) -> Result<InferenceOutcome> {
1049+
use crate::l7::inference::{ParseResult, format_http_response, try_parse_http_request};
1050+
10391051
let mut buf = vec![0u8; INITIAL_INFERENCE_BUF];
10401052
let mut used = 0usize;
10411053
let mut routed_any = false;
10421054

10431055
loop {
1044-
let n = match tls_client.read(&mut buf[used..]).await {
1056+
let n = match stream.read(&mut buf[used..]).await {
10451057
Ok(n) => n,
10461058
Err(e) => {
10471059
if routed_any {
@@ -1065,10 +1077,13 @@ async fn handle_inference_interception(
10651077
// Try to parse a complete HTTP request
10661078
match try_parse_http_request(&buf[..used]) {
10671079
ParseResult::Complete(request, consumed) => {
1068-
let was_routed = route_inference_request(&request, ctx, &mut tls_client).await?;
1080+
let was_routed = route_inference_request(&request, ctx, stream).await?;
10691081
if was_routed {
10701082
routed_any = true;
1071-
} else if !routed_any {
1083+
} else {
1084+
// Deny and close: a non-inference request must not be silently
1085+
// ignored on a keep-alive connection that previously routed
1086+
// inference traffic.
10721087
return Ok(InferenceOutcome::Denied {
10731088
reason: "connection not allowed by policy".to_string(),
10741089
});
@@ -1083,7 +1098,7 @@ async fn handle_inference_interception(
10831098
if used == buf.len() {
10841099
if buf.len() >= MAX_INFERENCE_BUF {
10851100
let response = format_http_response(413, &[], b"Payload Too Large");
1086-
write_all(&mut tls_client, &response).await?;
1101+
write_all(stream, &response).await?;
10871102
if routed_any {
10881103
break;
10891104
}
@@ -1109,7 +1124,7 @@ async fn handle_inference_interception(
11091124
ocsf_emit!(event);
11101125
}
11111126
let response = format_http_response(400, &[], b"Bad Request");
1112-
write_all(&mut tls_client, &response).await?;
1127+
write_all(stream, &response).await?;
11131128
return Ok(InferenceOutcome::Denied { reason });
11141129
}
11151130
}
@@ -2467,6 +2482,41 @@ mod tests {
24672482
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
24682483
}
24692484

2485+
#[test]
2486+
fn test_rejects_ipv4_cgnat() {
2487+
// 100.64.0.0/10 — CGNAT / shared address space (RFC 6598)
2488+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
2489+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 100, 50, 3))));
2490+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(
2491+
100, 127, 255, 255
2492+
))));
2493+
// Just outside the /10 boundary
2494+
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 128, 0, 1))));
2495+
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(
2496+
100, 63, 255, 255
2497+
))));
2498+
}
2499+
2500+
#[test]
2501+
fn test_rejects_ipv4_special_use_ranges() {
2502+
// 192.0.0.0/24 — IETF protocol assignments
2503+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(192, 0, 0, 1))));
2504+
// 198.18.0.0/15 — benchmarking
2505+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1))));
2506+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 19, 255, 255))));
2507+
// 198.51.100.0/24 — TEST-NET-2
2508+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1))));
2509+
// 203.0.113.0/24 — TEST-NET-3
2510+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))));
2511+
}
2512+
2513+
#[test]
2514+
fn test_rejects_ipv6_mapped_cgnat() {
2515+
// ::ffff:100.64.0.1 should be caught via IPv4-mapped unwrapping
2516+
let v6 = Ipv4Addr::new(100, 64, 0, 1).to_ipv6_mapped();
2517+
assert!(is_internal_ip(IpAddr::V6(v6)));
2518+
}
2519+
24702520
#[test]
24712521
fn test_allows_ipv4_public() {
24722522
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
@@ -3355,4 +3405,85 @@ mod tests {
33553405
let result = implicit_allowed_ips_for_ip_host("*.example.com");
33563406
assert!(result.is_empty());
33573407
}
3408+
3409+
/// Regression test: exercises the actual keep-alive interception loop to
3410+
/// verify that a non-inference request is denied even after a previous
3411+
/// inference request was successfully routed on the same connection.
3412+
///
3413+
/// Before the fix, `handle_inference_interception` used
3414+
/// `else if !routed_any` which silently dropped denials once `routed_any`
3415+
/// was true, allowing non-inference HTTP requests to piggyback on a
3416+
/// keep-alive connection that had previously handled inference traffic.
3417+
/// Regression test: exercises the actual keep-alive interception loop to
3418+
/// verify that a non-inference request is denied even after a previous
3419+
/// inference request was successfully routed on the same connection.
3420+
///
3421+
/// The server runs in a spawned task with empty routes (the inference
3422+
/// request gets a 503 "not configured" but is still recognized as
3423+
/// inference and returns Ok(true)). The client sends the inference
3424+
/// request, reads the 503 response, then sends a non-inference request
3425+
/// on the same connection. The server must return Denied.
3426+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3427+
async fn test_keepalive_denies_non_inference_after_routed() {
3428+
use openshell_router::Router;
3429+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
3430+
3431+
let router = Router::new().unwrap();
3432+
let patterns = crate::l7::inference::default_patterns();
3433+
// Empty routes: inference request gets 503 but returns Ok(true).
3434+
let ctx = InferenceContext::new(patterns, router, vec![], vec![]);
3435+
3436+
let body = r#"{"model":"test","messages":[{"role":"user","content":"hi"}]}"#;
3437+
let inference_req = format!(
3438+
"POST /v1/chat/completions HTTP/1.1\r\n\
3439+
Host: inference.local\r\n\
3440+
Content-Type: application/json\r\n\
3441+
Content-Length: {}\r\n\r\n{}",
3442+
body.len(),
3443+
body,
3444+
);
3445+
let non_inference_req = "GET /admin/config HTTP/1.1\r\nHost: inference.local\r\n\r\n";
3446+
3447+
let (client, mut server) = tokio::io::duplex(65536);
3448+
let (mut client_read, mut client_write) = tokio::io::split(client);
3449+
3450+
// Spawn the server task so it runs concurrently.
3451+
let server_task =
3452+
tokio::spawn(async move { process_inference_keepalive(&mut server, &ctx, 443).await });
3453+
3454+
// Client: send inference request, read response, send non-inference.
3455+
client_write
3456+
.write_all(inference_req.as_bytes())
3457+
.await
3458+
.unwrap();
3459+
3460+
// Read the 503 response so the server loops back to read.
3461+
let mut buf = vec![0u8; 4096];
3462+
let _ = client_read.read(&mut buf).await.unwrap();
3463+
3464+
// Send non-inference request on the same keep-alive connection.
3465+
client_write
3466+
.write_all(non_inference_req.as_bytes())
3467+
.await
3468+
.unwrap();
3469+
drop(client_write);
3470+
3471+
// Drain remaining response bytes.
3472+
tokio::spawn(async move {
3473+
let mut buf = vec![0u8; 4096];
3474+
loop {
3475+
match client_read.read(&mut buf).await {
3476+
Ok(0) | Err(_) => break,
3477+
Ok(_) => continue,
3478+
}
3479+
}
3480+
});
3481+
3482+
let outcome = server_task.await.unwrap().unwrap();
3483+
3484+
assert!(
3485+
matches!(outcome, InferenceOutcome::Denied { .. }),
3486+
"expected Denied after non-inference request on keep-alive, got: {outcome:?}"
3487+
);
3488+
}
33583489
}

0 commit comments

Comments
 (0)