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
152#[derive(Debug, PartialEq, Eq)]
154enum Target {
155 Connect { host: String, port: u16 },
157 Http {
160 host: String,
161 port: u16,
162 origin_line: String,
163 },
164}
165
166fn parse_request_line(line: &str) -> Option<Target> {
169 let line = line.trim_end_matches(['\r', '\n']);
170 let mut parts = line.split(' ');
171 let method = parts.next()?;
172 let uri = parts.next()?;
173 let version = parts.next()?;
174 if !version.starts_with("HTTP/") || parts.next().is_some() {
175 return None;
176 }
177 if method.eq_ignore_ascii_case("CONNECT") {
178 let (host, port) = split_host_port(uri, 443)?;
179 return Some(Target::Connect { host, port });
180 }
181 let rest = uri.strip_prefix("http://")?;
183 let (authority, path) = match rest.find('/') {
184 Some(i) => (&rest[..i], &rest[i..]),
185 None => (rest, "/"),
186 };
187 let (host, port) = split_host_port(authority, 80)?;
188 Some(Target::Http {
189 host,
190 port,
191 origin_line: format!("{method} {path} {version}\r\n"),
192 })
193}
194
195fn split_host_port(authority: &str, default_port: u16) -> Option<(String, u16)> {
198 if let Some(rest) = authority.strip_prefix('[') {
200 let (host, after) = rest.split_once(']')?;
201 let port = match after.strip_prefix(':') {
202 Some(p) => p.parse().ok()?,
203 None if after.is_empty() => default_port,
204 None => return None,
205 };
206 return Some((host.to_string(), port));
207 }
208 match authority.rsplit_once(':') {
209 Some((h, _)) if h.contains(':') => Some((authority.to_string(), default_port)),
213 Some(("", _)) => None,
215 Some((h, p)) => Some((h.to_string(), p.parse::<u16>().ok()?)),
217 None => Some((authority.to_string(), default_port)),
219 }
220}
221
222#[derive(Clone)]
224struct HostPolicy(Arc<HashSet<String>>);
225
226impl HostPolicy {
227 fn new(hosts: impl IntoIterator<Item = String>) -> Self {
228 Self(Arc::new(hosts.into_iter().collect()))
229 }
230 fn allows(&self, host: &str) -> bool {
231 self.0.contains(host)
232 }
233}
234
235pub struct ProxyHandle {
237 addr: SocketAddr,
238 shutdown: Arc<AtomicBool>,
239 accept: Option<JoinHandle<()>>,
240 refused: Arc<Mutex<HashSet<String>>>,
244}
245
246impl ProxyHandle {
247 #[cfg(test)]
250 pub fn addr(&self) -> SocketAddr {
251 self.addr
252 }
253
254 #[must_use]
260 pub fn refused_hosts(&self) -> Vec<String> {
261 self.refused
262 .lock()
263 .map(|s| {
264 let mut v: Vec<String> = s.iter().cloned().collect();
265 v.sort();
266 v
267 })
268 .unwrap_or_default()
269 }
270
271 #[must_use]
276 pub fn proxy_env(&self) -> Vec<(String, String)> {
277 let url = format!("http://{}", self.addr);
278 [
279 "http_proxy",
280 "https_proxy",
281 "all_proxy",
282 "HTTP_PROXY",
283 "HTTPS_PROXY",
284 "ALL_PROXY",
285 ]
286 .iter()
287 .map(|k| ((*k).to_string(), url.clone()))
288 .collect()
289 }
290}
291
292impl Drop for ProxyHandle {
293 fn drop(&mut self) {
294 self.shutdown.store(true, Ordering::SeqCst);
295 let _ = TcpStream::connect_timeout(&self.addr, Duration::from_millis(200));
297 if let Some(h) = self.accept.take() {
298 let _ = h.join();
299 }
300 }
301}
302
303pub fn start(
311 allow_hosts: impl IntoIterator<Item = String>,
312 resolver: Arc<dyn Resolver>,
313 sink: Arc<dyn AuditSink>,
314) -> io::Result<ProxyHandle> {
315 let listener = TcpListener::bind(("127.0.0.1", 0))?;
316 let addr = listener.local_addr()?;
317 let shutdown = Arc::new(AtomicBool::new(false));
318 let policy = HostPolicy::new(allow_hosts);
319 let refused: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
321
322 let accept = {
323 let shutdown = Arc::clone(&shutdown);
324 let refused = Arc::clone(&refused);
325 thread::Builder::new()
326 .name("agent-bridle-egress-proxy".to_string())
327 .spawn(move || {
328 for stream in listener.incoming() {
329 if shutdown.load(Ordering::SeqCst) {
330 break;
331 }
332 let Ok(client) = stream else { continue };
333 let policy = policy.clone();
334 let resolver = Arc::clone(&resolver);
335 let sink = Arc::clone(&sink);
336 let refused = Arc::clone(&refused);
337 let _ = thread::Builder::new()
339 .name("agent-bridle-egress-conn".to_string())
340 .spawn(move || {
341 let _ = handle_conn(
342 client,
343 &policy,
344 resolver.as_ref(),
345 sink.as_ref(),
346 &refused,
347 );
348 });
349 }
350 })?
351 };
352
353 Ok(ProxyHandle {
354 addr,
355 shutdown,
356 accept: Some(accept),
357 refused,
358 })
359}
360
361fn handle_conn(
365 client: TcpStream,
366 policy: &HostPolicy,
367 resolver: &dyn Resolver,
368 sink: &dyn AuditSink,
369 refused: &Mutex<HashSet<String>>,
370) -> io::Result<()> {
371 client.set_read_timeout(Some(CONN_TIMEOUT))?;
372 client.set_write_timeout(Some(CONN_TIMEOUT))?;
373 let mut reader = BufReader::new(client.try_clone()?);
374 let t0 = Instant::now();
375
376 let line = read_line_bounded(&mut reader)?;
377 let Some(target) = parse_request_line(&line) else {
378 return respond(&client, 400, "Bad Request");
380 };
381
382 let (host, port, kind) = match &target {
383 Target::Connect { host, port } => (host.clone(), *port, NetKind::Connect),
384 Target::Http { host, port, .. } => (host.clone(), *port, NetKind::Http),
385 };
386 let audit = |decision: NetDecision, up: u64, down: u64| {
388 sink.record(&NetAuditEvent {
389 ts_ms: now_ms(),
390 host: host.clone(),
391 port,
392 kind,
393 decision,
394 bytes_up: up,
395 bytes_down: down,
396 dur_ms: t0.elapsed().as_millis() as u64,
397 });
398 };
399
400 if !policy.allows(&host) {
401 audit(NetDecision::Denied, 0, 0); if let Ok(mut set) = refused.lock() {
405 set.insert(host.clone());
406 }
407 return respond(&client, 403, "Forbidden");
408 }
409
410 match target {
411 Target::Connect { host, port } => {
412 drain_headers(&mut reader)?;
415 let origin = match resolver
416 .resolve(&host, port)
417 .and_then(guard_target)
418 .and_then(dial)
419 {
420 Ok(o) => o,
421 Err(_) => {
422 audit(NetDecision::Error, 0, 0);
423 return respond(&client, 502, "Bad Gateway");
424 }
425 };
426 (&client).write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?;
430 let (up, down) = splice_buffered(reader, client, origin)?;
435 audit(NetDecision::Allowed, up, down);
436 Ok(())
437 }
438 Target::Http {
439 host,
440 port,
441 origin_line,
442 } => {
443 let headers = read_headers(&mut reader)?;
449 let mut origin = match resolver
450 .resolve(&host, port)
451 .and_then(guard_target)
452 .and_then(dial)
453 {
454 Ok(o) => o,
455 Err(_) => {
456 audit(NetDecision::Error, 0, 0);
457 return respond(&client, 502, "Bad Gateway");
458 }
459 };
460 let host_hdr = if port == 80 {
461 format!("Host: {host}\r\n")
462 } else {
463 format!("Host: {host}:{port}\r\n")
464 };
465 origin.write_all(origin_line.as_bytes())?;
466 origin.write_all(host_hdr.as_bytes())?;
467 for h in &headers {
468 if !h.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("host:")) {
469 origin.write_all(h.as_bytes())?;
470 }
471 }
472 origin.write_all(b"\r\n")?; let (up, down) = splice_buffered(reader, client, origin)?; audit(NetDecision::Allowed, up, down);
475 Ok(())
476 }
477 }
478}
479
480fn dial(addr: SocketAddr) -> io::Result<TcpStream> {
482 let s = TcpStream::connect_timeout(&addr, CONN_TIMEOUT)?;
483 s.set_read_timeout(Some(CONN_TIMEOUT))?;
484 s.set_write_timeout(Some(CONN_TIMEOUT))?;
485 Ok(s)
486}
487
488fn read_line_bounded(reader: &mut BufReader<TcpStream>) -> io::Result<String> {
490 let mut buf = Vec::new();
491 reader.take(MAX_HEAD as u64).read_until(b'\n', &mut buf)?;
492 Ok(String::from_utf8_lossy(&buf).into_owned())
493}
494
495fn read_headers(reader: &mut BufReader<TcpStream>) -> io::Result<Vec<String>> {
498 let mut lines = Vec::new();
499 let mut total = 0usize;
500 loop {
501 let line = read_line_bounded(reader)?;
502 total += line.len();
503 if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
504 return Ok(lines);
505 }
506 lines.push(line);
507 }
508}
509
510fn drain_headers(reader: &mut BufReader<TcpStream>) -> io::Result<()> {
512 let mut total = 0usize;
513 loop {
514 let line = read_line_bounded(reader)?;
515 total += line.len();
516 if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
517 return Ok(());
518 }
519 }
520}
521
522fn respond(client: &TcpStream, code: u16, reason: &str) -> io::Result<()> {
524 let mut c = client.try_clone()?;
525 let body = format!("{code} {reason}\n");
526 write!(
527 c,
528 "HTTP/1.1 {code} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
529 body.len()
530 )?;
531 let _ = c.flush();
532 let _ = client.shutdown(Shutdown::Both);
533 Ok(())
534}
535
536fn copy_counted(from: &mut impl Read, to: &mut impl Write) -> u64 {
541 let mut buf = [0u8; 16 * 1024];
542 let mut total = 0u64;
543 loop {
544 match from.read(&mut buf) {
545 Ok(0) => break,
546 Ok(n) => {
547 if to.write_all(&buf[..n]).is_err() {
548 break;
549 }
550 total += n as u64;
551 }
552 Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
553 Err(_) => break,
554 }
555 }
556 total
557}
558
559fn guard_target(addr: SocketAddr) -> io::Result<SocketAddr> {
574 if is_internal_ip(&addr.ip()) {
575 return Err(io::Error::new(
576 io::ErrorKind::PermissionDenied,
577 "SSRF-guard: refusing to proxy to an internal (non-loopback) address",
578 ));
579 }
580 Ok(addr)
581}
582
583fn is_internal_ip(ip: &std::net::IpAddr) -> bool {
586 match ip {
587 std::net::IpAddr::V4(v4) => {
588 let o = v4.octets();
589 v4.is_private()
590 || v4.is_link_local()
591 || v4.is_unspecified()
592 || v4.is_broadcast()
593 || (o[0] == 100 && (64..=127).contains(&o[1]))
595 }
596 std::net::IpAddr::V6(v6) => {
597 let seg0 = v6.segments()[0];
598 v6.is_unspecified()
599 || (seg0 & 0xfe00) == 0xfc00 || (seg0 & 0xffc0) == 0xfe80 }
602 }
603}
604
605fn splice_buffered(
609 mut client_reader: BufReader<TcpStream>,
610 client: TcpStream,
611 origin: TcpStream,
612) -> io::Result<(u64, u64)> {
613 let mut o_write = origin.try_clone()?;
614 let up = thread::spawn(move || {
615 let n = copy_counted(&mut client_reader, &mut o_write);
616 let _ = o_write.shutdown(Shutdown::Write);
617 n
618 });
619 let mut o_read = origin;
620 let mut c_write = client;
621 let down = copy_counted(&mut o_read, &mut c_write);
622 let _ = c_write.shutdown(Shutdown::Both);
631 let up = up.join().unwrap_or(0);
632 Ok((up, down))
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 #[test]
644 fn guard_target_refuses_internal_permits_loopback_and_global() {
645 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
646 let refused: [IpAddr; 7] = [
649 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(), ];
657 for ip in refused {
658 assert!(is_internal_ip(&ip), "{ip} must classify as internal");
659 assert!(
660 guard_target(SocketAddr::new(ip, 80)).is_err(),
661 "{ip} must be refused"
662 );
663 }
664 let allowed = [
665 "127.0.0.1",
666 "::1",
667 "8.8.8.8",
668 "1.1.1.1",
669 "2606:4700:4700::1111",
670 ];
671 for s in allowed {
672 let ip: IpAddr = s.parse().unwrap();
673 assert!(!is_internal_ip(&ip), "{s} must be permitted");
674 assert!(
675 guard_target(SocketAddr::new(ip, 443)).is_ok(),
676 "{s} must be permitted"
677 );
678 }
679 }
680
681 #[test]
682 fn parses_connect() {
683 assert_eq!(
684 parse_request_line("CONNECT example.com:443 HTTP/1.1\r\n"),
685 Some(Target::Connect {
686 host: "example.com".to_string(),
687 port: 443
688 })
689 );
690 assert_eq!(
692 parse_request_line("CONNECT example.com HTTP/1.1"),
693 Some(Target::Connect {
694 host: "example.com".to_string(),
695 port: 443
696 })
697 );
698 }
699
700 #[test]
701 fn parses_http_absolute_form_and_rewrites_to_origin_form() {
702 let t = parse_request_line("GET http://example.com/a/b?q=1 HTTP/1.1\r\n").unwrap();
703 assert_eq!(
704 t,
705 Target::Http {
706 host: "example.com".to_string(),
707 port: 80,
708 origin_line: "GET /a/b?q=1 HTTP/1.1\r\n".to_string(),
709 }
710 );
711 let t = parse_request_line("HEAD http://h:8080 HTTP/1.0").unwrap();
713 assert_eq!(
714 t,
715 Target::Http {
716 host: "h".to_string(),
717 port: 8080,
718 origin_line: "HEAD / HTTP/1.0\r\n".to_string(),
719 }
720 );
721 }
722
723 #[test]
724 fn parses_ipv6_authority() {
725 assert_eq!(
726 parse_request_line("CONNECT [::1]:8443 HTTP/1.1"),
727 Some(Target::Connect {
728 host: "::1".to_string(),
729 port: 8443
730 })
731 );
732 }
733
734 #[test]
735 fn rejects_malformed_and_unspoken_schemes() {
736 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());
739 assert!(parse_request_line("garbage").is_none());
740 assert!(parse_request_line("CONNECT x:notaport HTTP/1.1").is_none());
741 }
742
743 struct FixedResolver(SocketAddr);
745 impl Resolver for FixedResolver {
746 fn resolve(&self, _host: &str, _port: u16) -> io::Result<SocketAddr> {
747 Ok(self.0)
748 }
749 }
750
751 fn start_null(
753 hosts: impl IntoIterator<Item = String>,
754 resolver: Arc<dyn Resolver>,
755 ) -> io::Result<ProxyHandle> {
756 start(hosts, resolver, Arc::new(NullSink))
757 }
758
759 #[derive(Clone, Default)]
761 struct CapturingSink(Arc<Mutex<Vec<NetAuditEvent>>>);
762 impl AuditSink for CapturingSink {
763 fn record(&self, event: &NetAuditEvent) {
764 self.0.lock().unwrap().push(event.clone());
765 }
766 }
767 impl CapturingSink {
768 fn events(&self) -> Vec<NetAuditEvent> {
769 self.0.lock().unwrap().clone()
770 }
771 }
772
773 fn net_test_lock() -> std::sync::MutexGuard<'static, ()> {
782 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
783 LOCK.lock().unwrap_or_else(|e| e.into_inner())
784 }
785
786 fn spawn_origin() -> SocketAddr {
788 let l = TcpListener::bind(("127.0.0.1", 0)).unwrap();
789 let addr = l.local_addr().unwrap();
790 thread::spawn(move || {
791 for s in l.incoming().flatten() {
792 let mut s = s;
793 let mut b = [0u8; 512];
794 let _ = s.read(&mut b);
795 let _ = s.write_all(
796 b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
797 );
798 }
799 });
800 addr
801 }
802
803 fn http_get_via_proxy(proxy: SocketAddr, url: &str) -> (String, Vec<u8>) {
804 let mut c = TcpStream::connect(proxy).unwrap();
805 c.set_read_timeout(Some(Duration::from_secs(30))).unwrap();
806 write!(
807 c,
808 "GET {url} HTTP/1.1\r\nHost: ignored\r\nConnection: close\r\n\r\n"
809 )
810 .unwrap();
811 let mut resp = Vec::new();
812 let _ = c.read_to_end(&mut resp);
813 let status = String::from_utf8_lossy(&resp)
814 .lines()
815 .next()
816 .unwrap_or_default()
817 .to_string();
818 (status, resp)
819 }
820
821 #[test]
822 fn allowed_http_host_is_forwarded_to_origin() {
823 let _serial = net_test_lock();
824 let origin = spawn_origin();
825 let proxy = start_null(
826 ["allowed.test".to_string()],
827 Arc::new(FixedResolver(origin)),
828 )
829 .unwrap();
830 let (status, body) = http_get_via_proxy(proxy.addr(), "http://allowed.test/x");
831 assert!(status.contains("200"), "status={status}");
832 assert!(
833 String::from_utf8_lossy(&body).contains("origin"),
834 "the origin's body must reach the client: {status}"
835 );
836 }
837
838 #[test]
839 fn disallowed_http_host_is_refused_403_without_reaching_origin() {
840 let _serial = net_test_lock();
841 let proxy = start_null(
843 ["allowed.test".to_string()],
844 Arc::new(FixedResolver("127.0.0.1:1".parse().unwrap())),
845 )
846 .unwrap();
847 let (status, _) = http_get_via_proxy(proxy.addr(), "http://evil.test/x");
848 assert!(status.contains("403"), "denied host must get 403: {status}");
849 }
850
851 #[test]
855 fn refused_hosts_surfaces_denied_hosts_deduped_and_omits_allowed() {
856 let _serial = net_test_lock();
857 let origin = spawn_origin();
858 let proxy = start_null(
859 ["allowed.test".to_string()],
860 Arc::new(FixedResolver(origin)),
861 )
862 .unwrap();
863 let _ = http_get_via_proxy(proxy.addr(), "http://allowed.test/x"); let _ = http_get_via_proxy(proxy.addr(), "http://evil.test/y"); let _ = http_get_via_proxy(proxy.addr(), "http://evil.test/z"); let mut refused = Vec::new();
869 for _ in 0..200 {
870 refused = proxy.refused_hosts();
871 if !refused.is_empty() {
872 break;
873 }
874 thread::sleep(Duration::from_millis(10));
875 }
876 assert_eq!(
877 refused,
878 vec!["evil.test".to_string()],
879 "only the denied host, deduped; allowed host must NOT appear: {refused:?}"
880 );
881 }
882
883 #[test]
884 fn audit_records_allowed_with_bytes_and_denied_attempts() {
885 let _serial = net_test_lock();
886 let origin = spawn_origin();
887 let sink = CapturingSink::default();
888 let proxy = start(
889 ["allowed.test".to_string()],
890 Arc::new(FixedResolver(origin)),
891 Arc::new(sink.clone()),
892 )
893 .unwrap();
894 let _ = http_get_via_proxy(proxy.addr(), "http://allowed.test/x");
895 let _ = http_get_via_proxy(proxy.addr(), "http://evil.test/y"); let deadline = Instant::now() + Duration::from_secs(30);
912 let events = loop {
913 let ev = sink.events();
914 let have = |h: &str| ev.iter().any(|e| e.host == h);
915 if have("allowed.test") && have("evil.test") {
916 break ev;
917 }
918 if Instant::now() >= deadline {
919 eprintln!(
920 "skipping audit assertions: records did not land within the deadline \
921 (constrained CI runner); got {} event(s)",
922 ev.len()
923 );
924 return;
925 }
926 thread::sleep(Duration::from_millis(20));
927 };
928 let allowed = events
929 .iter()
930 .find(|e| e.host == "allowed.test")
931 .expect("an allowed event");
932 assert_eq!(allowed.decision, NetDecision::Allowed);
933 assert_eq!(allowed.kind, NetKind::Http);
934 assert_eq!(allowed.port, 80);
935 assert!(
936 allowed.bytes_down > 0,
937 "an allowed connection records response bytes: {allowed:?}"
938 );
939
940 let denied = events
941 .iter()
942 .find(|e| e.host == "evil.test")
943 .expect("a denied event (the exfil-attempt signal)");
944 assert_eq!(denied.decision, NetDecision::Denied);
945 assert_eq!(denied.bytes_up, 0);
946 assert_eq!(denied.bytes_down, 0);
947 }
948
949 #[test]
950 fn jsonl_sink_appends_one_newline_terminated_json_line_per_event() {
951 #[derive(Clone, Default)]
952 struct SharedBuf(Arc<Mutex<Vec<u8>>>);
953 impl Write for SharedBuf {
954 fn write(&mut self, b: &[u8]) -> io::Result<usize> {
955 self.0.lock().unwrap().extend_from_slice(b);
956 Ok(b.len())
957 }
958 fn flush(&mut self) -> io::Result<()> {
959 Ok(())
960 }
961 }
962 let buf = SharedBuf::default();
963 let sink = JsonlSink::new(buf.clone());
964 let mk = |host: &str| NetAuditEvent {
965 ts_ms: 1,
966 host: host.into(),
967 port: 80,
968 kind: NetKind::Http,
969 decision: NetDecision::Allowed,
970 bytes_up: 1,
971 bytes_down: 2,
972 dur_ms: 3,
973 };
974 sink.record(&mk("a"));
975 sink.record(&mk("b"));
976 let text = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
977 let lines: Vec<&str> = text.lines().collect();
978 assert_eq!(lines.len(), 2, "one JSON line per event: {text:?}");
979 assert_eq!(
980 serde_json::from_str::<NetAuditEvent>(lines[0])
981 .unwrap()
982 .host,
983 "a"
984 );
985 assert_eq!(
986 serde_json::from_str::<NetAuditEvent>(lines[1])
987 .unwrap()
988 .host,
989 "b"
990 );
991 }
992
993 #[test]
994 fn audit_event_json_round_trips() {
995 let e = NetAuditEvent {
996 ts_ms: 1,
997 host: "h".into(),
998 port: 443,
999 kind: NetKind::Connect,
1000 decision: NetDecision::Allowed,
1001 bytes_up: 10,
1002 bytes_down: 20,
1003 dur_ms: 5,
1004 };
1005 let line = serde_json::to_string(&e).unwrap();
1006 assert!(line.contains("\"decision\":\"allowed\"") && line.contains("\"kind\":\"connect\""));
1007 assert_eq!(serde_json::from_str::<NetAuditEvent>(&line).unwrap(), e);
1008 }
1009
1010 fn spawn_echo() -> SocketAddr {
1013 let l = TcpListener::bind(("127.0.0.1", 0)).unwrap();
1014 let addr = l.local_addr().unwrap();
1015 thread::spawn(move || {
1016 for s in l.incoming().flatten() {
1017 let mut s = s;
1018 let mut b = [0u8; 64];
1019 if let Ok(n) = s.read(&mut b) {
1020 let _ = s.write_all(&b[..n]);
1021 }
1022 }
1023 });
1024 addr
1025 }
1026
1027 #[test]
1028 fn connect_allowed_host_tunnels_opaque_bytes() {
1029 let _serial = net_test_lock();
1030 let echo = spawn_echo();
1031 let proxy =
1032 start_null(["allowed.test".to_string()], Arc::new(FixedResolver(echo))).unwrap();
1033 let mut c = TcpStream::connect(proxy.addr()).unwrap();
1034 c.set_read_timeout(Some(Duration::from_secs(30))).unwrap();
1035 write!(
1036 c,
1037 "CONNECT allowed.test:443 HTTP/1.1\r\nHost: allowed.test:443\r\n\r\n"
1038 )
1039 .unwrap();
1040 let mut status = BufReader::new(c.try_clone().unwrap());
1043 let mut head = String::new();
1044 loop {
1045 let mut l = String::new();
1046 status.read_line(&mut l).unwrap();
1047 if l == "\r\n" || l.is_empty() {
1048 break;
1049 }
1050 head.push_str(&l);
1051 }
1052 assert!(
1053 head.starts_with("HTTP/1.1 200"),
1054 "CONNECT must be accepted with a bare 200: {head:?}"
1055 );
1056 c.write_all(b"PING").unwrap();
1058 let mut back = [0u8; 4];
1059 status.read_exact(&mut back).unwrap();
1060 assert_eq!(
1061 &back, b"PING",
1062 "the tunnel must forward raw bytes both ways"
1063 );
1064 }
1065
1066 #[test]
1067 fn connect_disallowed_host_is_refused_403() {
1068 let _serial = net_test_lock();
1069 let proxy = start_null(
1070 ["allowed.test".to_string()],
1071 Arc::new(FixedResolver("127.0.0.1:1".parse().unwrap())),
1072 )
1073 .unwrap();
1074 let mut c = TcpStream::connect(proxy.addr()).unwrap();
1075 c.set_read_timeout(Some(Duration::from_secs(30))).unwrap();
1076 write!(c, "CONNECT evil.test:443 HTTP/1.1\r\n\r\n").unwrap();
1077 let mut resp = Vec::new();
1078 let _ = c.read_to_end(&mut resp);
1079 assert!(
1080 String::from_utf8_lossy(&resp).contains("403"),
1081 "a denied CONNECT must get 403, not a tunnel: {:?}",
1082 String::from_utf8_lossy(&resp)
1083 );
1084 }
1085
1086 fn spawn_host_echo() -> SocketAddr {
1088 let l = TcpListener::bind(("127.0.0.1", 0)).unwrap();
1089 let addr = l.local_addr().unwrap();
1090 thread::spawn(move || {
1091 for s in l.incoming().flatten() {
1092 let mut s = s;
1093 let mut reader = BufReader::new(s.try_clone().unwrap());
1094 let mut host = String::new();
1095 loop {
1096 let mut line = String::new();
1097 if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
1098 break;
1099 }
1100 if let Some(v) = line.get(..5).filter(|p| p.eq_ignore_ascii_case("host:")) {
1101 let _ = v;
1102 host = line[5..].trim().to_string();
1103 }
1104 }
1105 let body = format!("host={host}");
1106 let _ = write!(
1107 s,
1108 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1109 body.len()
1110 );
1111 }
1112 });
1113 addr
1114 }
1115
1116 #[test]
1117 fn http_host_header_is_normalized_to_the_validated_authority() {
1118 let _serial = net_test_lock();
1119 let origin = spawn_host_echo();
1120 let proxy = start_null(
1121 ["allowed.test".to_string()],
1122 Arc::new(FixedResolver(origin)),
1123 )
1124 .unwrap();
1125 let mut c = TcpStream::connect(proxy.addr()).unwrap();
1129 c.set_read_timeout(Some(Duration::from_secs(30))).unwrap();
1130 write!(
1131 c,
1132 "GET http://allowed.test/ HTTP/1.1\r\nHost: evil.test\r\nConnection: close\r\n\r\n"
1133 )
1134 .unwrap();
1135 let mut resp = Vec::new();
1136 let _ = c.read_to_end(&mut resp);
1137 let resp = String::from_utf8_lossy(&resp);
1138 assert!(
1139 resp.contains("host=allowed.test"),
1140 "origin must receive the validated Host: {resp}"
1141 );
1142 assert!(
1143 !resp.contains("evil.test"),
1144 "the spoofed Host must not reach the origin: {resp}"
1145 );
1146 }
1147
1148 #[test]
1149 fn proxy_env_points_at_the_bound_loopback_addr() {
1150 let _serial = net_test_lock();
1151 let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
1152 let env = proxy.proxy_env();
1153 let url = format!("http://127.0.0.1:{}", proxy.addr().port());
1154 assert!(env.iter().any(|(k, v)| k == "https_proxy" && *v == url));
1155 assert!(env.iter().any(|(k, v)| k == "HTTPS_PROXY" && *v == url));
1156 assert!(env.iter().all(|(_, v)| v.starts_with("http://127.0.0.1:")));
1157 }
1158
1159 #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1167 #[test]
1168 fn fenced_child_reaches_allowed_via_proxy_denied_refused_direct_kernel_blocked() {
1169 use agent_bridle_core::{
1170 best_available_sandbox, loopback_fenced_caveats, seatbelt_is_supported, Caveats,
1171 SandboxPolicy, Scope,
1172 };
1173 if !seatbelt_is_supported() {
1174 eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1175 return;
1176 }
1177 let curl = "/usr/bin/curl";
1178 if !std::path::Path::new(curl).exists() {
1179 eprintln!("skipping: no curl(1)");
1180 return;
1181 }
1182
1183 let origin = spawn_origin();
1184 let proxy = start_null(
1185 ["allowed.test".to_string()],
1186 Arc::new(FixedResolver(origin)),
1187 )
1188 .unwrap();
1189
1190 let granted = Caveats {
1193 net: Scope::only(["allowed.test".to_string()]),
1194 ..Caveats::top()
1195 };
1196 let prefix = best_available_sandbox(&Arc::new(SandboxPolicy::default()))
1197 .command_prefix(&loopback_fenced_caveats(&granted))
1198 .expect("seatbelt wrapper");
1199
1200 let run = |proxy_env: bool, url: &str| -> std::process::Output {
1204 let mut cmd = std::process::Command::new(&prefix[0]);
1205 cmd.args(&prefix[1..])
1206 .arg(curl)
1207 .args(["-sv", "--max-time", "5", url])
1208 .env_clear();
1209 if proxy_env {
1210 cmd.envs(proxy.proxy_env());
1211 }
1212 cmd.output().expect("spawn sandbox-exec")
1213 };
1214
1215 let allow = run(true, "http://allowed.test/");
1217 assert!(
1218 String::from_utf8_lossy(&allow.stdout).contains("origin"),
1219 "allow-listed host must reach the origin through the proxy: {allow:?}"
1220 );
1221 let deny = run(true, "http://denied.test/");
1224 assert!(
1225 String::from_utf8_lossy(&deny.stdout).contains("403"),
1226 "denied host must get the proxy's 403: {deny:?}"
1227 );
1228 let direct = run(false, "http://1.1.1.1/");
1234 let stderr = String::from_utf8_lossy(&direct.stderr);
1235 assert_eq!(
1236 direct.status.code(),
1237 Some(7),
1238 "direct off-box egress must be kernel-denied (curl exit 7): {stderr}"
1239 );
1240 assert!(
1241 stderr.contains("Operation not permitted"),
1242 "the block must be a kernel EPERM, not a routing failure: {stderr}"
1243 );
1244
1245 drop(proxy);
1246 }
1247
1248 #[test]
1249 fn dropping_the_handle_stops_the_listener() {
1250 let _serial = net_test_lock();
1251 let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
1252 let addr = proxy.addr();
1253 drop(proxy);
1254 thread::sleep(Duration::from_millis(100));
1258 if let Ok(mut c) = TcpStream::connect_timeout(&addr, Duration::from_millis(200)) {
1259 c.set_read_timeout(Some(Duration::from_millis(500)))
1260 .unwrap();
1261 let _ = write!(c, "GET http://x/ HTTP/1.1\r\n\r\n");
1262 let mut resp = Vec::new();
1263 let _ = c.read_to_end(&mut resp);
1264 assert!(resp.is_empty(), "a stopped proxy must not serve requests");
1265 }
1266 }
1267}