1use std::collections::HashSet;
25use std::io::{self, BufRead, BufReader, Read, Write};
26use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs};
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::{Arc, Mutex};
29use std::thread::{self, JoinHandle};
30use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
31
32use serde::{Deserialize, Serialize};
33
34const MAX_HEAD: usize = 8 * 1024;
37const CONN_TIMEOUT: Duration = Duration::from_secs(30);
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum NetKind {
48 Connect,
50 Http,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum NetDecision {
58 Allowed,
60 Denied,
62 Error,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct NetAuditEvent {
71 pub ts_ms: u64,
73 pub host: String,
75 pub port: u16,
77 pub kind: NetKind,
79 pub decision: NetDecision,
81 pub bytes_up: u64,
83 pub bytes_down: u64,
85 pub dur_ms: u64,
87}
88
89pub trait AuditSink: Send + Sync {
93 fn record(&self, event: &NetAuditEvent);
95}
96
97pub struct NullSink;
99
100impl AuditSink for NullSink {
101 fn record(&self, _event: &NetAuditEvent) {}
102}
103
104pub struct JsonlSink<W: Write + Send>(Mutex<W>);
106
107impl<W: Write + Send> JsonlSink<W> {
108 pub fn new(w: W) -> Self {
110 Self(Mutex::new(w))
111 }
112}
113
114impl<W: Write + Send> AuditSink for JsonlSink<W> {
115 fn record(&self, event: &NetAuditEvent) {
116 if let (Ok(mut w), Ok(mut line)) = (self.0.lock(), serde_json::to_string(event)) {
117 line.push('\n');
118 let _ = w.write_all(line.as_bytes());
119 let _ = w.flush();
120 }
121 }
122}
123
124fn now_ms() -> u64 {
126 SystemTime::now()
127 .duration_since(UNIX_EPOCH)
128 .map(|d| d.as_millis() as u64)
129 .unwrap_or(0)
130}
131
132pub trait Resolver: Send + Sync {
135 fn resolve(&self, host: &str, port: u16) -> io::Result<SocketAddr>;
137}
138
139pub struct StdResolver;
142
143impl Resolver for StdResolver {
144 fn resolve(&self, host: &str, port: u16) -> io::Result<SocketAddr> {
145 (host, port)
146 .to_socket_addrs()?
147 .next()
148 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no address for host"))
149 }
150}
151
152pub trait Conn: Read + Write + Send {
166 fn dup(&self) -> io::Result<Box<dyn Conn>>;
171 fn shutdown(&self, how: Shutdown) -> io::Result<()>;
174 fn set_timeouts(&self, dur: Duration) -> io::Result<()>;
177}
178
179impl Conn for TcpStream {
180 fn dup(&self) -> io::Result<Box<dyn Conn>> {
181 Ok(Box::new(self.try_clone()?))
182 }
183 fn shutdown(&self, how: Shutdown) -> io::Result<()> {
184 TcpStream::shutdown(self, how)
185 }
186 fn set_timeouts(&self, dur: Duration) -> io::Result<()> {
187 self.set_read_timeout(Some(dur))?;
188 self.set_write_timeout(Some(dur))?;
189 Ok(())
190 }
191}
192
193pub trait Connector: Send + Sync {
198 fn connect(&self, addr: SocketAddr) -> io::Result<Box<dyn Conn>>;
200}
201
202pub struct TcpConnector;
204
205impl Connector for TcpConnector {
206 fn connect(&self, addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
207 let s = TcpStream::connect_timeout(&addr, CONN_TIMEOUT)?;
208 s.set_read_timeout(Some(CONN_TIMEOUT))?;
209 s.set_write_timeout(Some(CONN_TIMEOUT))?;
210 Ok(Box::new(s))
211 }
212}
213
214#[derive(Debug, PartialEq, Eq)]
216enum Target {
217 Connect { host: String, port: u16 },
219 Http {
222 host: String,
223 port: u16,
224 origin_line: String,
225 },
226}
227
228fn parse_request_line(line: &str) -> Option<Target> {
231 let line = line.trim_end_matches(['\r', '\n']);
232 let mut parts = line.split(' ');
233 let method = parts.next()?;
234 let uri = parts.next()?;
235 let version = parts.next()?;
236 if !version.starts_with("HTTP/") || parts.next().is_some() {
237 return None;
238 }
239 if method.eq_ignore_ascii_case("CONNECT") {
240 let (host, port) = split_host_port(uri, 443)?;
241 return Some(Target::Connect { host, port });
242 }
243 let rest = uri.strip_prefix("http://")?;
245 let (authority, path) = match rest.find('/') {
246 Some(i) => (&rest[..i], &rest[i..]),
247 None => (rest, "/"),
248 };
249 let (host, port) = split_host_port(authority, 80)?;
250 Some(Target::Http {
251 host,
252 port,
253 origin_line: format!("{method} {path} {version}\r\n"),
254 })
255}
256
257fn split_host_port(authority: &str, default_port: u16) -> Option<(String, u16)> {
260 if let Some(rest) = authority.strip_prefix('[') {
262 let (host, after) = rest.split_once(']')?;
263 let port = match after.strip_prefix(':') {
264 Some(p) => p.parse().ok()?,
265 None if after.is_empty() => default_port,
266 None => return None,
267 };
268 return Some((host.to_string(), port));
269 }
270 match authority.rsplit_once(':') {
271 Some((h, _)) if h.contains(':') => Some((authority.to_string(), default_port)),
275 Some(("", _)) => None,
277 Some((h, p)) => Some((h.to_string(), p.parse::<u16>().ok()?)),
279 None => Some((authority.to_string(), default_port)),
281 }
282}
283
284#[derive(Clone)]
286struct HostPolicy {
287 allowed: Arc<HashSet<String>>,
288 private: Arc<HashSet<String>>,
289}
290
291impl HostPolicy {
292 fn new(hosts: impl IntoIterator<Item = String>) -> Self {
293 Self {
294 allowed: Arc::new(hosts.into_iter().collect()),
295 private: Arc::new(HashSet::new()),
296 }
297 }
298 fn allows(&self, host: &str) -> bool {
299 self.allowed.contains(host)
300 }
301 fn with_private_hosts(
302 hosts: impl IntoIterator<Item = String>,
303 private_hosts: impl IntoIterator<Item = String>,
304 ) -> io::Result<Self> {
305 let mut policy = Self::new(hosts);
306 policy.private = Arc::new(canonical_private_hosts(private_hosts)?);
307 Ok(policy)
308 }
309 fn allows_private(&self, host: &str) -> bool {
310 self.allows(host)
311 && canonical_private_host(host).is_some_and(|host| self.private.contains(&host))
312 }
313}
314
315fn canonical_private_host(host: &str) -> Option<String> {
318 use std::net::IpAddr;
319 if let Ok(ip) = host.parse::<IpAddr>() {
320 return Some(ip.to_string());
321 }
322 if let Some(ip) = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
323 return ip
324 .parse::<std::net::Ipv6Addr>()
325 .ok()
326 .map(|ip| ip.to_string());
327 }
328 let host = host.strip_suffix('.').unwrap_or(host);
329 if host.is_empty() || host.len() > 253 || !host.is_ascii() {
330 return None;
331 }
332 let labels: Vec<_> = host.split('.').collect();
333 if labels.iter().any(|label| {
334 label.is_empty()
335 || label.len() > 63
336 || label.starts_with('-')
337 || label.ends_with('-')
338 || !label
339 .bytes()
340 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
341 }) || labels.last().is_some_and(|label| {
342 label.bytes().all(|b| b.is_ascii_digit())
343 || label.starts_with("0x")
344 || label.starts_with("0X")
345 }) {
346 return None;
347 }
348 Some(host.to_ascii_lowercase())
349}
350
351pub fn canonical_private_hosts(
355 hosts: impl IntoIterator<Item = String>,
356) -> io::Result<HashSet<String>> {
357 hosts
358 .into_iter()
359 .map(|host| {
360 canonical_private_host(&host).ok_or_else(|| {
361 io::Error::new(
362 io::ErrorKind::InvalidInput,
363 "private-host grants require exact DNS names or IP addresses",
364 )
365 })
366 })
367 .collect()
368}
369
370#[derive(Debug)]
372pub struct ProxyHandle {
373 addr: SocketAddr,
374 shutdown: Arc<AtomicBool>,
375 accept: Option<JoinHandle<()>>,
376 refused: Arc<Mutex<HashSet<String>>>,
380}
381
382impl ProxyHandle {
383 pub fn addr(&self) -> SocketAddr {
388 self.addr
389 }
390
391 #[must_use]
397 pub fn refused_hosts(&self) -> Vec<String> {
398 self.refused
399 .lock()
400 .map(|s| {
401 let mut v: Vec<String> = s.iter().cloned().collect();
402 v.sort();
403 v
404 })
405 .unwrap_or_default()
406 }
407
408 #[must_use]
413 pub fn proxy_env(&self) -> Vec<(String, String)> {
414 let url = format!("http://{}", self.addr);
415 [
416 "http_proxy",
417 "https_proxy",
418 "all_proxy",
419 "HTTP_PROXY",
420 "HTTPS_PROXY",
421 "ALL_PROXY",
422 ]
423 .iter()
424 .map(|k| ((*k).to_string(), url.clone()))
425 .collect()
426 }
427}
428
429impl Drop for ProxyHandle {
430 fn drop(&mut self) {
431 self.shutdown.store(true, Ordering::SeqCst);
432 let _ = TcpStream::connect_timeout(&self.addr, Duration::from_millis(200));
434 if let Some(h) = self.accept.take() {
435 let _ = h.join();
436 }
437 }
438}
439
440pub fn start(
448 allow_hosts: impl IntoIterator<Item = String>,
449 resolver: Arc<dyn Resolver>,
450 sink: Arc<dyn AuditSink>,
451) -> io::Result<ProxyHandle> {
452 start_with_private_hosts(allow_hosts, [], resolver, sink)
453}
454
455pub fn start_with_private_hosts(
465 allow_hosts: impl IntoIterator<Item = String>,
466 private_hosts: impl IntoIterator<Item = String>,
467 resolver: Arc<dyn Resolver>,
468 sink: Arc<dyn AuditSink>,
469) -> io::Result<ProxyHandle> {
470 let policy = HostPolicy::with_private_hosts(allow_hosts, private_hosts)?;
471 let listener = TcpListener::bind(("127.0.0.1", 0))?;
472 let addr = listener.local_addr()?;
473 let shutdown = Arc::new(AtomicBool::new(false));
474 let connector: Arc<dyn Connector> = Arc::new(TcpConnector);
477 let refused: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
479
480 let accept = {
481 let shutdown = Arc::clone(&shutdown);
482 let refused = Arc::clone(&refused);
483 thread::Builder::new()
484 .name("agent-bridle-egress-proxy".to_string())
485 .spawn(move || {
486 for stream in listener.incoming() {
487 if shutdown.load(Ordering::SeqCst) {
488 break;
489 }
490 let Ok(client) = stream else { continue };
491 let policy = policy.clone();
492 let resolver = Arc::clone(&resolver);
493 let connector = Arc::clone(&connector);
494 let sink = Arc::clone(&sink);
495 let refused = Arc::clone(&refused);
496 let _ = thread::Builder::new()
498 .name("agent-bridle-egress-conn".to_string())
499 .spawn(move || {
500 let _ = handle_conn(
501 Box::new(client),
502 &policy,
503 connector.as_ref(),
504 resolver.as_ref(),
505 sink.as_ref(),
506 &refused,
507 );
508 });
509 }
510 })?
511 };
512
513 Ok(ProxyHandle {
514 addr,
515 shutdown,
516 accept: Some(accept),
517 refused,
518 })
519}
520
521pub fn start_egress_proxy(caveats: &crate::Caveats) -> io::Result<Option<ProxyHandle>> {
543 let Some(hosts) = crate::net_egress_proxy_hosts(caveats) else {
544 return Ok(None);
545 };
546 start_for_hosts(hosts).map(Some)
547}
548
549pub fn start_for_hosts(allow_hosts: impl IntoIterator<Item = String>) -> io::Result<ProxyHandle> {
554 start(allow_hosts, Arc::new(StdResolver), Arc::new(NullSink))
555}
556
557fn handle_conn(
561 client: Box<dyn Conn>,
562 policy: &HostPolicy,
563 connector: &dyn Connector,
564 resolver: &dyn Resolver,
565 sink: &dyn AuditSink,
566 refused: &Mutex<HashSet<String>>,
567) -> io::Result<()> {
568 client.set_timeouts(CONN_TIMEOUT)?;
569 let mut reader = BufReader::new(client.dup()?);
570 let t0 = Instant::now();
571
572 let line = read_line_bounded(&mut reader)?;
573 let Some(target) = parse_request_line(&line) else {
574 return respond(client.as_ref(), 400, "Bad Request");
576 };
577
578 let (host, port, kind) = match &target {
579 Target::Connect { host, port } => (host.clone(), *port, NetKind::Connect),
580 Target::Http { host, port, .. } => (host.clone(), *port, NetKind::Http),
581 };
582 let audit = |decision: NetDecision, up: u64, down: u64| {
584 sink.record(&NetAuditEvent {
585 ts_ms: now_ms(),
586 host: host.clone(),
587 port,
588 kind,
589 decision,
590 bytes_up: up,
591 bytes_down: down,
592 dur_ms: t0.elapsed().as_millis() as u64,
593 });
594 };
595
596 if !policy.allows(&host) {
597 audit(NetDecision::Denied, 0, 0); if let Ok(mut set) = refused.lock() {
601 set.insert(host.clone());
602 }
603 return respond(client.as_ref(), 403, "Forbidden");
604 }
605
606 match target {
607 Target::Connect { host, port } => {
608 drain_headers(&mut reader)?;
611 let origin = match resolver
612 .resolve(&host, port)
613 .and_then(|addr| guard_target(addr, &host, policy))
614 .and_then(|addr| connector.connect(addr))
615 {
616 Ok(o) => o,
617 Err(_) => {
618 audit(NetDecision::Error, 0, 0);
619 return respond(client.as_ref(), 502, "Bad Gateway");
620 }
621 };
622 let mut client = client;
626 client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?;
627 let (up, down) = splice_buffered(reader, client, origin)?;
632 audit(NetDecision::Allowed, up, down);
633 Ok(())
634 }
635 Target::Http {
636 host,
637 port,
638 origin_line,
639 } => {
640 let headers = read_headers(&mut reader)?;
646 let mut origin = match resolver
647 .resolve(&host, port)
648 .and_then(|addr| guard_target(addr, &host, policy))
649 .and_then(|addr| connector.connect(addr))
650 {
651 Ok(o) => o,
652 Err(_) => {
653 audit(NetDecision::Error, 0, 0);
654 return respond(client.as_ref(), 502, "Bad Gateway");
655 }
656 };
657 let host_hdr = if port == 80 {
658 format!("Host: {host}\r\n")
659 } else {
660 format!("Host: {host}:{port}\r\n")
661 };
662 origin.write_all(origin_line.as_bytes())?;
663 origin.write_all(host_hdr.as_bytes())?;
664 for h in &headers {
665 if !h.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("host:")) {
666 origin.write_all(h.as_bytes())?;
667 }
668 }
669 origin.write_all(b"\r\n")?; let (up, down) = splice_buffered(reader, client, origin)?; audit(NetDecision::Allowed, up, down);
672 Ok(())
673 }
674 }
675}
676
677fn read_line_bounded<R: Read>(reader: &mut BufReader<R>) -> io::Result<String> {
681 let mut buf = Vec::new();
682 reader.take(MAX_HEAD as u64).read_until(b'\n', &mut buf)?;
683 Ok(String::from_utf8_lossy(&buf).into_owned())
684}
685
686fn read_headers<R: Read>(reader: &mut BufReader<R>) -> io::Result<Vec<String>> {
689 let mut lines = Vec::new();
690 let mut total = 0usize;
691 loop {
692 let line = read_line_bounded(reader)?;
693 total += line.len();
694 if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
695 return Ok(lines);
696 }
697 lines.push(line);
698 }
699}
700
701fn drain_headers<R: Read>(reader: &mut BufReader<R>) -> io::Result<()> {
703 let mut total = 0usize;
704 loop {
705 let line = read_line_bounded(reader)?;
706 total += line.len();
707 if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
708 return Ok(());
709 }
710 }
711}
712
713fn respond(client: &dyn Conn, code: u16, reason: &str) -> io::Result<()> {
715 let mut c = client.dup()?;
716 let body = format!("{code} {reason}\n");
717 write!(
718 c,
719 "HTTP/1.1 {code} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
720 body.len()
721 )?;
722 let _ = c.flush();
723 let _ = client.shutdown(Shutdown::Both);
724 Ok(())
725}
726
727fn copy_counted(from: &mut impl Read, to: &mut impl Write) -> u64 {
732 let mut buf = [0u8; 16 * 1024];
733 let mut total = 0u64;
734 loop {
735 match from.read(&mut buf) {
736 Ok(0) => break,
737 Ok(n) => {
738 if to.write_all(&buf[..n]).is_err() {
739 break;
740 }
741 total += n as u64;
742 }
743 Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
744 Err(_) => break,
745 }
746 }
747 total
748}
749
750fn guard_target(addr: SocketAddr, host: &str, policy: &HostPolicy) -> io::Result<SocketAddr> {
765 let ip = match addr.ip() {
766 std::net::IpAddr::V6(ip) => ip.to_ipv4_mapped().map_or(addr.ip(), std::net::IpAddr::V4),
767 ip => ip,
768 };
769 let approvable = match ip {
770 std::net::IpAddr::V4(ip) => ip.is_private(),
771 std::net::IpAddr::V6(ip) => {
772 (ip.segments()[0] & 0xfe00) == 0xfc00
773 && ip.segments() != [0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254]
775 }
776 };
777 if is_internal_ip(&ip) && !(approvable && policy.allows_private(host)) {
778 return Err(io::Error::new(
779 io::ErrorKind::PermissionDenied,
780 "SSRF-guard: refusing to proxy to an internal (non-loopback) address",
781 ));
782 }
783 Ok(addr)
784}
785
786fn is_internal_ip(ip: &std::net::IpAddr) -> bool {
789 match ip {
790 std::net::IpAddr::V4(v4) => {
791 let o = v4.octets();
792 v4.is_private()
793 || v4.is_link_local()
794 || v4.is_unspecified()
795 || v4.is_broadcast()
796 || v4.is_multicast()
797 || v4.is_documentation()
798 || o[0] == 0 || o[0] >= 240
799 || (o[0] == 198 && (o[1] == 18 || o[1] == 19))
800 || (o[0] == 100 && (64..=127).contains(&o[1]))
802 }
803 std::net::IpAddr::V6(v6) => {
804 if let Some(v4) = v6.to_ipv4_mapped() {
805 return is_internal_ip(&std::net::IpAddr::V4(v4));
806 }
807 let seg0 = v6.segments()[0];
808 v6.is_unspecified()
809 || v6.is_multicast()
810 || (seg0 & 0xfe00) == 0xfc00 || (seg0 & 0xffc0) == 0xfe80 || (seg0 & 0xffc0) == 0xfec0 || (seg0 == 0x2001 && v6.segments()[1] == 0x0db8) }
815 }
816}
817
818fn splice_buffered(
822 mut client_reader: BufReader<Box<dyn Conn>>,
823 client: Box<dyn Conn>,
824 origin: Box<dyn Conn>,
825) -> io::Result<(u64, u64)> {
826 let mut o_write = origin.dup()?;
827 let up = thread::spawn(move || {
828 let n = copy_counted(&mut client_reader, &mut o_write);
829 let _ = o_write.shutdown(Shutdown::Write);
830 n
831 });
832 let mut o_read = origin;
833 let mut c_write = client;
834 let down = copy_counted(&mut o_read, &mut c_write);
835 let _ = c_write.shutdown(Shutdown::Both);
844 let up = up.join().unwrap_or(0);
845 Ok((up, down))
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851 use std::collections::VecDeque;
852
853 #[test]
859 fn start_egress_proxy_is_none_for_non_proxy_grants() {
860 use crate::{Caveats, Scope};
861 let all = Caveats::top();
862 assert!(start_egress_proxy(&all).unwrap().is_none(), "net: All");
863 let deny = Caveats {
864 net: Scope::only([] as [String; 0]),
865 ..Caveats::top()
866 };
867 assert!(start_egress_proxy(&deny).unwrap().is_none(), "deny-all");
868 let loopback = Caveats {
869 net: Scope::only(["localhost".to_string()]),
870 ..Caveats::top()
871 };
872 assert!(
873 start_egress_proxy(&loopback).unwrap().is_none(),
874 "loopback-only"
875 );
876 }
877
878 #[test]
883 fn start_egress_proxy_serves_a_remote_host_grant_and_refuses_off_list() {
884 use crate::{Caveats, Scope};
885 use std::io::{Read as _, Write as _};
886 let granted = Caveats {
887 net: Scope::only(["api.example.com".to_string()]),
888 ..Caveats::top()
889 };
890 let handle = start_egress_proxy(&granted)
891 .expect("bind loopback")
892 .expect("a remote-host grant calls for the proxy");
893 assert!(
894 handle.addr().ip().is_loopback(),
895 "proxy binds loopback only"
896 );
897
898 let mut client = TcpStream::connect(handle.addr()).expect("dial proxy");
899 client
900 .write_all(
901 b"CONNECT evil.example.net:443 HTTP/1.1\r\nHost: evil.example.net:443\r\n\r\n",
902 )
903 .expect("send CONNECT");
904 let mut reply = String::new();
907 client
908 .set_read_timeout(Some(Duration::from_secs(10)))
909 .expect("timeout");
910 let _ = client.read_to_string(&mut reply);
911 assert!(
912 reply.contains("403"),
913 "off-list CONNECT must be refused: {reply:?}"
914 );
915 assert!(
916 handle
917 .refused_hosts()
918 .contains(&"evil.example.net".to_string()),
919 "refusal must be recorded: {:?}",
920 handle.refused_hosts()
921 );
922 }
923
924 #[test]
929 fn guard_target_refuses_internal_permits_loopback_and_global() {
930 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
931 let refused: [IpAddr; 7] = [
934 Ipv4Addr::new(10, 0, 0, 5).into(), Ipv4Addr::new(172, 16, 9, 9).into(), Ipv4Addr::new(192, 168, 1, 1).into(), Ipv4Addr::new(169, 254, 169, 254).into(), Ipv4Addr::new(100, 64, 0, 1).into(), "fe80::1".parse().unwrap(), "fc00::1".parse().unwrap(), ];
942 for ip in refused {
943 assert!(is_internal_ip(&ip), "{ip} must classify as internal");
944 assert!(
945 guard_target(SocketAddr::new(ip, 80), "", &HostPolicy::new([])).is_err(),
946 "{ip} must be refused"
947 );
948 }
949 let allowed = [
950 "127.0.0.1",
951 "::1",
952 "8.8.8.8",
953 "1.1.1.1",
954 "2606:4700:4700::1111",
955 ];
956 for s in allowed {
957 let ip: IpAddr = s.parse().unwrap();
958 assert!(!is_internal_ip(&ip), "{s} must be permitted");
959 assert!(
960 guard_target(SocketAddr::new(ip, 443), "", &HostPolicy::new([])).is_ok(),
961 "{s} must be permitted"
962 );
963 }
964 }
965
966 #[test]
967 fn parses_connect() {
968 assert_eq!(
969 parse_request_line("CONNECT example.com:443 HTTP/1.1\r\n"),
970 Some(Target::Connect {
971 host: "example.com".to_string(),
972 port: 443
973 })
974 );
975 assert_eq!(
977 parse_request_line("CONNECT example.com HTTP/1.1"),
978 Some(Target::Connect {
979 host: "example.com".to_string(),
980 port: 443
981 })
982 );
983 }
984
985 #[test]
986 fn parses_http_absolute_form_and_rewrites_to_origin_form() {
987 let t = parse_request_line("GET http://example.com/a/b?q=1 HTTP/1.1\r\n").unwrap();
988 assert_eq!(
989 t,
990 Target::Http {
991 host: "example.com".to_string(),
992 port: 80,
993 origin_line: "GET /a/b?q=1 HTTP/1.1\r\n".to_string(),
994 }
995 );
996 let t = parse_request_line("HEAD http://h:8080 HTTP/1.0").unwrap();
998 assert_eq!(
999 t,
1000 Target::Http {
1001 host: "h".to_string(),
1002 port: 8080,
1003 origin_line: "HEAD / HTTP/1.0\r\n".to_string(),
1004 }
1005 );
1006 }
1007
1008 #[test]
1009 fn parses_ipv6_authority() {
1010 assert_eq!(
1011 parse_request_line("CONNECT [::1]:8443 HTTP/1.1"),
1012 Some(Target::Connect {
1013 host: "::1".to_string(),
1014 port: 8443
1015 })
1016 );
1017 }
1018
1019 #[test]
1020 fn rejects_malformed_and_unspoken_schemes() {
1021 assert!(parse_request_line("GET / HTTP/1.1").is_none()); assert!(parse_request_line("GET https://x/ HTTP/1.1").is_none()); assert!(parse_request_line("GET ftp://x/ HTTP/1.1").is_none());
1024 assert!(parse_request_line("garbage").is_none());
1025 assert!(parse_request_line("CONNECT x:notaport HTTP/1.1").is_none());
1026 }
1027
1028 struct FixedResolver(SocketAddr);
1030 impl Resolver for FixedResolver {
1031 fn resolve(&self, _host: &str, _port: u16) -> io::Result<SocketAddr> {
1032 Ok(self.0)
1033 }
1034 }
1035
1036 fn start_null(
1038 hosts: impl IntoIterator<Item = String>,
1039 resolver: Arc<dyn Resolver>,
1040 ) -> io::Result<ProxyHandle> {
1041 start(hosts, resolver, Arc::new(NullSink))
1042 }
1043
1044 #[derive(Clone, Default)]
1046 struct CapturingSink(Arc<Mutex<Vec<NetAuditEvent>>>);
1047 impl AuditSink for CapturingSink {
1048 fn record(&self, event: &NetAuditEvent) {
1049 self.0.lock().unwrap().push(event.clone());
1050 }
1051 }
1052 impl CapturingSink {
1053 fn events(&self) -> Vec<NetAuditEvent> {
1054 self.0.lock().unwrap().clone()
1055 }
1056 }
1057
1058 #[derive(Clone, Default)]
1072 struct ScriptedConn {
1073 to_read: Arc<Mutex<VecDeque<u8>>>,
1076 written: Arc<Mutex<Vec<u8>>>,
1079 }
1080
1081 impl ScriptedConn {
1082 fn with_script(bytes: &[u8]) -> Self {
1083 Self {
1084 to_read: Arc::new(Mutex::new(bytes.iter().copied().collect())),
1085 written: Arc::new(Mutex::new(Vec::new())),
1086 }
1087 }
1088 fn written(&self) -> Vec<u8> {
1090 self.written.lock().unwrap().clone()
1091 }
1092 fn written_str(&self) -> String {
1094 String::from_utf8_lossy(&self.written()).into_owned()
1095 }
1096 }
1097
1098 impl Read for ScriptedConn {
1099 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1100 let mut q = self.to_read.lock().unwrap();
1101 let n = buf.len().min(q.len());
1102 for slot in buf.iter_mut().take(n) {
1103 *slot = q.pop_front().unwrap();
1104 }
1105 Ok(n) }
1107 }
1108
1109 impl Write for ScriptedConn {
1110 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1111 self.written.lock().unwrap().extend_from_slice(buf);
1112 Ok(buf.len())
1113 }
1114 fn flush(&mut self) -> io::Result<()> {
1115 Ok(())
1116 }
1117 }
1118
1119 impl Conn for ScriptedConn {
1120 fn dup(&self) -> io::Result<Box<dyn Conn>> {
1121 Ok(Box::new(self.clone()))
1122 }
1123 fn shutdown(&self, _how: Shutdown) -> io::Result<()> {
1124 Ok(())
1125 }
1126 fn set_timeouts(&self, _dur: Duration) -> io::Result<()> {
1127 Ok(())
1128 }
1129 }
1130
1131 struct FakeConnector(ScriptedConn);
1133 impl Connector for FakeConnector {
1134 fn connect(&self, _addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
1135 Ok(Box::new(self.0.clone()))
1136 }
1137 }
1138
1139 struct FailingConnector;
1141 impl Connector for FailingConnector {
1142 fn connect(&self, _addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
1143 Err(io::Error::new(
1144 io::ErrorKind::ConnectionRefused,
1145 "origin unreachable",
1146 ))
1147 }
1148 }
1149
1150 struct Driven {
1152 client: ScriptedConn,
1154 origin: ScriptedConn,
1156 refused: Vec<String>,
1158 audit: Vec<NetAuditEvent>,
1160 }
1161
1162 fn drive_with(request: &[u8], allow: &[&str], connector: &dyn Connector) -> Driven {
1169 let client = ScriptedConn::with_script(request);
1170 let policy = HostPolicy::new(allow.iter().map(|s| s.to_string()));
1171 let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
1172 let sink = CapturingSink::default();
1173 let refused = Mutex::new(HashSet::new());
1174 let _ = handle_conn(
1175 Box::new(client.clone()),
1176 &policy,
1177 connector,
1178 &resolver,
1179 &sink,
1180 &refused,
1181 );
1182 let mut refused: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
1183 refused.sort();
1184 Driven {
1185 client,
1186 origin: ScriptedConn::default(),
1187 refused,
1188 audit: sink.events(),
1189 }
1190 }
1191
1192 fn drive_forward(request: &[u8], allow: &[&str], origin_script: &[u8]) -> Driven {
1195 let client = ScriptedConn::with_script(request);
1196 let origin = ScriptedConn::with_script(origin_script);
1197 let policy = HostPolicy::new(allow.iter().map(|s| s.to_string()));
1198 let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
1199 let sink = CapturingSink::default();
1200 let refused = Mutex::new(HashSet::new());
1201 let connector = FakeConnector(origin.clone());
1202 let _ = handle_conn(
1203 Box::new(client.clone()),
1204 &policy,
1205 &connector,
1206 &resolver,
1207 &sink,
1208 &refused,
1209 );
1210 let mut refused: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
1211 refused.sort();
1212 Driven {
1213 client,
1214 origin,
1215 refused,
1216 audit: sink.events(),
1217 }
1218 }
1219
1220 #[test]
1221 fn allowed_http_host_is_forwarded_to_origin() {
1222 let out = drive_forward(
1224 b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\nConnection: close\r\n\r\n",
1225 &["allowed.test"],
1226 b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1227 );
1228 let client_saw = out.client.written_str();
1229 assert!(client_saw.contains("200"), "client saw: {client_saw}");
1230 assert!(
1231 client_saw.contains("origin"),
1232 "the origin's body must reach the client: {client_saw}"
1233 );
1234 assert!(
1236 out.origin.written_str().starts_with("GET /x HTTP/1.1"),
1237 "origin must receive the origin-form request: {}",
1238 out.origin.written_str()
1239 );
1240 assert!(out.refused.is_empty(), "an allowed host is not refused");
1241 }
1242
1243 #[test]
1246 fn exact_private_hosts_require_both_grants_and_pin_the_screened_address() {
1247 use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
1248 for request in [
1249 &b"CONNECT service.test:443 HTTP/1.1\r\n\r\nPING"[..],
1250 &b"GET http://service.test:443/x HTTP/1.1\r\n\r\n"[..],
1251 ] {
1252 for ip in [
1254 IpAddr::V4(Ipv4Addr::new(10, 20, 30, 40)),
1255 IpAddr::V4(Ipv4Addr::new(172, 16, 10, 20)),
1256 IpAddr::V4(Ipv4Addr::new(192, 168, 50, 10)),
1257 IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x1234)),
1258 IpAddr::V6(Ipv4Addr::new(10, 20, 30, 40).to_ipv6_mapped()),
1259 ] {
1260 let address = SocketAddr::new(ip, 443);
1261 for (allow, private, expected) in [
1262 (vec!["service.test"], vec!["SERVICE.TEST."], 200),
1263 (vec!["service.test"], vec![], 502),
1264 (vec!["service.test"], vec!["other.test"], 502),
1265 (vec!["other.test"], vec!["service.test"], 403),
1266 ] {
1267 struct CountResolver(SocketAddr, std::sync::atomic::AtomicUsize);
1268 impl Resolver for CountResolver {
1269 fn resolve(&self, _: &str, _: u16) -> io::Result<SocketAddr> {
1270 assert_eq!(self.1.fetch_add(1, Ordering::SeqCst), 0, "resolve once");
1271 Ok(self.0)
1272 }
1273 }
1274 struct PinnedConnector(SocketAddr, std::sync::atomic::AtomicUsize);
1275 impl Connector for PinnedConnector {
1276 fn connect(&self, address: SocketAddr) -> io::Result<Box<dyn Conn>> {
1277 assert_eq!(address, self.0, "dial the screened address without DNS");
1278 self.1.fetch_add(1, Ordering::SeqCst);
1279 Ok(Box::new(ScriptedConn::with_script(
1280 b"HTTP/1.1 200 OK\r\n\r\n",
1281 )))
1282 }
1283 }
1284 let policy = HostPolicy::with_private_hosts(
1285 allow.into_iter().map(str::to_string),
1286 private.into_iter().map(str::to_string),
1287 )
1288 .unwrap();
1289 let resolver = CountResolver(address, 0.into());
1290 let connector = PinnedConnector(address, 0.into());
1291 let client = ScriptedConn::with_script(request);
1292 handle_conn(
1293 Box::new(client.clone()),
1294 &policy,
1295 &connector,
1296 &resolver,
1297 &NullSink,
1298 &Mutex::new(HashSet::new()),
1299 )
1300 .unwrap();
1301 assert!(
1302 client
1303 .written_str()
1304 .starts_with(&format!("HTTP/1.1 {expected}")),
1305 "address {address}, expected {expected}: {}",
1306 client.written_str()
1307 );
1308 assert_eq!(
1309 connector.1.load(Ordering::SeqCst),
1310 usize::from(expected == 200)
1311 );
1312 assert_eq!(
1313 resolver.1.load(Ordering::SeqCst),
1314 usize::from(expected != 403)
1315 );
1316 }
1317 }
1318 }
1319 }
1320
1321 #[test]
1322 fn exact_private_hosts_never_approve_forbidden_ranges() {
1323 use std::net::{IpAddr, Ipv4Addr};
1324 let policy = HostPolicy::with_private_hosts(
1325 ["service.test".to_string()],
1326 ["service.test".to_string()],
1327 )
1328 .unwrap();
1329 for ip in [
1330 "169.254.169.254",
1331 "fd00:ec2::254",
1332 "0.0.0.0",
1333 "0.1.2.3",
1334 "224.0.0.1",
1335 "255.255.255.255",
1336 "192.0.2.1",
1337 "198.18.0.1",
1338 "::",
1339 "fe80::1",
1340 "ff02::1",
1341 "::ffff:169.254.169.254",
1342 "::ffff:224.0.0.1",
1343 ]
1344 .into_iter()
1345 .map(|ip| ip.parse::<IpAddr>().unwrap())
1346 .chain([
1347 IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)),
1348 IpAddr::V6(Ipv4Addr::new(100, 64, 0, 1).to_ipv6_mapped()),
1349 ]) {
1350 let resolver = FixedResolver(SocketAddr::new(ip, 443));
1351 for request in [
1352 &b"CONNECT service.test:443 HTTP/1.1\r\n\r\n"[..],
1353 &b"GET http://service.test/ HTTP/1.1\r\n\r\n"[..],
1354 ] {
1355 let client = ScriptedConn::with_script(request);
1356 let sink = CapturingSink::default();
1357 handle_conn(
1359 Box::new(client.clone()),
1360 &policy,
1361 &FakeConnector(ScriptedConn::with_script(b"HTTP/1.1 200 OK\r\n\r\n")),
1362 &resolver,
1363 &sink,
1364 &Mutex::new(HashSet::new()),
1365 )
1366 .unwrap();
1367 assert!(
1368 client.written_str().contains("502"),
1369 "forbidden address {ip}"
1370 );
1371 assert_eq!(sink.events()[0].decision, NetDecision::Error);
1372 }
1373 }
1374 }
1375
1376 #[test]
1377 fn exact_private_hosts_reject_patterns_urls_and_ambiguous_names() {
1378 for invalid in [
1379 "",
1380 "*",
1381 "*.test",
1382 ".test",
1383 "https://service.test",
1384 "service.test:443",
1385 "user@service.test",
1386 "service.test/path",
1387 " service.test",
1388 "service.test ",
1389 "a..test",
1390 "-bad.test",
1391 "bad-.test",
1392 "127.1",
1393 "0x7f000001",
1394 "service.test?x",
1395 ] {
1396 assert!(
1397 HostPolicy::with_private_hosts(["service.test".to_string()], [invalid.to_string()])
1398 .is_err(),
1399 "invalid exact name: {invalid}"
1400 );
1401 }
1402 let policy =
1403 HostPolicy::with_private_hosts(["fd00::1".to_string()], ["[FD00::1]".to_string()])
1404 .unwrap();
1405 assert!(policy.allows_private("fd00::1"));
1406 }
1407
1408 #[test]
1409 fn disallowed_http_host_is_refused_403_without_reaching_origin() {
1410 let out = drive_with(
1413 b"GET http://evil.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1414 &["allowed.test"],
1415 &FailingConnector,
1416 );
1417 let client_saw = out.client.written_str();
1418 assert!(
1419 client_saw.contains("403"),
1420 "denied host must get 403: {client_saw}"
1421 );
1422 assert!(
1423 !client_saw.contains("502"),
1424 "the origin must not be dialled for a denied host: {client_saw}"
1425 );
1426 assert!(out.origin.written().is_empty(), "origin must see nothing");
1427 }
1428
1429 #[test]
1430 fn unreachable_allowed_origin_yields_502() {
1431 let out = drive_with(
1433 b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1434 &["allowed.test"],
1435 &FailingConnector,
1436 );
1437 assert!(
1438 out.client.written_str().contains("502"),
1439 "an unreachable allowed origin must get 502: {}",
1440 out.client.written_str()
1441 );
1442 let ev = out.audit.iter().find(|e| e.host == "allowed.test").unwrap();
1443 assert_eq!(ev.decision, NetDecision::Error);
1444 }
1445
1446 #[test]
1447 fn malformed_request_yields_400_and_no_audit() {
1448 let out = drive_with(
1451 b"GET / HTTP/1.1\r\n\r\n",
1452 &["allowed.test"],
1453 &FailingConnector,
1454 );
1455 assert!(out.client.written_str().contains("400"));
1456 assert!(
1457 out.audit.is_empty(),
1458 "a malformed request is not an egress event: {:?}",
1459 out.audit
1460 );
1461 assert!(out.refused.is_empty());
1462 }
1463
1464 #[test]
1467 fn refused_hosts_surfaces_denied_hosts_deduped_and_omits_allowed() {
1468 let policy = HostPolicy::new(["allowed.test".to_string()]);
1471 let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
1472 let sink = NullSink;
1473 let refused = Mutex::new(HashSet::new());
1474 let origin = ScriptedConn::with_script(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
1475 let connector = FakeConnector(origin);
1476
1477 for (req, conn) in [
1478 (
1479 &b"GET http://allowed.test/x HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1480 &connector as &dyn Connector,
1481 ),
1482 (
1483 &b"GET http://evil.test/y HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1484 &FailingConnector,
1485 ),
1486 (
1487 &b"GET http://evil.test/z HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1488 &FailingConnector,
1489 ),
1490 ] {
1491 let client = ScriptedConn::with_script(req);
1492 let _ = handle_conn(Box::new(client), &policy, conn, &resolver, &sink, &refused);
1493 }
1494
1495 let mut got: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
1496 got.sort();
1497 assert_eq!(
1498 got,
1499 vec!["evil.test".to_string()],
1500 "only the denied host, deduped; the allowed host must NOT appear: {got:?}"
1501 );
1502 }
1503
1504 #[test]
1505 fn audit_records_allowed_with_bytes_and_denied_attempts() {
1506 let allowed = drive_forward(
1508 b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1509 &["allowed.test"],
1510 b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1511 );
1512 let ev = allowed
1513 .audit
1514 .iter()
1515 .find(|e| e.host == "allowed.test")
1516 .expect("an allowed event");
1517 assert_eq!(ev.decision, NetDecision::Allowed);
1518 assert_eq!(ev.kind, NetKind::Http);
1519 assert_eq!(ev.port, 80);
1520 assert!(
1521 ev.bytes_down > 0,
1522 "an allowed connection records response bytes: {ev:?}"
1523 );
1524
1525 let denied = drive_with(
1527 b"GET http://evil.test/y HTTP/1.1\r\nHost: ignored\r\n\r\n",
1528 &["allowed.test"],
1529 &FailingConnector,
1530 );
1531 let ev = denied
1532 .audit
1533 .iter()
1534 .find(|e| e.host == "evil.test")
1535 .expect("a denied event");
1536 assert_eq!(ev.decision, NetDecision::Denied);
1537 assert_eq!(ev.bytes_up, 0);
1538 assert_eq!(ev.bytes_down, 0);
1539 }
1540
1541 #[test]
1542 fn jsonl_sink_appends_one_newline_terminated_json_line_per_event() {
1543 #[derive(Clone, Default)]
1544 struct SharedBuf(Arc<Mutex<Vec<u8>>>);
1545 impl Write for SharedBuf {
1546 fn write(&mut self, b: &[u8]) -> io::Result<usize> {
1547 self.0.lock().unwrap().extend_from_slice(b);
1548 Ok(b.len())
1549 }
1550 fn flush(&mut self) -> io::Result<()> {
1551 Ok(())
1552 }
1553 }
1554 let buf = SharedBuf::default();
1555 let sink = JsonlSink::new(buf.clone());
1556 let mk = |host: &str| NetAuditEvent {
1557 ts_ms: 1,
1558 host: host.into(),
1559 port: 80,
1560 kind: NetKind::Http,
1561 decision: NetDecision::Allowed,
1562 bytes_up: 1,
1563 bytes_down: 2,
1564 dur_ms: 3,
1565 };
1566 sink.record(&mk("a"));
1567 sink.record(&mk("b"));
1568 let text = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
1569 let lines: Vec<&str> = text.lines().collect();
1570 assert_eq!(lines.len(), 2, "one JSON line per event: {text:?}");
1571 assert_eq!(
1572 serde_json::from_str::<NetAuditEvent>(lines[0])
1573 .unwrap()
1574 .host,
1575 "a"
1576 );
1577 assert_eq!(
1578 serde_json::from_str::<NetAuditEvent>(lines[1])
1579 .unwrap()
1580 .host,
1581 "b"
1582 );
1583 }
1584
1585 #[test]
1586 fn audit_event_json_round_trips() {
1587 let e = NetAuditEvent {
1588 ts_ms: 1,
1589 host: "h".into(),
1590 port: 443,
1591 kind: NetKind::Connect,
1592 decision: NetDecision::Allowed,
1593 bytes_up: 10,
1594 bytes_down: 20,
1595 dur_ms: 5,
1596 };
1597 let line = serde_json::to_string(&e).unwrap();
1598 assert!(line.contains("\"decision\":\"allowed\"") && line.contains("\"kind\":\"connect\""));
1599 assert_eq!(serde_json::from_str::<NetAuditEvent>(&line).unwrap(), e);
1600 }
1601
1602 #[test]
1603 fn connect_allowed_host_tunnels_opaque_bytes() {
1604 let out = drive_forward(
1610 b"CONNECT allowed.test:443 HTTP/1.1\r\nHost: allowed.test:443\r\n\r\nPING",
1611 &["allowed.test"],
1612 b"PING",
1613 );
1614 let client_saw = out.client.written_str();
1615 assert!(
1616 client_saw.starts_with("HTTP/1.1 200"),
1617 "CONNECT must be accepted with a bare 200: {client_saw:?}"
1618 );
1619 assert!(
1620 client_saw.contains("PING"),
1621 "the origin's bytes must tunnel back to the client: {client_saw:?}"
1622 );
1623 assert_eq!(
1624 out.origin.written(),
1625 b"PING",
1626 "the client's pipelined bytes must reach the origin through the tunnel"
1627 );
1628 }
1629
1630 #[test]
1631 fn connect_disallowed_host_is_refused_403() {
1632 let out = drive_with(
1633 b"CONNECT evil.test:443 HTTP/1.1\r\n\r\n",
1634 &["allowed.test"],
1635 &FailingConnector,
1636 );
1637 assert!(
1638 out.client.written_str().contains("403"),
1639 "a denied CONNECT must get 403, not a tunnel: {}",
1640 out.client.written_str()
1641 );
1642 }
1643
1644 #[test]
1645 fn http_host_header_is_normalized_to_the_validated_authority() {
1646 let out = drive_forward(
1650 b"GET http://allowed.test/ HTTP/1.1\r\nHost: evil.test\r\nConnection: close\r\n\r\n",
1651 &["allowed.test"],
1652 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1653 );
1654 let origin_saw = out.origin.written_str();
1655 assert!(
1656 origin_saw.contains("Host: allowed.test\r\n"),
1657 "origin must receive the validated Host: {origin_saw:?}"
1658 );
1659 assert!(
1660 !origin_saw.contains("evil.test"),
1661 "the spoofed Host must not reach the origin: {origin_saw:?}"
1662 );
1663 }
1664
1665 fn net_test_lock() -> std::sync::MutexGuard<'static, ()> {
1679 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1680 LOCK.lock().unwrap_or_else(|e| e.into_inner())
1681 }
1682
1683 #[test]
1684 fn proxy_env_points_at_the_bound_loopback_addr() {
1685 let _serial = net_test_lock();
1690 let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
1691 let env = proxy.proxy_env();
1692 let url = format!("http://127.0.0.1:{}", proxy.addr().port());
1693 assert!(env.iter().any(|(k, v)| k == "https_proxy" && *v == url));
1694 assert!(env.iter().any(|(k, v)| k == "HTTPS_PROXY" && *v == url));
1695 assert!(env.iter().all(|(_, v)| v.starts_with("http://127.0.0.1:")));
1696 }
1697
1698 #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1704 fn spawn_origin() -> SocketAddr {
1705 let l = TcpListener::bind(("127.0.0.1", 0)).unwrap();
1706 let addr = l.local_addr().unwrap();
1707 thread::spawn(move || {
1708 for s in l.incoming().flatten() {
1709 let mut s = s;
1710 let mut b = [0u8; 512];
1711 let _ = s.read(&mut b);
1712 let _ = s.write_all(
1713 b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1714 );
1715 }
1716 });
1717 addr
1718 }
1719
1720 #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1733 #[test]
1734 fn fenced_child_reaches_allowed_via_proxy_denied_refused_direct_kernel_blocked() {
1735 use crate::{
1736 best_available_sandbox, loopback_fenced_caveats, seatbelt_is_supported, Caveats,
1737 SandboxPolicy, Scope,
1738 };
1739 let _serial = net_test_lock();
1743 if !seatbelt_is_supported() {
1744 eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1745 return;
1746 }
1747 let curl = "/usr/bin/curl";
1748 if !std::path::Path::new(curl).exists() {
1749 eprintln!("skipping: no curl(1)");
1750 return;
1751 }
1752
1753 let origin = spawn_origin();
1754 let proxy = start_with_private_hosts(
1755 ["allowed.test".to_string()],
1756 ["allowed.test".to_string()],
1757 Arc::new(FixedResolver(origin)),
1758 Arc::new(NullSink),
1759 )
1760 .unwrap();
1761
1762 let granted = Caveats {
1765 net: Scope::only(["allowed.test".to_string()]),
1766 ..Caveats::top()
1767 };
1768 let prefix = best_available_sandbox(&Arc::new(SandboxPolicy::default()))
1769 .command_prefix(&loopback_fenced_caveats(&granted))
1770 .expect("seatbelt wrapper");
1771
1772 let run = |proxy_env: bool, url: &str| -> std::process::Output {
1776 let mut cmd = std::process::Command::new(&prefix[0]);
1777 cmd.args(&prefix[1..])
1778 .arg(curl)
1779 .args(["-sv", "--max-time", "5", url])
1780 .env_clear();
1781 if proxy_env {
1782 cmd.envs(proxy.proxy_env());
1783 }
1784 cmd.output().expect("spawn sandbox-exec")
1785 };
1786
1787 let mut allow = None;
1795 for attempt in 0..3 {
1796 let output = run(true, "http://allowed.test/");
1797 let stdout = String::from_utf8_lossy(&output.stdout);
1798 if output.status.success() && stdout.contains("origin") {
1799 allow = Some(output);
1800 break;
1801 }
1802 if attempt < 2 {
1803 thread::sleep(Duration::from_millis(100));
1804 continue;
1805 }
1806 allow = Some(output);
1807 }
1808 let allow = allow.expect("at least one allow-leg probe should run");
1809 assert!(
1810 allow.status.success() && String::from_utf8_lossy(&allow.stdout).contains("origin"),
1811 "allow-listed host must reach the origin through the proxy: {allow:?}"
1812 );
1813 let deny = run(true, "http://denied.test/");
1816 assert!(
1817 String::from_utf8_lossy(&deny.stdout).contains("403"),
1818 "denied host must get the proxy's 403: {deny:?}"
1819 );
1820 let direct = run(false, "http://1.1.1.1/");
1826 let stderr = String::from_utf8_lossy(&direct.stderr);
1827 assert_eq!(
1828 direct.status.code(),
1829 Some(7),
1830 "direct off-box egress must be kernel-denied (curl exit 7): {stderr}"
1831 );
1832 assert!(
1833 stderr.contains("Operation not permitted"),
1834 "the block must be a kernel EPERM, not a routing failure: {stderr}"
1835 );
1836
1837 drop(proxy);
1838 }
1839
1840 #[test]
1847 fn dropping_the_handle_stops_the_listener() {
1848 let _serial = net_test_lock();
1849 let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
1850 let addr = proxy.addr();
1851 drop(proxy);
1852 thread::sleep(Duration::from_millis(100));
1856 if let Ok(mut c) = TcpStream::connect_timeout(&addr, Duration::from_millis(200)) {
1857 c.set_read_timeout(Some(Duration::from_millis(500)))
1858 .unwrap();
1859 let _ = write!(c, "GET http://x/ HTTP/1.1\r\n\r\n");
1860 let mut resp = Vec::new();
1861 let _ = c.read_to_end(&mut resp);
1862 assert!(resp.is_empty(), "a stopped proxy must not serve requests");
1863 }
1864 }
1865}