use std::collections::HashSet;
use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
const MAX_HEAD: usize = 8 * 1024;
const CONN_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NetKind {
Connect,
Http,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NetDecision {
Allowed,
Denied,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetAuditEvent {
pub ts_ms: u64,
pub host: String,
pub port: u16,
pub kind: NetKind,
pub decision: NetDecision,
pub bytes_up: u64,
pub bytes_down: u64,
pub dur_ms: u64,
}
pub trait AuditSink: Send + Sync {
fn record(&self, event: &NetAuditEvent);
}
pub struct NullSink;
impl AuditSink for NullSink {
fn record(&self, _event: &NetAuditEvent) {}
}
pub struct JsonlSink<W: Write + Send>(Mutex<W>);
impl<W: Write + Send> JsonlSink<W> {
pub fn new(w: W) -> Self {
Self(Mutex::new(w))
}
}
impl<W: Write + Send> AuditSink for JsonlSink<W> {
fn record(&self, event: &NetAuditEvent) {
if let (Ok(mut w), Ok(mut line)) = (self.0.lock(), serde_json::to_string(event)) {
line.push('\n');
let _ = w.write_all(line.as_bytes());
let _ = w.flush();
}
}
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub trait Resolver: Send + Sync {
fn resolve(&self, host: &str, port: u16) -> io::Result<SocketAddr>;
}
pub struct StdResolver;
impl Resolver for StdResolver {
fn resolve(&self, host: &str, port: u16) -> io::Result<SocketAddr> {
(host, port)
.to_socket_addrs()?
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no address for host"))
}
}
pub trait Conn: Read + Write + Send {
fn dup(&self) -> io::Result<Box<dyn Conn>>;
fn shutdown(&self, how: Shutdown) -> io::Result<()>;
fn set_timeouts(&self, dur: Duration) -> io::Result<()>;
}
impl Conn for TcpStream {
fn dup(&self) -> io::Result<Box<dyn Conn>> {
Ok(Box::new(self.try_clone()?))
}
fn shutdown(&self, how: Shutdown) -> io::Result<()> {
TcpStream::shutdown(self, how)
}
fn set_timeouts(&self, dur: Duration) -> io::Result<()> {
self.set_read_timeout(Some(dur))?;
self.set_write_timeout(Some(dur))?;
Ok(())
}
}
pub trait Connector: Send + Sync {
fn connect(&self, addr: SocketAddr) -> io::Result<Box<dyn Conn>>;
}
pub struct TcpConnector;
impl Connector for TcpConnector {
fn connect(&self, addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
let s = TcpStream::connect_timeout(&addr, CONN_TIMEOUT)?;
s.set_read_timeout(Some(CONN_TIMEOUT))?;
s.set_write_timeout(Some(CONN_TIMEOUT))?;
Ok(Box::new(s))
}
}
#[derive(Debug, PartialEq, Eq)]
enum Target {
Connect { host: String, port: u16 },
Http {
host: String,
port: u16,
origin_line: String,
},
}
fn parse_request_line(line: &str) -> Option<Target> {
let line = line.trim_end_matches(['\r', '\n']);
let mut parts = line.split(' ');
let method = parts.next()?;
let uri = parts.next()?;
let version = parts.next()?;
if !version.starts_with("HTTP/") || parts.next().is_some() {
return None;
}
if method.eq_ignore_ascii_case("CONNECT") {
let (host, port) = split_host_port(uri, 443)?;
return Some(Target::Connect { host, port });
}
let rest = uri.strip_prefix("http://")?;
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, "/"),
};
let (host, port) = split_host_port(authority, 80)?;
Some(Target::Http {
host,
port,
origin_line: format!("{method} {path} {version}\r\n"),
})
}
fn split_host_port(authority: &str, default_port: u16) -> Option<(String, u16)> {
if let Some(rest) = authority.strip_prefix('[') {
let (host, after) = rest.split_once(']')?;
let port = match after.strip_prefix(':') {
Some(p) => p.parse().ok()?,
None if after.is_empty() => default_port,
None => return None,
};
return Some((host.to_string(), port));
}
match authority.rsplit_once(':') {
Some((h, _)) if h.contains(':') => Some((authority.to_string(), default_port)),
Some(("", _)) => None,
Some((h, p)) => Some((h.to_string(), p.parse::<u16>().ok()?)),
None => Some((authority.to_string(), default_port)),
}
}
#[derive(Clone)]
struct HostPolicy(Arc<HashSet<String>>);
impl HostPolicy {
fn new(hosts: impl IntoIterator<Item = String>) -> Self {
Self(Arc::new(hosts.into_iter().collect()))
}
fn allows(&self, host: &str) -> bool {
self.0.contains(host)
}
}
pub struct ProxyHandle {
addr: SocketAddr,
shutdown: Arc<AtomicBool>,
accept: Option<JoinHandle<()>>,
refused: Arc<Mutex<HashSet<String>>>,
}
impl ProxyHandle {
#[cfg(test)]
pub fn addr(&self) -> SocketAddr {
self.addr
}
#[must_use]
pub fn refused_hosts(&self) -> Vec<String> {
self.refused
.lock()
.map(|s| {
let mut v: Vec<String> = s.iter().cloned().collect();
v.sort();
v
})
.unwrap_or_default()
}
#[must_use]
pub fn proxy_env(&self) -> Vec<(String, String)> {
let url = format!("http://{}", self.addr);
[
"http_proxy",
"https_proxy",
"all_proxy",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
]
.iter()
.map(|k| ((*k).to_string(), url.clone()))
.collect()
}
}
impl Drop for ProxyHandle {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
let _ = TcpStream::connect_timeout(&self.addr, Duration::from_millis(200));
if let Some(h) = self.accept.take() {
let _ = h.join();
}
}
}
pub fn start(
allow_hosts: impl IntoIterator<Item = String>,
resolver: Arc<dyn Resolver>,
sink: Arc<dyn AuditSink>,
) -> io::Result<ProxyHandle> {
let listener = TcpListener::bind(("127.0.0.1", 0))?;
let addr = listener.local_addr()?;
let shutdown = Arc::new(AtomicBool::new(false));
let policy = HostPolicy::new(allow_hosts);
let connector: Arc<dyn Connector> = Arc::new(TcpConnector);
let refused: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
let accept = {
let shutdown = Arc::clone(&shutdown);
let refused = Arc::clone(&refused);
thread::Builder::new()
.name("agent-bridle-egress-proxy".to_string())
.spawn(move || {
for stream in listener.incoming() {
if shutdown.load(Ordering::SeqCst) {
break;
}
let Ok(client) = stream else { continue };
let policy = policy.clone();
let resolver = Arc::clone(&resolver);
let connector = Arc::clone(&connector);
let sink = Arc::clone(&sink);
let refused = Arc::clone(&refused);
let _ = thread::Builder::new()
.name("agent-bridle-egress-conn".to_string())
.spawn(move || {
let _ = handle_conn(
Box::new(client),
&policy,
connector.as_ref(),
resolver.as_ref(),
sink.as_ref(),
&refused,
);
});
}
})?
};
Ok(ProxyHandle {
addr,
shutdown,
accept: Some(accept),
refused,
})
}
fn handle_conn(
client: Box<dyn Conn>,
policy: &HostPolicy,
connector: &dyn Connector,
resolver: &dyn Resolver,
sink: &dyn AuditSink,
refused: &Mutex<HashSet<String>>,
) -> io::Result<()> {
client.set_timeouts(CONN_TIMEOUT)?;
let mut reader = BufReader::new(client.dup()?);
let t0 = Instant::now();
let line = read_line_bounded(&mut reader)?;
let Some(target) = parse_request_line(&line) else {
return respond(client.as_ref(), 400, "Bad Request");
};
let (host, port, kind) = match &target {
Target::Connect { host, port } => (host.clone(), *port, NetKind::Connect),
Target::Http { host, port, .. } => (host.clone(), *port, NetKind::Http),
};
let audit = |decision: NetDecision, up: u64, down: u64| {
sink.record(&NetAuditEvent {
ts_ms: now_ms(),
host: host.clone(),
port,
kind,
decision,
bytes_up: up,
bytes_down: down,
dur_ms: t0.elapsed().as_millis() as u64,
});
};
if !policy.allows(&host) {
audit(NetDecision::Denied, 0, 0); if let Ok(mut set) = refused.lock() {
set.insert(host.clone());
}
return respond(client.as_ref(), 403, "Forbidden");
}
match target {
Target::Connect { host, port } => {
drain_headers(&mut reader)?;
let origin = match resolver
.resolve(&host, port)
.and_then(guard_target)
.and_then(|addr| connector.connect(addr))
{
Ok(o) => o,
Err(_) => {
audit(NetDecision::Error, 0, 0);
return respond(client.as_ref(), 502, "Bad Gateway");
}
};
let mut client = client;
client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?;
let (up, down) = splice_buffered(reader, client, origin)?;
audit(NetDecision::Allowed, up, down);
Ok(())
}
Target::Http {
host,
port,
origin_line,
} => {
let headers = read_headers(&mut reader)?;
let mut origin = match resolver
.resolve(&host, port)
.and_then(guard_target)
.and_then(|addr| connector.connect(addr))
{
Ok(o) => o,
Err(_) => {
audit(NetDecision::Error, 0, 0);
return respond(client.as_ref(), 502, "Bad Gateway");
}
};
let host_hdr = if port == 80 {
format!("Host: {host}\r\n")
} else {
format!("Host: {host}:{port}\r\n")
};
origin.write_all(origin_line.as_bytes())?;
origin.write_all(host_hdr.as_bytes())?;
for h in &headers {
if !h.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("host:")) {
origin.write_all(h.as_bytes())?;
}
}
origin.write_all(b"\r\n")?; let (up, down) = splice_buffered(reader, client, origin)?; audit(NetDecision::Allowed, up, down);
Ok(())
}
}
}
fn read_line_bounded<R: Read>(reader: &mut BufReader<R>) -> io::Result<String> {
let mut buf = Vec::new();
reader.take(MAX_HEAD as u64).read_until(b'\n', &mut buf)?;
Ok(String::from_utf8_lossy(&buf).into_owned())
}
fn read_headers<R: Read>(reader: &mut BufReader<R>) -> io::Result<Vec<String>> {
let mut lines = Vec::new();
let mut total = 0usize;
loop {
let line = read_line_bounded(reader)?;
total += line.len();
if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
return Ok(lines);
}
lines.push(line);
}
}
fn drain_headers<R: Read>(reader: &mut BufReader<R>) -> io::Result<()> {
let mut total = 0usize;
loop {
let line = read_line_bounded(reader)?;
total += line.len();
if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
return Ok(());
}
}
}
fn respond(client: &dyn Conn, code: u16, reason: &str) -> io::Result<()> {
let mut c = client.dup()?;
let body = format!("{code} {reason}\n");
write!(
c,
"HTTP/1.1 {code} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)?;
let _ = c.flush();
let _ = client.shutdown(Shutdown::Both);
Ok(())
}
fn copy_counted(from: &mut impl Read, to: &mut impl Write) -> u64 {
let mut buf = [0u8; 16 * 1024];
let mut total = 0u64;
loop {
match from.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if to.write_all(&buf[..n]).is_err() {
break;
}
total += n as u64;
}
Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
total
}
fn guard_target(addr: SocketAddr) -> io::Result<SocketAddr> {
if is_internal_ip(&addr.ip()) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"SSRF-guard: refusing to proxy to an internal (non-loopback) address",
));
}
Ok(addr)
}
fn is_internal_ip(ip: &std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| (o[0] == 100 && (64..=127).contains(&o[1]))
}
std::net::IpAddr::V6(v6) => {
let seg0 = v6.segments()[0];
v6.is_unspecified()
|| (seg0 & 0xfe00) == 0xfc00 || (seg0 & 0xffc0) == 0xfe80 }
}
}
fn splice_buffered(
mut client_reader: BufReader<Box<dyn Conn>>,
client: Box<dyn Conn>,
origin: Box<dyn Conn>,
) -> io::Result<(u64, u64)> {
let mut o_write = origin.dup()?;
let up = thread::spawn(move || {
let n = copy_counted(&mut client_reader, &mut o_write);
let _ = o_write.shutdown(Shutdown::Write);
n
});
let mut o_read = origin;
let mut c_write = client;
let down = copy_counted(&mut o_read, &mut c_write);
let _ = c_write.shutdown(Shutdown::Both);
let up = up.join().unwrap_or(0);
Ok((up, down))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
#[test]
fn guard_target_refuses_internal_permits_loopback_and_global() {
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let refused: [IpAddr; 7] = [
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(), ];
for ip in refused {
assert!(is_internal_ip(&ip), "{ip} must classify as internal");
assert!(
guard_target(SocketAddr::new(ip, 80)).is_err(),
"{ip} must be refused"
);
}
let allowed = [
"127.0.0.1",
"::1",
"8.8.8.8",
"1.1.1.1",
"2606:4700:4700::1111",
];
for s in allowed {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_internal_ip(&ip), "{s} must be permitted");
assert!(
guard_target(SocketAddr::new(ip, 443)).is_ok(),
"{s} must be permitted"
);
}
}
#[test]
fn parses_connect() {
assert_eq!(
parse_request_line("CONNECT example.com:443 HTTP/1.1\r\n"),
Some(Target::Connect {
host: "example.com".to_string(),
port: 443
})
);
assert_eq!(
parse_request_line("CONNECT example.com HTTP/1.1"),
Some(Target::Connect {
host: "example.com".to_string(),
port: 443
})
);
}
#[test]
fn parses_http_absolute_form_and_rewrites_to_origin_form() {
let t = parse_request_line("GET http://example.com/a/b?q=1 HTTP/1.1\r\n").unwrap();
assert_eq!(
t,
Target::Http {
host: "example.com".to_string(),
port: 80,
origin_line: "GET /a/b?q=1 HTTP/1.1\r\n".to_string(),
}
);
let t = parse_request_line("HEAD http://h:8080 HTTP/1.0").unwrap();
assert_eq!(
t,
Target::Http {
host: "h".to_string(),
port: 8080,
origin_line: "HEAD / HTTP/1.0\r\n".to_string(),
}
);
}
#[test]
fn parses_ipv6_authority() {
assert_eq!(
parse_request_line("CONNECT [::1]:8443 HTTP/1.1"),
Some(Target::Connect {
host: "::1".to_string(),
port: 8443
})
);
}
#[test]
fn rejects_malformed_and_unspoken_schemes() {
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());
assert!(parse_request_line("garbage").is_none());
assert!(parse_request_line("CONNECT x:notaport HTTP/1.1").is_none());
}
struct FixedResolver(SocketAddr);
impl Resolver for FixedResolver {
fn resolve(&self, _host: &str, _port: u16) -> io::Result<SocketAddr> {
Ok(self.0)
}
}
fn start_null(
hosts: impl IntoIterator<Item = String>,
resolver: Arc<dyn Resolver>,
) -> io::Result<ProxyHandle> {
start(hosts, resolver, Arc::new(NullSink))
}
#[derive(Clone, Default)]
struct CapturingSink(Arc<Mutex<Vec<NetAuditEvent>>>);
impl AuditSink for CapturingSink {
fn record(&self, event: &NetAuditEvent) {
self.0.lock().unwrap().push(event.clone());
}
}
impl CapturingSink {
fn events(&self) -> Vec<NetAuditEvent> {
self.0.lock().unwrap().clone()
}
}
#[derive(Clone, Default)]
struct ScriptedConn {
to_read: Arc<Mutex<VecDeque<u8>>>,
written: Arc<Mutex<Vec<u8>>>,
}
impl ScriptedConn {
fn with_script(bytes: &[u8]) -> Self {
Self {
to_read: Arc::new(Mutex::new(bytes.iter().copied().collect())),
written: Arc::new(Mutex::new(Vec::new())),
}
}
fn written(&self) -> Vec<u8> {
self.written.lock().unwrap().clone()
}
fn written_str(&self) -> String {
String::from_utf8_lossy(&self.written()).into_owned()
}
}
impl Read for ScriptedConn {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let mut q = self.to_read.lock().unwrap();
let n = buf.len().min(q.len());
for slot in buf.iter_mut().take(n) {
*slot = q.pop_front().unwrap();
}
Ok(n) }
}
impl Write for ScriptedConn {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.written.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Conn for ScriptedConn {
fn dup(&self) -> io::Result<Box<dyn Conn>> {
Ok(Box::new(self.clone()))
}
fn shutdown(&self, _how: Shutdown) -> io::Result<()> {
Ok(())
}
fn set_timeouts(&self, _dur: Duration) -> io::Result<()> {
Ok(())
}
}
struct FakeConnector(ScriptedConn);
impl Connector for FakeConnector {
fn connect(&self, _addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
Ok(Box::new(self.0.clone()))
}
}
struct FailingConnector;
impl Connector for FailingConnector {
fn connect(&self, _addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
Err(io::Error::new(
io::ErrorKind::ConnectionRefused,
"origin unreachable",
))
}
}
struct Driven {
client: ScriptedConn,
origin: ScriptedConn,
refused: Vec<String>,
audit: Vec<NetAuditEvent>,
}
fn drive_with(request: &[u8], allow: &[&str], connector: &dyn Connector) -> Driven {
let client = ScriptedConn::with_script(request);
let policy = HostPolicy::new(allow.iter().map(|s| s.to_string()));
let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
let sink = CapturingSink::default();
let refused = Mutex::new(HashSet::new());
let _ = handle_conn(
Box::new(client.clone()),
&policy,
connector,
&resolver,
&sink,
&refused,
);
let mut refused: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
refused.sort();
Driven {
client,
origin: ScriptedConn::default(),
refused,
audit: sink.events(),
}
}
fn drive_forward(request: &[u8], allow: &[&str], origin_script: &[u8]) -> Driven {
let client = ScriptedConn::with_script(request);
let origin = ScriptedConn::with_script(origin_script);
let policy = HostPolicy::new(allow.iter().map(|s| s.to_string()));
let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
let sink = CapturingSink::default();
let refused = Mutex::new(HashSet::new());
let connector = FakeConnector(origin.clone());
let _ = handle_conn(
Box::new(client.clone()),
&policy,
&connector,
&resolver,
&sink,
&refused,
);
let mut refused: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
refused.sort();
Driven {
client,
origin,
refused,
audit: sink.events(),
}
}
#[test]
fn allowed_http_host_is_forwarded_to_origin() {
let out = drive_forward(
b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\nConnection: close\r\n\r\n",
&["allowed.test"],
b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
);
let client_saw = out.client.written_str();
assert!(client_saw.contains("200"), "client saw: {client_saw}");
assert!(
client_saw.contains("origin"),
"the origin's body must reach the client: {client_saw}"
);
assert!(
out.origin.written_str().starts_with("GET /x HTTP/1.1"),
"origin must receive the origin-form request: {}",
out.origin.written_str()
);
assert!(out.refused.is_empty(), "an allowed host is not refused");
}
#[test]
fn disallowed_http_host_is_refused_403_without_reaching_origin() {
let out = drive_with(
b"GET http://evil.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
&["allowed.test"],
&FailingConnector,
);
let client_saw = out.client.written_str();
assert!(
client_saw.contains("403"),
"denied host must get 403: {client_saw}"
);
assert!(
!client_saw.contains("502"),
"the origin must not be dialled for a denied host: {client_saw}"
);
assert!(out.origin.written().is_empty(), "origin must see nothing");
}
#[test]
fn unreachable_allowed_origin_yields_502() {
let out = drive_with(
b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
&["allowed.test"],
&FailingConnector,
);
assert!(
out.client.written_str().contains("502"),
"an unreachable allowed origin must get 502: {}",
out.client.written_str()
);
let ev = out.audit.iter().find(|e| e.host == "allowed.test").unwrap();
assert_eq!(ev.decision, NetDecision::Error);
}
#[test]
fn malformed_request_yields_400_and_no_audit() {
let out = drive_with(
b"GET / HTTP/1.1\r\n\r\n",
&["allowed.test"],
&FailingConnector,
);
assert!(out.client.written_str().contains("400"));
assert!(
out.audit.is_empty(),
"a malformed request is not an egress event: {:?}",
out.audit
);
assert!(out.refused.is_empty());
}
#[test]
fn refused_hosts_surfaces_denied_hosts_deduped_and_omits_allowed() {
let policy = HostPolicy::new(["allowed.test".to_string()]);
let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
let sink = NullSink;
let refused = Mutex::new(HashSet::new());
let origin = ScriptedConn::with_script(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
let connector = FakeConnector(origin);
for (req, conn) in [
(
&b"GET http://allowed.test/x HTTP/1.1\r\nHost: a\r\n\r\n"[..],
&connector as &dyn Connector,
),
(
&b"GET http://evil.test/y HTTP/1.1\r\nHost: a\r\n\r\n"[..],
&FailingConnector,
),
(
&b"GET http://evil.test/z HTTP/1.1\r\nHost: a\r\n\r\n"[..],
&FailingConnector,
),
] {
let client = ScriptedConn::with_script(req);
let _ = handle_conn(Box::new(client), &policy, conn, &resolver, &sink, &refused);
}
let mut got: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
got.sort();
assert_eq!(
got,
vec!["evil.test".to_string()],
"only the denied host, deduped; the allowed host must NOT appear: {got:?}"
);
}
#[test]
fn audit_records_allowed_with_bytes_and_denied_attempts() {
let allowed = drive_forward(
b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
&["allowed.test"],
b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
);
let ev = allowed
.audit
.iter()
.find(|e| e.host == "allowed.test")
.expect("an allowed event");
assert_eq!(ev.decision, NetDecision::Allowed);
assert_eq!(ev.kind, NetKind::Http);
assert_eq!(ev.port, 80);
assert!(
ev.bytes_down > 0,
"an allowed connection records response bytes: {ev:?}"
);
let denied = drive_with(
b"GET http://evil.test/y HTTP/1.1\r\nHost: ignored\r\n\r\n",
&["allowed.test"],
&FailingConnector,
);
let ev = denied
.audit
.iter()
.find(|e| e.host == "evil.test")
.expect("a denied event");
assert_eq!(ev.decision, NetDecision::Denied);
assert_eq!(ev.bytes_up, 0);
assert_eq!(ev.bytes_down, 0);
}
#[test]
fn jsonl_sink_appends_one_newline_terminated_json_line_per_event() {
#[derive(Clone, Default)]
struct SharedBuf(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuf {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let buf = SharedBuf::default();
let sink = JsonlSink::new(buf.clone());
let mk = |host: &str| NetAuditEvent {
ts_ms: 1,
host: host.into(),
port: 80,
kind: NetKind::Http,
decision: NetDecision::Allowed,
bytes_up: 1,
bytes_down: 2,
dur_ms: 3,
};
sink.record(&mk("a"));
sink.record(&mk("b"));
let text = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "one JSON line per event: {text:?}");
assert_eq!(
serde_json::from_str::<NetAuditEvent>(lines[0])
.unwrap()
.host,
"a"
);
assert_eq!(
serde_json::from_str::<NetAuditEvent>(lines[1])
.unwrap()
.host,
"b"
);
}
#[test]
fn audit_event_json_round_trips() {
let e = NetAuditEvent {
ts_ms: 1,
host: "h".into(),
port: 443,
kind: NetKind::Connect,
decision: NetDecision::Allowed,
bytes_up: 10,
bytes_down: 20,
dur_ms: 5,
};
let line = serde_json::to_string(&e).unwrap();
assert!(line.contains("\"decision\":\"allowed\"") && line.contains("\"kind\":\"connect\""));
assert_eq!(serde_json::from_str::<NetAuditEvent>(&line).unwrap(), e);
}
#[test]
fn connect_allowed_host_tunnels_opaque_bytes() {
let out = drive_forward(
b"CONNECT allowed.test:443 HTTP/1.1\r\nHost: allowed.test:443\r\n\r\nPING",
&["allowed.test"],
b"PING",
);
let client_saw = out.client.written_str();
assert!(
client_saw.starts_with("HTTP/1.1 200"),
"CONNECT must be accepted with a bare 200: {client_saw:?}"
);
assert!(
client_saw.contains("PING"),
"the origin's bytes must tunnel back to the client: {client_saw:?}"
);
assert_eq!(
out.origin.written(),
b"PING",
"the client's pipelined bytes must reach the origin through the tunnel"
);
}
#[test]
fn connect_disallowed_host_is_refused_403() {
let out = drive_with(
b"CONNECT evil.test:443 HTTP/1.1\r\n\r\n",
&["allowed.test"],
&FailingConnector,
);
assert!(
out.client.written_str().contains("403"),
"a denied CONNECT must get 403, not a tunnel: {}",
out.client.written_str()
);
}
#[test]
fn http_host_header_is_normalized_to_the_validated_authority() {
let out = drive_forward(
b"GET http://allowed.test/ HTTP/1.1\r\nHost: evil.test\r\nConnection: close\r\n\r\n",
&["allowed.test"],
b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
);
let origin_saw = out.origin.written_str();
assert!(
origin_saw.contains("Host: allowed.test\r\n"),
"origin must receive the validated Host: {origin_saw:?}"
);
assert!(
!origin_saw.contains("evil.test"),
"the spoofed Host must not reach the origin: {origin_saw:?}"
);
}
#[test]
fn proxy_env_points_at_the_bound_loopback_addr() {
let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
let env = proxy.proxy_env();
let url = format!("http://127.0.0.1:{}", proxy.addr().port());
assert!(env.iter().any(|(k, v)| k == "https_proxy" && *v == url));
assert!(env.iter().any(|(k, v)| k == "HTTPS_PROXY" && *v == url));
assert!(env.iter().all(|(_, v)| v.starts_with("http://127.0.0.1:")));
}
#[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
fn spawn_origin() -> SocketAddr {
let l = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = l.local_addr().unwrap();
thread::spawn(move || {
for s in l.incoming().flatten() {
let mut s = s;
let mut b = [0u8; 512];
let _ = s.read(&mut b);
let _ = s.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
);
}
});
addr
}
#[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
#[test]
fn fenced_child_reaches_allowed_via_proxy_denied_refused_direct_kernel_blocked() {
use agent_bridle_core::{
best_available_sandbox, loopback_fenced_caveats, seatbelt_is_supported, Caveats,
SandboxPolicy, Scope,
};
if !seatbelt_is_supported() {
eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
return;
}
let curl = "/usr/bin/curl";
if !std::path::Path::new(curl).exists() {
eprintln!("skipping: no curl(1)");
return;
}
let origin = spawn_origin();
let proxy = start_null(
["allowed.test".to_string()],
Arc::new(FixedResolver(origin)),
)
.unwrap();
let granted = Caveats {
net: Scope::only(["allowed.test".to_string()]),
..Caveats::top()
};
let prefix = best_available_sandbox(&Arc::new(SandboxPolicy::default()))
.command_prefix(&loopback_fenced_caveats(&granted))
.expect("seatbelt wrapper");
let run = |proxy_env: bool, url: &str| -> std::process::Output {
let mut cmd = std::process::Command::new(&prefix[0]);
cmd.args(&prefix[1..])
.arg(curl)
.args(["-sv", "--max-time", "5", url])
.env_clear();
if proxy_env {
cmd.envs(proxy.proxy_env());
}
cmd.output().expect("spawn sandbox-exec")
};
let allow = run(true, "http://allowed.test/");
assert!(
String::from_utf8_lossy(&allow.stdout).contains("origin"),
"allow-listed host must reach the origin through the proxy: {allow:?}"
);
let deny = run(true, "http://denied.test/");
assert!(
String::from_utf8_lossy(&deny.stdout).contains("403"),
"denied host must get the proxy's 403: {deny:?}"
);
let direct = run(false, "http://1.1.1.1/");
let stderr = String::from_utf8_lossy(&direct.stderr);
assert_eq!(
direct.status.code(),
Some(7),
"direct off-box egress must be kernel-denied (curl exit 7): {stderr}"
);
assert!(
stderr.contains("Operation not permitted"),
"the block must be a kernel EPERM, not a routing failure: {stderr}"
);
drop(proxy);
}
#[test]
fn dropping_the_handle_stops_the_listener() {
let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
let addr = proxy.addr();
drop(proxy);
thread::sleep(Duration::from_millis(100));
if let Ok(mut c) = TcpStream::connect_timeout(&addr, Duration::from_millis(200)) {
c.set_read_timeout(Some(Duration::from_millis(500)))
.unwrap();
let _ = write!(c, "GET http://x/ HTTP/1.1\r\n\r\n");
let mut resp = Vec::new();
let _ = c.read_to_end(&mut resp);
assert!(resp.is_empty(), "a stopped proxy must not serve requests");
}
}
}