Skip to content

Commit 1cb4ce2

Browse files
authored
feat(sandbox): bound proxy connections and finish the SOCKS5 fuzzing (#290)
* feat(sandbox): bound proxy connections and finish the SOCKS5 fuzzing Three gaps #246 left open. The accept loop spawned a thread per connection with no bound, so anything that can reach the port could make the proxy spawn threads, and for IP-mode clients drive a DNS lookup per allowlist hostname on each. Past 64 in flight the proxy now closes the connection rather than queuing it; a SOCKS5 client sees a failed connect. The counter is raised before the spawn and lowered by a failed one, so the cap cannot drift downward. Case-insensitive matching is documented but was not asserted: the target now requires the same verdict for a host with its case flipped. Removing the fold in is_host_allowed fails it on "c" vs "C". is_ip_allowed had no coverage because it resolves allowlisted hostnames. It is reachable through NetAllowlist::is_ip_allowed, and the target restricts its patterns to the shapes that function never resolves — literal IPs, "*" and "*." wildcards — so it stays off the network. The resolving branch needs a stubbed resolver and is still uncovered. Signed-off-by: Luca Muscariello <muscariello@ieee.org> * test(sandbox): unit-test NetAllowlist::is_ip_allowed The method was added for the fuzz target, and fuzz targets are not in the coverage build, so a new public method arrived with nothing exercising it — codecov put the patch at 79.6%. Literal IPv4 and IPv6 matches, deny-by-default on an empty list, the "*" short-circuit ahead of address parsing, and a "*." pattern that cannot match an address on its own. All patterns are literals or wildcards so the tests never reach the resolver. Signed-off-by: Luca Muscariello <muscariello@ieee.org> * refactor(sandbox): make the connection permit RAII and cover the rest Measuring locally rather than reading codecov's per-line field left five uncovered lines among the change. Three were the failed-spawn branch that lowered the counter by hand. A permit that lowers it on drop removes the branch instead of testing it: the handler holds it, and a spawn that never runs the closure drops it too. One exit path per counter beats one branch per exit path. The other two were a poisoned-lock arm, now tested — a panic while the allowlist was held must not become permission to connect — and an Err arm in the shedding test's own connect loop, which cannot fire below the cap and is now an outright failure instead. Every added line in net_proxy.rs is covered. Signed-off-by: Luca Muscariello <muscariello@ieee.org> --------- Signed-off-by: Luca Muscariello <muscariello@ieee.org>
1 parent 488446e commit 1cb4ce2

2 files changed

Lines changed: 207 additions & 1 deletion

File tree

crates/shadi_sandbox/fuzz/fuzz_targets/socks5-frame.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,58 @@ fuzz_target!(|data: &[u8]| {
4747
"empty allowlist accepted {host}"
4848
);
4949
let _ = allowlist.is_allowed(host);
50+
51+
// Matching is documented as case-insensitive, so the same host in a
52+
// different case cannot produce a different verdict. Both the host and
53+
// the patterns are folded, so flipping either side must agree.
54+
let flipped: String = host
55+
.chars()
56+
.map(|c| {
57+
if c.is_ascii_lowercase() {
58+
c.to_ascii_uppercase()
59+
} else {
60+
c.to_ascii_lowercase()
61+
}
62+
})
63+
.collect();
64+
assert_eq!(
65+
allowlist.is_allowed(host),
66+
allowlist.is_allowed(&flipped),
67+
"case changed the verdict for {host:?} vs {flipped:?}"
68+
);
69+
}
70+
71+
// is_ip_allowed resolves any allowlisted hostname to compare against the
72+
// incoming IP, so the patterns here are restricted to the shapes it never
73+
// resolves — literal IPs, `*`, and `*.` wildcards — to keep the target off
74+
// the network. The resolving branch needs a stubbed resolver and is not
75+
// covered here.
76+
let ip_safe: Vec<String> = patterns
77+
.iter()
78+
.filter(|p| {
79+
let t = p.trim();
80+
t == "*" || t.starts_with("*.") || t.parse::<std::net::IpAddr>().is_ok()
81+
})
82+
.cloned()
83+
.collect();
84+
let ip_list = NetAllowlist::new(ip_safe.clone());
85+
let wide_open = ip_safe.iter().any(|p| p.trim() == "*");
86+
87+
for candidate in ["127.0.0.1", "::1", "10.0.0.1", "not-an-ip"] {
88+
assert!(
89+
!empty.is_ip_allowed(candidate),
90+
"empty allowlist accepted ip {candidate}"
91+
);
92+
let verdict = ip_list.is_ip_allowed(candidate);
93+
if wide_open {
94+
// `*` short-circuits before the IP is parsed, so even a
95+
// non-address is allowed by it.
96+
assert!(verdict, "`*` did not allow {candidate}");
97+
} else if candidate == "not-an-ip" && !ip_safe.iter().any(|p| p.trim() == candidate) {
98+
assert!(
99+
!verdict,
100+
"an unparseable address was allowed without a matching pattern"
101+
);
102+
}
50103
}
51104
});

