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(Arc<HashSet<String>>);
287
288impl HostPolicy {
289 fn new(hosts: impl IntoIterator<Item = String>) -> Self {
290 Self(Arc::new(hosts.into_iter().collect()))
291 }
292 fn allows(&self, host: &str) -> bool {
293 self.0.contains(host)
294 }
295}
296
297pub struct ProxyHandle {
299 addr: SocketAddr,
300 shutdown: Arc<AtomicBool>,
301 accept: Option<JoinHandle<()>>,
302 refused: Arc<Mutex<HashSet<String>>>,
306}
307
308impl ProxyHandle {
309 #[cfg(test)]
312 pub fn addr(&self) -> SocketAddr {
313 self.addr
314 }
315
316 #[must_use]
322 pub fn refused_hosts(&self) -> Vec<String> {
323 self.refused
324 .lock()
325 .map(|s| {
326 let mut v: Vec<String> = s.iter().cloned().collect();
327 v.sort();
328 v
329 })
330 .unwrap_or_default()
331 }
332
333 #[must_use]
338 pub fn proxy_env(&self) -> Vec<(String, String)> {
339 let url = format!("http://{}", self.addr);
340 [
341 "http_proxy",
342 "https_proxy",
343 "all_proxy",
344 "HTTP_PROXY",
345 "HTTPS_PROXY",
346 "ALL_PROXY",
347 ]
348 .iter()
349 .map(|k| ((*k).to_string(), url.clone()))
350 .collect()
351 }
352}
353
354impl Drop for ProxyHandle {
355 fn drop(&mut self) {
356 self.shutdown.store(true, Ordering::SeqCst);
357 let _ = TcpStream::connect_timeout(&self.addr, Duration::from_millis(200));
359 if let Some(h) = self.accept.take() {
360 let _ = h.join();
361 }
362 }
363}
364
365pub fn start(
373 allow_hosts: impl IntoIterator<Item = String>,
374 resolver: Arc<dyn Resolver>,
375 sink: Arc<dyn AuditSink>,
376) -> io::Result<ProxyHandle> {
377 let listener = TcpListener::bind(("127.0.0.1", 0))?;
378 let addr = listener.local_addr()?;
379 let shutdown = Arc::new(AtomicBool::new(false));
380 let policy = HostPolicy::new(allow_hosts);
381 let connector: Arc<dyn Connector> = Arc::new(TcpConnector);
384 let refused: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
386
387 let accept = {
388 let shutdown = Arc::clone(&shutdown);
389 let refused = Arc::clone(&refused);
390 thread::Builder::new()
391 .name("agent-bridle-egress-proxy".to_string())
392 .spawn(move || {
393 for stream in listener.incoming() {
394 if shutdown.load(Ordering::SeqCst) {
395 break;
396 }
397 let Ok(client) = stream else { continue };
398 let policy = policy.clone();
399 let resolver = Arc::clone(&resolver);
400 let connector = Arc::clone(&connector);
401 let sink = Arc::clone(&sink);
402 let refused = Arc::clone(&refused);
403 let _ = thread::Builder::new()
405 .name("agent-bridle-egress-conn".to_string())
406 .spawn(move || {
407 let _ = handle_conn(
408 Box::new(client),
409 &policy,
410 connector.as_ref(),
411 resolver.as_ref(),
412 sink.as_ref(),
413 &refused,
414 );
415 });
416 }
417 })?
418 };
419
420 Ok(ProxyHandle {
421 addr,
422 shutdown,
423 accept: Some(accept),
424 refused,
425 })
426}
427
428fn handle_conn(
432 client: Box<dyn Conn>,
433 policy: &HostPolicy,
434 connector: &dyn Connector,
435 resolver: &dyn Resolver,
436 sink: &dyn AuditSink,
437 refused: &Mutex<HashSet<String>>,
438) -> io::Result<()> {
439 client.set_timeouts(CONN_TIMEOUT)?;
440 let mut reader = BufReader::new(client.dup()?);
441 let t0 = Instant::now();
442
443 let line = read_line_bounded(&mut reader)?;
444 let Some(target) = parse_request_line(&line) else {
445 return respond(client.as_ref(), 400, "Bad Request");
447 };
448
449 let (host, port, kind) = match &target {
450 Target::Connect { host, port } => (host.clone(), *port, NetKind::Connect),
451 Target::Http { host, port, .. } => (host.clone(), *port, NetKind::Http),
452 };
453 let audit = |decision: NetDecision, up: u64, down: u64| {
455 sink.record(&NetAuditEvent {
456 ts_ms: now_ms(),
457 host: host.clone(),
458 port,
459 kind,
460 decision,
461 bytes_up: up,
462 bytes_down: down,
463 dur_ms: t0.elapsed().as_millis() as u64,
464 });
465 };
466
467 if !policy.allows(&host) {
468 audit(NetDecision::Denied, 0, 0); if let Ok(mut set) = refused.lock() {
472 set.insert(host.clone());
473 }
474 return respond(client.as_ref(), 403, "Forbidden");
475 }
476
477 match target {
478 Target::Connect { host, port } => {
479 drain_headers(&mut reader)?;
482 let origin = match resolver
483 .resolve(&host, port)
484 .and_then(guard_target)
485 .and_then(|addr| connector.connect(addr))
486 {
487 Ok(o) => o,
488 Err(_) => {
489 audit(NetDecision::Error, 0, 0);
490 return respond(client.as_ref(), 502, "Bad Gateway");
491 }
492 };
493 let mut client = client;
497 client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?;
498 let (up, down) = splice_buffered(reader, client, origin)?;
503 audit(NetDecision::Allowed, up, down);
504 Ok(())
505 }
506 Target::Http {
507 host,
508 port,
509 origin_line,
510 } => {
511 let headers = read_headers(&mut reader)?;
517 let mut origin = match resolver
518 .resolve(&host, port)
519 .and_then(guard_target)
520 .and_then(|addr| connector.connect(addr))
521 {
522 Ok(o) => o,
523 Err(_) => {
524 audit(NetDecision::Error, 0, 0);
525 return respond(client.as_ref(), 502, "Bad Gateway");
526 }
527 };
528 let host_hdr = if port == 80 {
529 format!("Host: {host}\r\n")
530 } else {
531 format!("Host: {host}:{port}\r\n")
532 };
533 origin.write_all(origin_line.as_bytes())?;
534 origin.write_all(host_hdr.as_bytes())?;
535 for h in &headers {
536 if !h.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("host:")) {
537 origin.write_all(h.as_bytes())?;
538 }
539 }
540 origin.write_all(b"\r\n")?; let (up, down) = splice_buffered(reader, client, origin)?; audit(NetDecision::Allowed, up, down);
543 Ok(())
544 }
545 }
546}
547
548fn read_line_bounded<R: Read>(reader: &mut BufReader<R>) -> io::Result<String> {
552 let mut buf = Vec::new();
553 reader.take(MAX_HEAD as u64).read_until(b'\n', &mut buf)?;
554 Ok(String::from_utf8_lossy(&buf).into_owned())
555}
556
557fn read_headers<R: Read>(reader: &mut BufReader<R>) -> io::Result<Vec<String>> {
560 let mut lines = Vec::new();
561 let mut total = 0usize;
562 loop {
563 let line = read_line_bounded(reader)?;
564 total += line.len();
565 if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
566 return Ok(lines);
567 }
568 lines.push(line);
569 }
570}
571
572fn drain_headers<R: Read>(reader: &mut BufReader<R>) -> io::Result<()> {
574 let mut total = 0usize;
575 loop {
576 let line = read_line_bounded(reader)?;
577 total += line.len();
578 if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
579 return Ok(());
580 }
581 }
582}
583
584fn respond(client: &dyn Conn, code: u16, reason: &str) -> io::Result<()> {
586 let mut c = client.dup()?;
587 let body = format!("{code} {reason}\n");
588 write!(
589 c,
590 "HTTP/1.1 {code} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
591 body.len()
592 )?;
593 let _ = c.flush();
594 let _ = client.shutdown(Shutdown::Both);
595 Ok(())
596}
597
598fn copy_counted(from: &mut impl Read, to: &mut impl Write) -> u64 {
603 let mut buf = [0u8; 16 * 1024];
604 let mut total = 0u64;
605 loop {
606 match from.read(&mut buf) {
607 Ok(0) => break,
608 Ok(n) => {
609 if to.write_all(&buf[..n]).is_err() {
610 break;
611 }
612 total += n as u64;
613 }
614 Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
615 Err(_) => break,
616 }
617 }
618 total
619}
620
621fn guard_target(addr: SocketAddr) -> io::Result<SocketAddr> {
636 if is_internal_ip(&addr.ip()) {
637 return Err(io::Error::new(
638 io::ErrorKind::PermissionDenied,
639 "SSRF-guard: refusing to proxy to an internal (non-loopback) address",
640 ));
641 }
642 Ok(addr)
643}
644
645fn is_internal_ip(ip: &std::net::IpAddr) -> bool {
648 match ip {
649 std::net::IpAddr::V4(v4) => {
650 let o = v4.octets();
651 v4.is_private()
652 || v4.is_link_local()
653 || v4.is_unspecified()
654 || v4.is_broadcast()
655 || (o[0] == 100 && (64..=127).contains(&o[1]))
657 }
658 std::net::IpAddr::V6(v6) => {
659 let seg0 = v6.segments()[0];
660 v6.is_unspecified()
661 || (seg0 & 0xfe00) == 0xfc00 || (seg0 & 0xffc0) == 0xfe80 }
664 }
665}
666
667fn splice_buffered(
671 mut client_reader: BufReader<Box<dyn Conn>>,
672 client: Box<dyn Conn>,
673 origin: Box<dyn Conn>,
674) -> io::Result<(u64, u64)> {
675 let mut o_write = origin.dup()?;
676 let up = thread::spawn(move || {
677 let n = copy_counted(&mut client_reader, &mut o_write);
678 let _ = o_write.shutdown(Shutdown::Write);
679 n
680 });
681 let mut o_read = origin;
682 let mut c_write = client;
683 let down = copy_counted(&mut o_read, &mut c_write);
684 let _ = c_write.shutdown(Shutdown::Both);
693 let up = up.join().unwrap_or(0);
694 Ok((up, down))
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use std::collections::VecDeque;
701
702 #[test]
707 fn guard_target_refuses_internal_permits_loopback_and_global() {
708 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
709 let refused: [IpAddr; 7] = [
712 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(), ];
720 for ip in refused {
721 assert!(is_internal_ip(&ip), "{ip} must classify as internal");
722 assert!(
723 guard_target(SocketAddr::new(ip, 80)).is_err(),
724 "{ip} must be refused"
725 );
726 }
727 let allowed = [
728 "127.0.0.1",
729 "::1",
730 "8.8.8.8",
731 "1.1.1.1",
732 "2606:4700:4700::1111",
733 ];
734 for s in allowed {
735 let ip: IpAddr = s.parse().unwrap();
736 assert!(!is_internal_ip(&ip), "{s} must be permitted");
737 assert!(
738 guard_target(SocketAddr::new(ip, 443)).is_ok(),
739 "{s} must be permitted"
740 );
741 }
742 }
743
744 #[test]
745 fn parses_connect() {
746 assert_eq!(
747 parse_request_line("CONNECT example.com:443 HTTP/1.1\r\n"),
748 Some(Target::Connect {
749 host: "example.com".to_string(),
750 port: 443
751 })
752 );
753 assert_eq!(
755 parse_request_line("CONNECT example.com HTTP/1.1"),
756 Some(Target::Connect {
757 host: "example.com".to_string(),
758 port: 443
759 })
760 );
761 }
762
763 #[test]
764 fn parses_http_absolute_form_and_rewrites_to_origin_form() {
765 let t = parse_request_line("GET http://example.com/a/b?q=1 HTTP/1.1\r\n").unwrap();
766 assert_eq!(
767 t,
768 Target::Http {
769 host: "example.com".to_string(),
770 port: 80,
771 origin_line: "GET /a/b?q=1 HTTP/1.1\r\n".to_string(),
772 }
773 );
774 let t = parse_request_line("HEAD http://h:8080 HTTP/1.0").unwrap();
776 assert_eq!(
777 t,
778 Target::Http {
779 host: "h".to_string(),
780 port: 8080,
781 origin_line: "HEAD / HTTP/1.0\r\n".to_string(),
782 }
783 );
784 }
785
786 #[test]
787 fn parses_ipv6_authority() {
788 assert_eq!(
789 parse_request_line("CONNECT [::1]:8443 HTTP/1.1"),
790 Some(Target::Connect {
791 host: "::1".to_string(),
792 port: 8443
793 })
794 );
795 }
796
797 #[test]
798 fn rejects_malformed_and_unspoken_schemes() {
799 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());
802 assert!(parse_request_line("garbage").is_none());
803 assert!(parse_request_line("CONNECT x:notaport HTTP/1.1").is_none());
804 }
805
806 struct FixedResolver(SocketAddr);
808 impl Resolver for FixedResolver {
809 fn resolve(&self, _host: &str, _port: u16) -> io::Result<SocketAddr> {
810 Ok(self.0)
811 }
812 }
813
814 fn start_null(
816 hosts: impl IntoIterator<Item = String>,
817 resolver: Arc<dyn Resolver>,
818 ) -> io::Result<ProxyHandle> {
819 start(hosts, resolver, Arc::new(NullSink))
820 }
821
822 #[derive(Clone, Default)]
824 struct CapturingSink(Arc<Mutex<Vec<NetAuditEvent>>>);
825 impl AuditSink for CapturingSink {
826 fn record(&self, event: &NetAuditEvent) {
827 self.0.lock().unwrap().push(event.clone());
828 }
829 }
830 impl CapturingSink {
831 fn events(&self) -> Vec<NetAuditEvent> {
832 self.0.lock().unwrap().clone()
833 }
834 }
835
836 #[derive(Clone, Default)]
850 struct ScriptedConn {
851 to_read: Arc<Mutex<VecDeque<u8>>>,
854 written: Arc<Mutex<Vec<u8>>>,
857 }
858
859 impl ScriptedConn {
860 fn with_script(bytes: &[u8]) -> Self {
861 Self {
862 to_read: Arc::new(Mutex::new(bytes.iter().copied().collect())),
863 written: Arc::new(Mutex::new(Vec::new())),
864 }
865 }
866 fn written(&self) -> Vec<u8> {
868 self.written.lock().unwrap().clone()
869 }
870 fn written_str(&self) -> String {
872 String::from_utf8_lossy(&self.written()).into_owned()
873 }
874 }
875
876 impl Read for ScriptedConn {
877 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
878 let mut q = self.to_read.lock().unwrap();
879 let n = buf.len().min(q.len());
880 for slot in buf.iter_mut().take(n) {
881 *slot = q.pop_front().unwrap();
882 }
883 Ok(n) }
885 }
886
887 impl Write for ScriptedConn {
888 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
889 self.written.lock().unwrap().extend_from_slice(buf);
890 Ok(buf.len())
891 }
892 fn flush(&mut self) -> io::Result<()> {
893 Ok(())
894 }
895 }
896
897 impl Conn for ScriptedConn {
898 fn dup(&self) -> io::Result<Box<dyn Conn>> {
899 Ok(Box::new(self.clone()))
900 }
901 fn shutdown(&self, _how: Shutdown) -> io::Result<()> {
902 Ok(())
903 }
904 fn set_timeouts(&self, _dur: Duration) -> io::Result<()> {
905 Ok(())
906 }
907 }
908
909 struct FakeConnector(ScriptedConn);
911 impl Connector for FakeConnector {
912 fn connect(&self, _addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
913 Ok(Box::new(self.0.clone()))
914 }
915 }
916
917 struct FailingConnector;
919 impl Connector for FailingConnector {
920 fn connect(&self, _addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
921 Err(io::Error::new(
922 io::ErrorKind::ConnectionRefused,
923 "origin unreachable",
924 ))
925 }
926 }
927
928 struct Driven {
930 client: ScriptedConn,
932 origin: ScriptedConn,
934 refused: Vec<String>,
936 audit: Vec<NetAuditEvent>,
938 }
939
940 fn drive_with(request: &[u8], allow: &[&str], connector: &dyn Connector) -> Driven {
947 let client = ScriptedConn::with_script(request);
948 let policy = HostPolicy::new(allow.iter().map(|s| s.to_string()));
949 let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
950 let sink = CapturingSink::default();
951 let refused = Mutex::new(HashSet::new());
952 let _ = handle_conn(
953 Box::new(client.clone()),
954 &policy,
955 connector,
956 &resolver,
957 &sink,
958 &refused,
959 );
960 let mut refused: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
961 refused.sort();
962 Driven {
963 client,
964 origin: ScriptedConn::default(),
965 refused,
966 audit: sink.events(),
967 }
968 }
969
970 fn drive_forward(request: &[u8], allow: &[&str], origin_script: &[u8]) -> Driven {
973 let client = ScriptedConn::with_script(request);
974 let origin = ScriptedConn::with_script(origin_script);
975 let policy = HostPolicy::new(allow.iter().map(|s| s.to_string()));
976 let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
977 let sink = CapturingSink::default();
978 let refused = Mutex::new(HashSet::new());
979 let connector = FakeConnector(origin.clone());
980 let _ = handle_conn(
981 Box::new(client.clone()),
982 &policy,
983 &connector,
984 &resolver,
985 &sink,
986 &refused,
987 );
988 let mut refused: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
989 refused.sort();
990 Driven {
991 client,
992 origin,
993 refused,
994 audit: sink.events(),
995 }
996 }
997
998 #[test]
999 fn allowed_http_host_is_forwarded_to_origin() {
1000 let out = drive_forward(
1002 b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\nConnection: close\r\n\r\n",
1003 &["allowed.test"],
1004 b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1005 );
1006 let client_saw = out.client.written_str();
1007 assert!(client_saw.contains("200"), "client saw: {client_saw}");
1008 assert!(
1009 client_saw.contains("origin"),
1010 "the origin's body must reach the client: {client_saw}"
1011 );
1012 assert!(
1014 out.origin.written_str().starts_with("GET /x HTTP/1.1"),
1015 "origin must receive the origin-form request: {}",
1016 out.origin.written_str()
1017 );
1018 assert!(out.refused.is_empty(), "an allowed host is not refused");
1019 }
1020
1021 #[test]
1022 fn disallowed_http_host_is_refused_403_without_reaching_origin() {
1023 let out = drive_with(
1026 b"GET http://evil.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1027 &["allowed.test"],
1028 &FailingConnector,
1029 );
1030 let client_saw = out.client.written_str();
1031 assert!(
1032 client_saw.contains("403"),
1033 "denied host must get 403: {client_saw}"
1034 );
1035 assert!(
1036 !client_saw.contains("502"),
1037 "the origin must not be dialled for a denied host: {client_saw}"
1038 );
1039 assert!(out.origin.written().is_empty(), "origin must see nothing");
1040 }
1041
1042 #[test]
1043 fn unreachable_allowed_origin_yields_502() {
1044 let out = drive_with(
1046 b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1047 &["allowed.test"],
1048 &FailingConnector,
1049 );
1050 assert!(
1051 out.client.written_str().contains("502"),
1052 "an unreachable allowed origin must get 502: {}",
1053 out.client.written_str()
1054 );
1055 let ev = out.audit.iter().find(|e| e.host == "allowed.test").unwrap();
1056 assert_eq!(ev.decision, NetDecision::Error);
1057 }
1058
1059 #[test]
1060 fn malformed_request_yields_400_and_no_audit() {
1061 let out = drive_with(
1064 b"GET / HTTP/1.1\r\n\r\n",
1065 &["allowed.test"],
1066 &FailingConnector,
1067 );
1068 assert!(out.client.written_str().contains("400"));
1069 assert!(
1070 out.audit.is_empty(),
1071 "a malformed request is not an egress event: {:?}",
1072 out.audit
1073 );
1074 assert!(out.refused.is_empty());
1075 }
1076
1077 #[test]
1080 fn refused_hosts_surfaces_denied_hosts_deduped_and_omits_allowed() {
1081 let policy = HostPolicy::new(["allowed.test".to_string()]);
1084 let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
1085 let sink = NullSink;
1086 let refused = Mutex::new(HashSet::new());
1087 let origin = ScriptedConn::with_script(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
1088 let connector = FakeConnector(origin);
1089
1090 for (req, conn) in [
1091 (
1092 &b"GET http://allowed.test/x HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1093 &connector as &dyn Connector,
1094 ),
1095 (
1096 &b"GET http://evil.test/y HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1097 &FailingConnector,
1098 ),
1099 (
1100 &b"GET http://evil.test/z HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1101 &FailingConnector,
1102 ),
1103 ] {
1104 let client = ScriptedConn::with_script(req);
1105 let _ = handle_conn(Box::new(client), &policy, conn, &resolver, &sink, &refused);
1106 }
1107
1108 let mut got: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
1109 got.sort();
1110 assert_eq!(
1111 got,
1112 vec!["evil.test".to_string()],
1113 "only the denied host, deduped; the allowed host must NOT appear: {got:?}"
1114 );
1115 }
1116
1117 #[test]
1118 fn audit_records_allowed_with_bytes_and_denied_attempts() {
1119 let allowed = drive_forward(
1121 b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1122 &["allowed.test"],
1123 b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1124 );
1125 let ev = allowed
1126 .audit
1127 .iter()
1128 .find(|e| e.host == "allowed.test")
1129 .expect("an allowed event");
1130 assert_eq!(ev.decision, NetDecision::Allowed);
1131 assert_eq!(ev.kind, NetKind::Http);
1132 assert_eq!(ev.port, 80);
1133 assert!(
1134 ev.bytes_down > 0,
1135 "an allowed connection records response bytes: {ev:?}"
1136 );
1137
1138 let denied = drive_with(
1140 b"GET http://evil.test/y HTTP/1.1\r\nHost: ignored\r\n\r\n",
1141 &["allowed.test"],
1142 &FailingConnector,
1143 );
1144 let ev = denied
1145 .audit
1146 .iter()
1147 .find(|e| e.host == "evil.test")
1148 .expect("a denied event");
1149 assert_eq!(ev.decision, NetDecision::Denied);
1150 assert_eq!(ev.bytes_up, 0);
1151 assert_eq!(ev.bytes_down, 0);
1152 }
1153
1154 #[test]
1155 fn jsonl_sink_appends_one_newline_terminated_json_line_per_event() {
1156 #[derive(Clone, Default)]
1157 struct SharedBuf(Arc<Mutex<Vec<u8>>>);
1158 impl Write for SharedBuf {
1159 fn write(&mut self, b: &[u8]) -> io::Result<usize> {
1160 self.0.lock().unwrap().extend_from_slice(b);
1161 Ok(b.len())
1162 }
1163 fn flush(&mut self) -> io::Result<()> {
1164 Ok(())
1165 }
1166 }
1167 let buf = SharedBuf::default();
1168 let sink = JsonlSink::new(buf.clone());
1169 let mk = |host: &str| NetAuditEvent {
1170 ts_ms: 1,
1171 host: host.into(),
1172 port: 80,
1173 kind: NetKind::Http,
1174 decision: NetDecision::Allowed,
1175 bytes_up: 1,
1176 bytes_down: 2,
1177 dur_ms: 3,
1178 };
1179 sink.record(&mk("a"));
1180 sink.record(&mk("b"));
1181 let text = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
1182 let lines: Vec<&str> = text.lines().collect();
1183 assert_eq!(lines.len(), 2, "one JSON line per event: {text:?}");
1184 assert_eq!(
1185 serde_json::from_str::<NetAuditEvent>(lines[0])
1186 .unwrap()
1187 .host,
1188 "a"
1189 );
1190 assert_eq!(
1191 serde_json::from_str::<NetAuditEvent>(lines[1])
1192 .unwrap()
1193 .host,
1194 "b"
1195 );
1196 }
1197
1198 #[test]
1199 fn audit_event_json_round_trips() {
1200 let e = NetAuditEvent {
1201 ts_ms: 1,
1202 host: "h".into(),
1203 port: 443,
1204 kind: NetKind::Connect,
1205 decision: NetDecision::Allowed,
1206 bytes_up: 10,
1207 bytes_down: 20,
1208 dur_ms: 5,
1209 };
1210 let line = serde_json::to_string(&e).unwrap();
1211 assert!(line.contains("\"decision\":\"allowed\"") && line.contains("\"kind\":\"connect\""));
1212 assert_eq!(serde_json::from_str::<NetAuditEvent>(&line).unwrap(), e);
1213 }
1214
1215 #[test]
1216 fn connect_allowed_host_tunnels_opaque_bytes() {
1217 let out = drive_forward(
1223 b"CONNECT allowed.test:443 HTTP/1.1\r\nHost: allowed.test:443\r\n\r\nPING",
1224 &["allowed.test"],
1225 b"PING",
1226 );
1227 let client_saw = out.client.written_str();
1228 assert!(
1229 client_saw.starts_with("HTTP/1.1 200"),
1230 "CONNECT must be accepted with a bare 200: {client_saw:?}"
1231 );
1232 assert!(
1233 client_saw.contains("PING"),
1234 "the origin's bytes must tunnel back to the client: {client_saw:?}"
1235 );
1236 assert_eq!(
1237 out.origin.written(),
1238 b"PING",
1239 "the client's pipelined bytes must reach the origin through the tunnel"
1240 );
1241 }
1242
1243 #[test]
1244 fn connect_disallowed_host_is_refused_403() {
1245 let out = drive_with(
1246 b"CONNECT evil.test:443 HTTP/1.1\r\n\r\n",
1247 &["allowed.test"],
1248 &FailingConnector,
1249 );
1250 assert!(
1251 out.client.written_str().contains("403"),
1252 "a denied CONNECT must get 403, not a tunnel: {}",
1253 out.client.written_str()
1254 );
1255 }
1256
1257 #[test]
1258 fn http_host_header_is_normalized_to_the_validated_authority() {
1259 let out = drive_forward(
1263 b"GET http://allowed.test/ HTTP/1.1\r\nHost: evil.test\r\nConnection: close\r\n\r\n",
1264 &["allowed.test"],
1265 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1266 );
1267 let origin_saw = out.origin.written_str();
1268 assert!(
1269 origin_saw.contains("Host: allowed.test\r\n"),
1270 "origin must receive the validated Host: {origin_saw:?}"
1271 );
1272 assert!(
1273 !origin_saw.contains("evil.test"),
1274 "the spoofed Host must not reach the origin: {origin_saw:?}"
1275 );
1276 }
1277
1278 #[test]
1279 fn proxy_env_points_at_the_bound_loopback_addr() {
1280 let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
1283 let env = proxy.proxy_env();
1284 let url = format!("http://127.0.0.1:{}", proxy.addr().port());
1285 assert!(env.iter().any(|(k, v)| k == "https_proxy" && *v == url));
1286 assert!(env.iter().any(|(k, v)| k == "HTTPS_PROXY" && *v == url));
1287 assert!(env.iter().all(|(_, v)| v.starts_with("http://127.0.0.1:")));
1288 }
1289
1290 #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1296 fn spawn_origin() -> SocketAddr {
1297 let l = TcpListener::bind(("127.0.0.1", 0)).unwrap();
1298 let addr = l.local_addr().unwrap();
1299 thread::spawn(move || {
1300 for s in l.incoming().flatten() {
1301 let mut s = s;
1302 let mut b = [0u8; 512];
1303 let _ = s.read(&mut b);
1304 let _ = s.write_all(
1305 b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1306 );
1307 }
1308 });
1309 addr
1310 }
1311
1312 #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1323 #[test]
1324 fn fenced_child_reaches_allowed_via_proxy_denied_refused_direct_kernel_blocked() {
1325 use agent_bridle_core::{
1326 best_available_sandbox, loopback_fenced_caveats, seatbelt_is_supported, Caveats,
1327 SandboxPolicy, Scope,
1328 };
1329 if !seatbelt_is_supported() {
1330 eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1331 return;
1332 }
1333 let curl = "/usr/bin/curl";
1334 if !std::path::Path::new(curl).exists() {
1335 eprintln!("skipping: no curl(1)");
1336 return;
1337 }
1338
1339 let origin = spawn_origin();
1340 let proxy = start_null(
1341 ["allowed.test".to_string()],
1342 Arc::new(FixedResolver(origin)),
1343 )
1344 .unwrap();
1345
1346 let granted = Caveats {
1349 net: Scope::only(["allowed.test".to_string()]),
1350 ..Caveats::top()
1351 };
1352 let prefix = best_available_sandbox(&Arc::new(SandboxPolicy::default()))
1353 .command_prefix(&loopback_fenced_caveats(&granted))
1354 .expect("seatbelt wrapper");
1355
1356 let run = |proxy_env: bool, url: &str| -> std::process::Output {
1360 let mut cmd = std::process::Command::new(&prefix[0]);
1361 cmd.args(&prefix[1..])
1362 .arg(curl)
1363 .args(["-sv", "--max-time", "5", url])
1364 .env_clear();
1365 if proxy_env {
1366 cmd.envs(proxy.proxy_env());
1367 }
1368 cmd.output().expect("spawn sandbox-exec")
1369 };
1370
1371 let allow = run(true, "http://allowed.test/");
1373 assert!(
1374 String::from_utf8_lossy(&allow.stdout).contains("origin"),
1375 "allow-listed host must reach the origin through the proxy: {allow:?}"
1376 );
1377 let deny = run(true, "http://denied.test/");
1380 assert!(
1381 String::from_utf8_lossy(&deny.stdout).contains("403"),
1382 "denied host must get the proxy's 403: {deny:?}"
1383 );
1384 let direct = run(false, "http://1.1.1.1/");
1390 let stderr = String::from_utf8_lossy(&direct.stderr);
1391 assert_eq!(
1392 direct.status.code(),
1393 Some(7),
1394 "direct off-box egress must be kernel-denied (curl exit 7): {stderr}"
1395 );
1396 assert!(
1397 stderr.contains("Operation not permitted"),
1398 "the block must be a kernel EPERM, not a routing failure: {stderr}"
1399 );
1400
1401 drop(proxy);
1402 }
1403
1404 #[test]
1409 fn dropping_the_handle_stops_the_listener() {
1410 let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
1411 let addr = proxy.addr();
1412 drop(proxy);
1413 thread::sleep(Duration::from_millis(100));
1417 if let Ok(mut c) = TcpStream::connect_timeout(&addr, Duration::from_millis(200)) {
1418 c.set_read_timeout(Some(Duration::from_millis(500)))
1419 .unwrap();
1420 let _ = write!(c, "GET http://x/ HTTP/1.1\r\n\r\n");
1421 let mut resp = Vec::new();
1422 let _ = c.read_to_end(&mut resp);
1423 assert!(resp.is_empty(), "a stopped proxy must not serve requests");
1424 }
1425 }
1426}