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"))
}
}
#[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 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 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(
client,
&policy,
resolver.as_ref(),
sink.as_ref(),
&refused,
);
});
}
})?
};
Ok(ProxyHandle {
addr,
shutdown,
accept: Some(accept),
refused,
})
}
fn handle_conn(
client: TcpStream,
policy: &HostPolicy,
resolver: &dyn Resolver,
sink: &dyn AuditSink,
refused: &Mutex<HashSet<String>>,
) -> io::Result<()> {
client.set_read_timeout(Some(CONN_TIMEOUT))?;
client.set_write_timeout(Some(CONN_TIMEOUT))?;
let mut reader = BufReader::new(client.try_clone()?);
let t0 = Instant::now();
let line = read_line_bounded(&mut reader)?;
let Some(target) = parse_request_line(&line) else {
return respond(&client, 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, 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(dial)
{
Ok(o) => o,
Err(_) => {
audit(NetDecision::Error, 0, 0);
return respond(&client, 502, "Bad Gateway");
}
};
(&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(dial)
{
Ok(o) => o,
Err(_) => {
audit(NetDecision::Error, 0, 0);
return respond(&client, 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 dial(addr: SocketAddr) -> io::Result<TcpStream> {
let s = TcpStream::connect_timeout(&addr, CONN_TIMEOUT)?;
s.set_read_timeout(Some(CONN_TIMEOUT))?;
s.set_write_timeout(Some(CONN_TIMEOUT))?;
Ok(s)
}
fn read_line_bounded(reader: &mut BufReader<TcpStream>) -> 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(reader: &mut BufReader<TcpStream>) -> 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(reader: &mut BufReader<TcpStream>) -> 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: &TcpStream, code: u16, reason: &str) -> io::Result<()> {
let mut c = client.try_clone()?;
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<TcpStream>,
client: TcpStream,
origin: TcpStream,
) -> io::Result<(u64, u64)> {
let mut o_write = origin.try_clone()?;
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::*;
#[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()
}
}
fn net_test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
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
}
fn http_get_via_proxy(proxy: SocketAddr, url: &str) -> (String, Vec<u8>) {
let mut c = TcpStream::connect(proxy).unwrap();
c.set_read_timeout(Some(Duration::from_secs(30))).unwrap();
write!(
c,
"GET {url} HTTP/1.1\r\nHost: ignored\r\nConnection: close\r\n\r\n"
)
.unwrap();
let mut resp = Vec::new();
let _ = c.read_to_end(&mut resp);
let status = String::from_utf8_lossy(&resp)
.lines()
.next()
.unwrap_or_default()
.to_string();
(status, resp)
}
#[test]
fn allowed_http_host_is_forwarded_to_origin() {
let _serial = net_test_lock();
let origin = spawn_origin();
let proxy = start_null(
["allowed.test".to_string()],
Arc::new(FixedResolver(origin)),
)
.unwrap();
let (status, body) = http_get_via_proxy(proxy.addr(), "http://allowed.test/x");
assert!(status.contains("200"), "status={status}");
assert!(
String::from_utf8_lossy(&body).contains("origin"),
"the origin's body must reach the client: {status}"
);
}
#[test]
fn disallowed_http_host_is_refused_403_without_reaching_origin() {
let _serial = net_test_lock();
let proxy = start_null(
["allowed.test".to_string()],
Arc::new(FixedResolver("127.0.0.1:1".parse().unwrap())),
)
.unwrap();
let (status, _) = http_get_via_proxy(proxy.addr(), "http://evil.test/x");
assert!(status.contains("403"), "denied host must get 403: {status}");
}
#[test]
fn refused_hosts_surfaces_denied_hosts_deduped_and_omits_allowed() {
let _serial = net_test_lock();
let origin = spawn_origin();
let proxy = start_null(
["allowed.test".to_string()],
Arc::new(FixedResolver(origin)),
)
.unwrap();
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();
for _ in 0..200 {
refused = proxy.refused_hosts();
if !refused.is_empty() {
break;
}
thread::sleep(Duration::from_millis(10));
}
assert_eq!(
refused,
vec!["evil.test".to_string()],
"only the denied host, deduped; allowed host must NOT appear: {refused:?}"
);
}
#[test]
fn audit_records_allowed_with_bytes_and_denied_attempts() {
let _serial = net_test_lock();
let origin = spawn_origin();
let sink = CapturingSink::default();
let proxy = start(
["allowed.test".to_string()],
Arc::new(FixedResolver(origin)),
Arc::new(sink.clone()),
)
.unwrap();
let _ = http_get_via_proxy(proxy.addr(), "http://allowed.test/x");
let _ = http_get_via_proxy(proxy.addr(), "http://evil.test/y");
let deadline = Instant::now() + Duration::from_secs(30);
let events = loop {
let ev = sink.events();
let have = |h: &str| ev.iter().any(|e| e.host == h);
if have("allowed.test") && have("evil.test") {
break ev;
}
if Instant::now() >= deadline {
eprintln!(
"skipping audit assertions: records did not land within the deadline \
(constrained CI runner); got {} event(s)",
ev.len()
);
return;
}
thread::sleep(Duration::from_millis(20));
};
let allowed = events
.iter()
.find(|e| e.host == "allowed.test")
.expect("an allowed event");
assert_eq!(allowed.decision, NetDecision::Allowed);
assert_eq!(allowed.kind, NetKind::Http);
assert_eq!(allowed.port, 80);
assert!(
allowed.bytes_down > 0,
"an allowed connection records response bytes: {allowed:?}"
);
let denied = events
.iter()
.find(|e| e.host == "evil.test")
.expect("a denied event (the exfil-attempt signal)");
assert_eq!(denied.decision, NetDecision::Denied);
assert_eq!(denied.bytes_up, 0);
assert_eq!(denied.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);
}
fn spawn_echo() -> 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; 64];
if let Ok(n) = s.read(&mut b) {
let _ = s.write_all(&b[..n]);
}
}
});
addr
}
#[test]
fn connect_allowed_host_tunnels_opaque_bytes() {
let _serial = net_test_lock();
let echo = spawn_echo();
let proxy =
start_null(["allowed.test".to_string()], Arc::new(FixedResolver(echo))).unwrap();
let mut c = TcpStream::connect(proxy.addr()).unwrap();
c.set_read_timeout(Some(Duration::from_secs(30))).unwrap();
write!(
c,
"CONNECT allowed.test:443 HTTP/1.1\r\nHost: allowed.test:443\r\n\r\n"
)
.unwrap();
let mut status = BufReader::new(c.try_clone().unwrap());
let mut head = String::new();
loop {
let mut l = String::new();
status.read_line(&mut l).unwrap();
if l == "\r\n" || l.is_empty() {
break;
}
head.push_str(&l);
}
assert!(
head.starts_with("HTTP/1.1 200"),
"CONNECT must be accepted with a bare 200: {head:?}"
);
c.write_all(b"PING").unwrap();
let mut back = [0u8; 4];
status.read_exact(&mut back).unwrap();
assert_eq!(
&back, b"PING",
"the tunnel must forward raw bytes both ways"
);
}
#[test]
fn connect_disallowed_host_is_refused_403() {
let _serial = net_test_lock();
let proxy = start_null(
["allowed.test".to_string()],
Arc::new(FixedResolver("127.0.0.1:1".parse().unwrap())),
)
.unwrap();
let mut c = TcpStream::connect(proxy.addr()).unwrap();
c.set_read_timeout(Some(Duration::from_secs(30))).unwrap();
write!(c, "CONNECT evil.test:443 HTTP/1.1\r\n\r\n").unwrap();
let mut resp = Vec::new();
let _ = c.read_to_end(&mut resp);
assert!(
String::from_utf8_lossy(&resp).contains("403"),
"a denied CONNECT must get 403, not a tunnel: {:?}",
String::from_utf8_lossy(&resp)
);
}
fn spawn_host_echo() -> 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 reader = BufReader::new(s.try_clone().unwrap());
let mut host = String::new();
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
break;
}
if let Some(v) = line.get(..5).filter(|p| p.eq_ignore_ascii_case("host:")) {
let _ = v;
host = line[5..].trim().to_string();
}
}
let body = format!("host={host}");
let _ = write!(
s,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
}
});
addr
}
#[test]
fn http_host_header_is_normalized_to_the_validated_authority() {
let _serial = net_test_lock();
let origin = spawn_host_echo();
let proxy = start_null(
["allowed.test".to_string()],
Arc::new(FixedResolver(origin)),
)
.unwrap();
let mut c = TcpStream::connect(proxy.addr()).unwrap();
c.set_read_timeout(Some(Duration::from_secs(30))).unwrap();
write!(
c,
"GET http://allowed.test/ HTTP/1.1\r\nHost: evil.test\r\nConnection: close\r\n\r\n"
)
.unwrap();
let mut resp = Vec::new();
let _ = c.read_to_end(&mut resp);
let resp = String::from_utf8_lossy(&resp);
assert!(
resp.contains("host=allowed.test"),
"origin must receive the validated Host: {resp}"
);
assert!(
!resp.contains("evil.test"),
"the spoofed Host must not reach the origin: {resp}"
);
}
#[test]
fn proxy_env_points_at_the_bound_loopback_addr() {
let _serial = net_test_lock();
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"))]
#[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 _serial = net_test_lock();
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");
}
}
}