crates/shadi_sandbox/src/net_proxy.rs

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ impl NetAllowlist {
111111
};
112112
is_host_allowed(host, &guard)
113113
}
114+
115+
/// Check whether a literal `ip` is permitted by the current list.
116+
///
117+
/// Unlike [`Self::is_allowed`] this also matches an allowlisted hostname
118+
/// that resolves to `ip`, so it can perform DNS lookups.
119+
pub fn is_ip_allowed(&self, ip: &str) -> bool {
120+
let guard = match self.0.read() {
121+
Ok(g) => g,
122+
Err(_) => return false,
123+
};
124+
is_ip_allowed(ip, &guard)
125+
}
114126
}
115127

116128
fn is_host_allowed(host: &str, list: &[String]) -> bool {
@@ -303,22 +315,68 @@ impl Drop for NetProxy {
303315
// Accept loop
304316
// ---------------------------------------------------------------------------
305317

318+
/// Concurrent connection handlers the proxy will run at once.
319+
///
320+
/// One thread per accepted connection is unbounded: anything that can reach
321+
/// the port — the sandboxed process, or any process of the same user — can
322+
/// make the proxy spawn threads and, for IP-mode clients, drive a DNS lookup
323+
/// per allowlist hostname on each one. Past this many the proxy sheds load by
324+
/// closing the connection instead of queuing it, which a SOCKS5 client sees
325+
/// as a failed connect.
326+
pub const MAX_CONCURRENT_CONNECTIONS: usize = 64;
327+
328+
/// Raises the in-flight connection count for as long as it is held.
329+
struct ConnectionPermit(Arc<std::sync::atomic::AtomicUsize>);
330+
331+
impl ConnectionPermit {
332+
fn acquire(counter: &Arc<std::sync::atomic::AtomicUsize>) -> Self {
333+
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
334+
Self(Arc::clone(counter))
335+
}
336+
}
337+
338+
impl Drop for ConnectionPermit {
339+
fn drop(&mut self) {
340+
self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
341+
}
342+
}
343+
306344
fn accept_loop(
307345
listener: TcpListener,
308346
allowlist: NetAllowlist,
309347
stop: Arc<std::sync::atomic::AtomicBool>,
310348
) {
311349
listener.set_nonblocking(false).ok();
350+
let in_flight = Arc::new(std::sync::atomic::AtomicUsize::new(0));
312351
loop {
313352
match listener.accept() {
314353
Ok((stream, _peer)) => {
315354
if stop.load(std::sync::atomic::Ordering::SeqCst) {
316355
break;
317356
}
357+
if in_flight.load(std::sync::atomic::Ordering::SeqCst)
358+
>= MAX_CONCURRENT_CONNECTIONS
359+
{
360+
warn!(
361+
"net proxy: {} connections in flight, shedding",
362+
MAX_CONCURRENT_CONNECTIONS
363+
);
364+
drop(stream);
365+
continue;
366+
}
318367
let al = allowlist.clone();
368+
// Held by the handler, so the count falls however the handler
369+
// ends — including a spawn that never runs it, which drops the
370+
// closure and the permit with it. A hand-rolled decrement
371+
// needs a branch per exit and leaks the cap downward if one
372+
// is missed.
373+
let permit = ConnectionPermit::acquire(&in_flight);
319374
thread::Builder::new()
320375
.name("shadi-proxy-conn".into())
321-
.spawn(move || handle_connection(stream, al))
376+
.spawn(move || {
377+
let _permit = permit;
378+
handle_connection(stream, al);
379+
})
322380
.ok();
323381
}
324382
Err(ref e) if e.kind() == std::io::ErrorKind::ConnectionAborted => continue,
@@ -803,6 +861,101 @@ mod tests {
803861
assert_eq!(&buf, b"hello");
804862
}
805863

864+
#[test]
865+
fn is_ip_allowed_matches_literals_and_denies_by_default() {
866+
// Only literal IPs and `*` here: a hostname pattern would send
867+
// is_ip_allowed to the resolver, and a unit test must not do DNS.
868+
let empty = NetAllowlist::new(vec![]);
869+
assert!(!empty.is_ip_allowed("127.0.0.1"), "empty list is deny-all");
870+
assert!(!empty.is_ip_allowed("not-an-ip"));
871+
872+
let literal = NetAllowlist::new(vec!["127.0.0.1".into(), "10.0.0.7".into()]);
873+
assert!(literal.is_ip_allowed("127.0.0.1"));
874+
assert!(literal.is_ip_allowed("10.0.0.7"));
875+
assert!(!literal.is_ip_allowed("10.0.0.8"));
876+
877+
// `*` short-circuits before the address is parsed, so it allows even
878+
// a value that is not an address.
879+
let open = NetAllowlist::new(vec!["*".into()]);
880+
assert!(open.is_ip_allowed("127.0.0.1"));
881+
assert!(open.is_ip_allowed("not-an-ip"));
882+
883+
// A `*.` pattern cannot be resolved to a fixed address, so it never
884+
// matches an IP on its own.
885+
let wildcard = NetAllowlist::new(vec!["*.example.com".into()]);
886+
assert!(!wildcard.is_ip_allowed("127.0.0.1"));
887+
}
888+
889+
#[test]
890+
fn is_ip_allowed_denies_when_the_lock_is_poisoned() {
891+
// A poisoned allowlist must fail closed, like is_allowed does: a
892+
// panic while the list was held cannot become permission to connect.
893+
let list = NetAllowlist::new(vec!["127.0.0.1".into()]);
894+
assert!(list.is_ip_allowed("127.0.0.1"), "sanity before poisoning");
895+
896+
let poisoner = list.clone();
897+
let _ = std::thread::spawn(move || {
898+
let _held = poisoner.0.write().unwrap();
899+
panic!("poison the allowlist lock");
900+
})
901+
.join();
902+
903+
assert!(
904+
list.0.read().is_err(),
905+
"the lock should be poisoned for this test to mean anything"
906+
);
907+
assert!(
908+
!list.is_ip_allowed("127.0.0.1"),
909+
"a poisoned allowlist allowed a connection"
910+
);
911+
}
912+
913+
#[test]
914+
fn is_ip_allowed_accepts_ipv6_literals() {
915+
let list = NetAllowlist::new(vec!["::1".into()]);
916+
assert!(list.is_ip_allowed("::1"));
917+
assert!(!list.is_ip_allowed("::2"));
918+
}
919+
920+
#[test]
921+
fn proxy_sheds_load_past_the_concurrency_cap() {
922+
let _guard = lock_proxy_ports();
923+
// Deny everything: handle_connection still reads the greeting, so each
924+
// held connection occupies a handler thread without needing upstream.
925+
let proxy = NetProxy::start(NetAllowlist::new(vec![])).unwrap();
926+
let port = proxy.port();
927+
928+
// Open the cap's worth of connections and leave them mid-handshake,
929+
// so every handler thread is parked on a read.
930+
let mut held = Vec::new();
931+
for i in 0..MAX_CONCURRENT_CONNECTIONS {
932+
let conn = std::net::TcpStream::connect(format!("127.0.0.1:{port}"))
933+
.unwrap_or_else(|e| panic!("proxy refused connection {i} below its own cap: {e}"));
934+
held.push(conn);
935+
}
936+
937+
// Give the accept loop time to spawn a handler for each.
938+
std::thread::sleep(std::time::Duration::from_millis(200));
939+
940+
// One more: the proxy accepts the TCP connection (the listener backlog
941+
// does that) and then closes it without a SOCKS5 reply. A client sees
942+
// EOF rather than a greeting response.
943+
let mut extra = std::net::TcpStream::connect(format!("127.0.0.1:{port}"))
944+
.expect("listener still accepts");
945+
extra.write_all(&[5, 1, 0]).ok();
946+
extra
947+
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
948+
.ok();
949+
let mut reply = [0u8; 2];
950+
let read = extra.read(&mut reply);
951+
assert!(
952+
matches!(read, Ok(0)) || read.is_err(),
953+
"a shed connection answered the greeting: {read:?}"
954+
);
955+
956+
drop(held);
957+
}
958+
806959
#[test]
807960
fn proxy_restart_rebinds_to_same_port() {
808961
// Serialize against other tests that bind port 0 so the OS cannot

0 commit comments

Comments
 (0)