Skip to main content

agent_bridle_core/
net_proxy.rs

1//! Loopback egress proxy for the macOS `net` host allow-list (#124, ADR 0016).
2//!
3//! SBPL cannot name a remote host (ADR 0015), so a general `net: Only([host, …])`
4//! grant cannot be kernel-confined by hostname. The honest mechanism: kernel-fence
5//! a spawned child's egress to the **loopback interface** (the ADR 0015 rule, via
6//! [`crate::loopback_fenced_caveats`]), then run **this** loopback
7//! forward proxy — which the child is pointed at through `*_PROXY` env — to enforce
8//! the hostname allow-list. The child can reach *nothing* off-box except through
9//! the proxy, and the proxy admits only allow-listed hosts.
10//!
11//! **Grade (honesty):** the loopback fence is kernel-grade and unbypassable; the
12//! hostname match is **userspace** (this parent-side process). So the `net` axis
13//! stays reported `Advisory` — the proxy *over-delivers* above that floor (the
14//! `report.rs` doctrine), it does not raise the honest kernel claim.
15//!
16//! **Scope:** HTTP `CONNECT` (HTTPS tunnelling — the proxy never terminates TLS)
17//! and `http://` absolute-form forwarding. Non-proxy-aware traffic (raw sockets,
18//! tools ignoring `*_PROXY`) is kernel-fenced to loopback and therefore blocked
19//! off-box — fail-closed, the safe direction.
20//!
21//! Std-only (`std::net` + `std::thread`); no async runtime, no new dependency —
22//! so [`ProxyHandle`] is a plain RAII value whose `Drop` tears the listener down.
23
24use 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
34/// Longest request line / header block the proxy will buffer before giving up
35/// (a request line is short; this only bounds a hostile client). 8 KiB.
36const MAX_HEAD: usize = 8 * 1024;
37/// Per-connection socket timeout, so a stuck peer cannot pin a proxy thread.
38const CONN_TIMEOUT: Duration = Duration::from_secs(30);
39
40// ── Audit (#124, ADR 0016): the proxy is the child's sole egress chokepoint, so
41// every proxy-visible connection is recorded through an operator-supplied sink.
42// This is observability only — it never changes an enforcement decision. ────────
43
44/// The kind of egress a child requested through the proxy.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum NetKind {
48    /// `CONNECT host:port` — an opaque (HTTPS) tunnel.
49    Connect,
50    /// `http://…` plaintext forward.
51    Http,
52}
53
54/// The proxy's allow-list decision for one connection.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum NetDecision {
58    /// Host on the allow-list — connection made.
59    Allowed,
60    /// Host **not** on the allow-list — refused with 403 (the exfil-attempt signal).
61    Denied,
62    /// Allow-listed but the origin could not be reached (DNS/connect failure).
63    Error,
64}
65
66/// One audited egress connection through the proxy — a complete record of the
67/// child's proxy-visible network activity (#124, ADR 0016). Serialised as one
68/// JSON line by [`JsonlSink`]; the `bridle-netmon` binary renders a live view.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct NetAuditEvent {
71    /// Unix-epoch milliseconds when the connection was decided.
72    pub ts_ms: u64,
73    /// The requested host (CONNECT authority or `http://` URI host).
74    pub host: String,
75    /// The requested port.
76    pub port: u16,
77    /// Tunnel (`connect`) or plaintext forward (`http`).
78    pub kind: NetKind,
79    /// Allow-list outcome.
80    pub decision: NetDecision,
81    /// Bytes the child sent (client → origin); `0` for a denied/errored conn.
82    pub bytes_up: u64,
83    /// Bytes the origin returned (origin → child).
84    pub bytes_down: u64,
85    /// Connection lifetime in milliseconds.
86    pub dur_ms: u64,
87}
88
89/// A destination for [`NetAuditEvent`]s — the operator's audit trail. `record` is
90/// called from a per-connection thread, so implementations must be thread-safe
91/// and must never block the connection for long.
92pub trait AuditSink: Send + Sync {
93    /// Record one completed connection.
94    fn record(&self, event: &NetAuditEvent);
95}
96
97/// The default sink — discard everything (audit off, zero overhead).
98pub struct NullSink;
99
100impl AuditSink for NullSink {
101    fn record(&self, _event: &NetAuditEvent) {}
102}
103
104/// Append each event as one JSON line to a `Write` (a file, stderr, a pipe).
105pub struct JsonlSink<W: Write + Send>(Mutex<W>);
106
107impl<W: Write + Send> JsonlSink<W> {
108    /// Wrap a writer as a JSON-lines audit sink.
109    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
124/// Unix-epoch milliseconds now (saturating to 0 before the epoch — never panics).
125fn 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
132/// Resolves a proxied hostname to the address the proxy dials. A seam so a test
133/// can map an allow-listed name to a loopback origin (hermetic, no real DNS).
134pub trait Resolver: Send + Sync {
135    /// Resolve `host:port` to a single dial target, or an error if it cannot.
136    fn resolve(&self, host: &str, port: u16) -> io::Result<SocketAddr>;
137}
138
139/// The production resolver — the platform's own `getaddrinfo`, run in the parent
140/// (never the fenced child), taking the first address.
141pub 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// ── I/O seams (#166): the per-connection logic is written against `Conn` +
153// `Connector` traits, not concrete `TcpStream`, so the parse / allow-list /
154// forward / tunnel / audit LOGIC can be unit-tested against an in-memory duplex
155// with NO real socket (deterministic, portable). Production wires the real
156// `TcpStream` / `TcpConnector`; the tests wire scripted in-memory endpoints. ───
157
158/// A bidirectional connection the proxy speaks over. Abstracts the three
159/// `TcpStream`-specific operations the forward/tunnel path needs beyond
160/// `Read`/`Write` — a second owned handle ([`Self::dup`], for the read/write
161/// split and the origin clone), directional [`Self::shutdown`] (how the copy
162/// threads signal EOF), and the per-connection timeouts ([`Self::set_timeouts`]).
163/// `TcpStream` implements it for production; an in-memory scripted stream
164/// implements it for tests.
165pub trait Conn: Read + Write + Send {
166    /// A second owned handle to the SAME underlying stream — for `TcpStream`
167    /// this is `try_clone` (both handles share one socket); the proxy holds one
168    /// half for reading and one for writing, and `splice` needs a third for the
169    /// origin write direction.
170    fn dup(&self) -> io::Result<Box<dyn Conn>>;
171    /// Shut down the read half, write half, or both — how a copy thread signals
172    /// EOF to its peer and how a response closes the client.
173    fn shutdown(&self, how: Shutdown) -> io::Result<()>;
174    /// Apply the per-connection read+write timeouts. A no-op for in-memory
175    /// fakes, which never block on a real socket.
176    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
193/// Dials a resolved origin, returning the connection to forward to. A seam so a
194/// test can hand the proxy a scripted in-memory origin instead of a real TCP
195/// dial — the second half (with [`Conn`]) of what makes the forward path
196/// testable without a socket.
197pub trait Connector: Send + Sync {
198    /// Connect to `addr`, or fail (surfaced to the client as `502 Bad Gateway`).
199    fn connect(&self, addr: SocketAddr) -> io::Result<Box<dyn Conn>>;
200}
201
202/// The production connector — a real bounded TCP dial to the resolved origin.
203pub 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/// What a client's first request line asked for.
215#[derive(Debug, PartialEq, Eq)]
216enum Target {
217    /// `CONNECT host:port HTTP/x` — a TLS tunnel to open.
218    Connect { host: String, port: u16 },
219    /// `METHOD http://host[:port]/path HTTP/x` — a plaintext request to forward;
220    /// `origin_line` is the same line rewritten to origin-form (`METHOD /path …`).
221    Http {
222        host: String,
223        port: u16,
224        origin_line: String,
225    },
226}
227
228/// Parse a proxy request line. Returns `None` on anything malformed or a scheme
229/// the proxy does not speak (only `CONNECT` and `http://` absolute-form).
230fn 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    // Absolute-form: METHOD http://host[:port]/path HTTP/x  (proxy requests only).
244    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
257/// Split `host:port` (or a bare host, or a bracketed IPv6 literal) into
258/// `(host, port)`, defaulting the port. Returns `None` on an unparsable port.
259fn split_host_port(authority: &str, default_port: u16) -> Option<(String, u16)> {
260    // Bracketed IPv6 literal: [::1] or [::1]:8080
261    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        // Host part still holds a ':' → an unbracketed IPv6 literal ("::1"); take
272        // the whole authority as the host at the default port (fail-safe: a weird
273        // string just misses the exact-name allow-list).
274        Some((h, _)) if h.contains(':') => Some((authority.to_string(), default_port)),
275        // ":443" — no host.
276        Some(("", _)) => None,
277        // A single ':' → host:port; a non-numeric port is malformed (reject).
278        Some((h, p)) => Some((h.to_string(), p.parse::<u16>().ok()?)),
279        // No ':' → a bare host at the default port.
280        None => Some((authority.to_string(), default_port)),
281    }
282}
283
284/// The exact-hostname allow-list, mirroring `ToolContext::check_net`'s membership.
285#[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
297/// A running loopback egress proxy. Dropping the handle shuts it down.
298#[derive(Debug)]
299pub struct ProxyHandle {
300    addr: SocketAddr,
301    shutdown: Arc<AtomicBool>,
302    accept: Option<JoinHandle<()>>,
303    /// #196: out-of-allow-list hosts the child tried to reach — refused with 403.
304    /// Accumulated across all connections (independent of the opt-in audit sink)
305    /// so the shell tool can surface them as structured `net` denials.
306    refused: Arc<Mutex<HashSet<String>>>,
307}
308
309impl ProxyHandle {
310    /// The loopback address the proxy listens on. A spawned child is wired via
311    /// [`Self::proxy_env`]; a **no-subprocess** caller (#257 — e.g. a
312    /// `reqwest::Client`) points itself here instead:
313    /// `reqwest::Proxy::all(format!("http://{}", handle.addr()))`.
314    pub fn addr(&self) -> SocketAddr {
315        self.addr
316    }
317
318    /// #196: the CONNECT hosts this proxy REFUSED (not on the allow-list),
319    /// deduplicated and sorted. The shell tool reads this after the child is
320    /// reaped and turns each into a `Denial { kind: Net, target: host }` so a
321    /// consumer can prompt per-host. Empty when the child only reached
322    /// allow-listed hosts (or none).
323    #[must_use]
324    pub fn refused_hosts(&self) -> Vec<String> {
325        self.refused
326            .lock()
327            .map(|s| {
328                let mut v: Vec<String> = s.iter().cloned().collect();
329                v.sort();
330                v
331            })
332            .unwrap_or_default()
333    }
334
335    /// The `*_PROXY` environment the child needs to route through this proxy.
336    /// Both cases are set: curl honours lowercase `http_proxy` (it ignores the
337    /// uppercase form for CGI-safety) but uppercase `HTTPS_PROXY`/`ALL_PROXY`;
338    /// other tools (wget, requests, node) read the rest.
339    #[must_use]
340    pub fn proxy_env(&self) -> Vec<(String, String)> {
341        let url = format!("http://{}", self.addr);
342        [
343            "http_proxy",
344            "https_proxy",
345            "all_proxy",
346            "HTTP_PROXY",
347            "HTTPS_PROXY",
348            "ALL_PROXY",
349        ]
350        .iter()
351        .map(|k| ((*k).to_string(), url.clone()))
352        .collect()
353    }
354}
355
356impl Drop for ProxyHandle {
357    fn drop(&mut self) {
358        self.shutdown.store(true, Ordering::SeqCst);
359        // Wake the blocking `accept()` so the loop observes the flag and exits.
360        let _ = TcpStream::connect_timeout(&self.addr, Duration::from_millis(200));
361        if let Some(h) = self.accept.take() {
362            let _ = h.join();
363        }
364    }
365}
366
367/// Start a loopback forward proxy that admits only `allow_hosts`, resolving via
368/// `resolver` and auditing every connection through `sink` ([`NullSink`] for no
369/// audit). Binds `127.0.0.1:0` (an ephemeral port — concurrent runs never
370/// collide) and serves until the returned [`ProxyHandle`] is dropped.
371///
372/// Fail-closed: an error binding the listener is returned so the caller refuses
373/// the run rather than spawning an unfenced child.
374pub fn start(
375    allow_hosts: impl IntoIterator<Item = String>,
376    resolver: Arc<dyn Resolver>,
377    sink: Arc<dyn AuditSink>,
378) -> io::Result<ProxyHandle> {
379    let listener = TcpListener::bind(("127.0.0.1", 0))?;
380    let addr = listener.local_addr()?;
381    let shutdown = Arc::new(AtomicBool::new(false));
382    let policy = HostPolicy::new(allow_hosts);
383    // The production origin dialer — a real TCP connect. Tests bypass `start`
384    // entirely and drive `handle_conn` with a scripted in-memory connector.
385    let connector: Arc<dyn Connector> = Arc::new(TcpConnector);
386    // #196: shared refused-host accumulator, populated by each connection thread.
387    let refused: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
388
389    let accept = {
390        let shutdown = Arc::clone(&shutdown);
391        let refused = Arc::clone(&refused);
392        thread::Builder::new()
393            .name("agent-bridle-egress-proxy".to_string())
394            .spawn(move || {
395                for stream in listener.incoming() {
396                    if shutdown.load(Ordering::SeqCst) {
397                        break;
398                    }
399                    let Ok(client) = stream else { continue };
400                    let policy = policy.clone();
401                    let resolver = Arc::clone(&resolver);
402                    let connector = Arc::clone(&connector);
403                    let sink = Arc::clone(&sink);
404                    let refused = Arc::clone(&refused);
405                    // One detached thread per connection; it ends at EOF.
406                    let _ = thread::Builder::new()
407                        .name("agent-bridle-egress-conn".to_string())
408                        .spawn(move || {
409                            let _ = handle_conn(
410                                Box::new(client),
411                                &policy,
412                                connector.as_ref(),
413                                resolver.as_ref(),
414                                sink.as_ref(),
415                                &refused,
416                            );
417                        });
418                }
419            })?
420    };
421
422    Ok(ProxyHandle {
423        addr,
424        shutdown,
425        accept: Some(accept),
426        refused,
427    })
428}
429
430/// Start the egress proxy for a **general remote-host** `net` grant (#257) — the
431/// caveats-level convenience over [`start`], for an external caller.
432///
433/// `Ok(None)` when `caveats` does not call for a proxy
434/// ([`crate::net_egress_proxy_hosts`] is `None`): `net: All` needs no fence,
435/// and deny-all / loopback-only stay with their kernel owners. `Ok(Some(handle))`
436/// = the proxy is live on `handle.addr()`, admitting exactly the granted hosts.
437/// `Err` = the grant calls for a proxy but the loopback listener could not bind —
438/// the caller must fail closed, never proceed unfenced.
439///
440/// Two consumption shapes:
441/// - **Spawned child**: pair with [`crate::loopback_fenced_caveats`] (the kernel
442///   fence) and wire the child via [`ProxyHandle::proxy_env`] — what
443///   `ConfinedCommand::spawn_tokio` does automatically under this grant.
444/// - **No subprocess** (an in-process `reqwest::Client`): route the client at
445///   `http://{handle.addr()}` (`reqwest::Proxy::all`). No kernel fence applies —
446///   the caller is pointing *itself* at the proxy, so the per-host allow-list is
447///   exactly as strong as the caller's routing (advisory grade, honestly).
448///
449/// Audit is off ([`NullSink`]); an embedder that wants the audit trail or a
450/// custom resolver uses [`start`] directly.
451pub fn start_egress_proxy(caveats: &crate::Caveats) -> io::Result<Option<ProxyHandle>> {
452    let Some(hosts) = crate::net_egress_proxy_hosts(caveats) else {
453        return Ok(None);
454    };
455    start_for_hosts(hosts).map(Some)
456}
457
458/// [`start`] with the production defaults (platform resolver, audit off) — the
459/// shared entry both [`start_egress_proxy`] and `ConfinedCommand::spawn_tokio`'s
460/// egress wiring (#257) call, so "start the proxy for these hosts" has exactly
461/// one production spelling.
462pub fn start_for_hosts(allow_hosts: impl IntoIterator<Item = String>) -> io::Result<ProxyHandle> {
463    start(allow_hosts, Arc::new(StdResolver), Arc::new(NullSink))
464}
465
466/// Serve one client connection: parse its request line, enforce the allow-list,
467/// and either tunnel (`CONNECT`) or forward (`http://`) to the resolved origin.
468/// Every connection with a parsed host is recorded through `sink`.
469fn handle_conn(
470    client: Box<dyn Conn>,
471    policy: &HostPolicy,
472    connector: &dyn Connector,
473    resolver: &dyn Resolver,
474    sink: &dyn AuditSink,
475    refused: &Mutex<HashSet<String>>,
476) -> io::Result<()> {
477    client.set_timeouts(CONN_TIMEOUT)?;
478    let mut reader = BufReader::new(client.dup()?);
479    let t0 = Instant::now();
480
481    let line = read_line_bounded(&mut reader)?;
482    let Some(target) = parse_request_line(&line) else {
483        // No host to attribute — a malformed request is not an egress event.
484        return respond(client.as_ref(), 400, "Bad Request");
485    };
486
487    let (host, port, kind) = match &target {
488        Target::Connect { host, port } => (host.clone(), *port, NetKind::Connect),
489        Target::Http { host, port, .. } => (host.clone(), *port, NetKind::Http),
490    };
491    // Emit the audit record once, whatever the outcome.
492    let audit = |decision: NetDecision, up: u64, down: u64| {
493        sink.record(&NetAuditEvent {
494            ts_ms: now_ms(),
495            host: host.clone(),
496            port,
497            kind,
498            decision,
499            bytes_up: up,
500            bytes_down: down,
501            dur_ms: t0.elapsed().as_millis() as u64,
502        });
503    };
504
505    if !policy.allows(&host) {
506        audit(NetDecision::Denied, 0, 0); // the exfil-attempt signal
507                                          // #196: record the refused host so the shell tool can surface it as a
508                                          // structured `net` denial (the audit sink is opt-in; this is always on).
509        if let Ok(mut set) = refused.lock() {
510            set.insert(host.clone());
511        }
512        return respond(client.as_ref(), 403, "Forbidden");
513    }
514
515    match target {
516        Target::Connect { host, port } => {
517            // CONNECT: drain the remaining request headers (up to the blank line)
518            // before the tunnel begins — the client waits for our 200 first.
519            drain_headers(&mut reader)?;
520            let origin = match resolver
521                .resolve(&host, port)
522                .and_then(guard_target)
523                .and_then(|addr| connector.connect(addr))
524            {
525                Ok(o) => o,
526                Err(_) => {
527                    audit(NetDecision::Error, 0, 0);
528                    return respond(client.as_ref(), 502, "Bad Gateway");
529                }
530            };
531            // A CONNECT success is a *bare* status line — no body, no
532            // `Content-Length` — after which the socket is an opaque tunnel. (Do
533            // NOT use `respond`, which appends a body that would corrupt it.)
534            let mut client = client;
535            client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?;
536            // Forward via `splice_buffered` (not a raw `tunnel`) so any bytes the
537            // client already pipelined past the CONNECT header block — buffered in
538            // `reader` — reach the origin (a TLS ClientHello sent with the CONNECT;
539            // #138). The up-copy reads the BufReader, which drains its buffer first.
540            let (up, down) = splice_buffered(reader, client, origin)?;
541            audit(NetDecision::Allowed, up, down);
542            Ok(())
543        }
544        Target::Http {
545            host,
546            port,
547            origin_line,
548        } => {
549            // Read the client's request headers and **drop any client-supplied
550            // Host** — an attacker could send `Host: disallowed.com` to domain-front
551            // off a shared/CDN origin at the allow-listed IP. Substitute the proxy's
552            // own Host, derived from the *validated* authority, so the origin only
553            // ever sees the host the allow-list approved.
554            let headers = read_headers(&mut reader)?;
555            let mut origin = match resolver
556                .resolve(&host, port)
557                .and_then(guard_target)
558                .and_then(|addr| connector.connect(addr))
559            {
560                Ok(o) => o,
561                Err(_) => {
562                    audit(NetDecision::Error, 0, 0);
563                    return respond(client.as_ref(), 502, "Bad Gateway");
564                }
565            };
566            let host_hdr = if port == 80 {
567                format!("Host: {host}\r\n")
568            } else {
569                format!("Host: {host}:{port}\r\n")
570            };
571            origin.write_all(origin_line.as_bytes())?;
572            origin.write_all(host_hdr.as_bytes())?;
573            for h in &headers {
574                if !h.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("host:")) {
575                    origin.write_all(h.as_bytes())?;
576                }
577            }
578            origin.write_all(b"\r\n")?; // end of the (rewritten) header block
579            let (up, down) = splice_buffered(reader, client, origin)?; // forward the body
580            audit(NetDecision::Allowed, up, down);
581            Ok(())
582        }
583    }
584}
585
586/// Read one CRLF-terminated line, bounded to [`MAX_HEAD`]. Generic over the
587/// buffered reader's source so the same logic drives a real socket or an
588/// in-memory test stream.
589fn read_line_bounded<R: Read>(reader: &mut BufReader<R>) -> io::Result<String> {
590    let mut buf = Vec::new();
591    reader.take(MAX_HEAD as u64).read_until(b'\n', &mut buf)?;
592    Ok(String::from_utf8_lossy(&buf).into_owned())
593}
594
595/// Read request header lines up to (not including) the terminating blank line,
596/// bounded by [`MAX_HEAD`]. Each returned line keeps its trailing CRLF.
597fn read_headers<R: Read>(reader: &mut BufReader<R>) -> io::Result<Vec<String>> {
598    let mut lines = Vec::new();
599    let mut total = 0usize;
600    loop {
601        let line = read_line_bounded(reader)?;
602        total += line.len();
603        if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
604            return Ok(lines);
605        }
606        lines.push(line);
607    }
608}
609
610/// Consume request headers up to and including the terminating blank line.
611fn drain_headers<R: Read>(reader: &mut BufReader<R>) -> io::Result<()> {
612    let mut total = 0usize;
613    loop {
614        let line = read_line_bounded(reader)?;
615        total += line.len();
616        if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
617            return Ok(());
618        }
619    }
620}
621
622/// Write a minimal HTTP/1.1 status response and close.
623fn respond(client: &dyn Conn, code: u16, reason: &str) -> io::Result<()> {
624    let mut c = client.dup()?;
625    let body = format!("{code} {reason}\n");
626    write!(
627        c,
628        "HTTP/1.1 {code} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
629        body.len()
630    )?;
631    let _ = c.flush();
632    let _ = client.shutdown(Shutdown::Both);
633    Ok(())
634}
635
636/// Copy `from` → `to`, returning the bytes **forwarded** — preserved even if a
637/// stream errors mid-copy (a reset after data still counts what flowed), so the
638/// audit totals are not silently zeroed by an abrupt close (`std::io::copy`
639/// discards its count on error). Stops at EOF, a write failure, or a read error.
640fn copy_counted(from: &mut impl Read, to: &mut impl Write) -> u64 {
641    let mut buf = [0u8; 16 * 1024];
642    let mut total = 0u64;
643    loop {
644        match from.read(&mut buf) {
645            Ok(0) => break,
646            Ok(n) => {
647                if to.write_all(&buf[..n]).is_err() {
648                    break;
649                }
650                total += n as u64;
651            }
652            Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
653            Err(_) => break,
654        }
655    }
656    total
657}
658
659/// Bidirectional raw byte tunnel between `client` and `origin` (the `CONNECT`
660/// case): two copy threads, each shutting its write half at EOF. Returns
661/// `(bytes_up, bytes_down)` — child→origin and origin→child — for the audit.
662/// SSRF-pivot guard (#138): refuse to dial a resolved origin whose IP is on an
663/// **internal** range — RFC1918 private, `169.254/16` link-local (incl. the cloud
664/// metadata endpoint `169.254.169.254`), `100.64/10` CGNAT, and IPv6 ULA/link-local.
665/// The egress proxy fronts a *loopback-fenced* child; without this an allow-listed
666/// name that resolves (or is rebound) to an internal address would give that child
667/// a parent-mediated path to endpoints its own kernel fence forbids. Loopback is
668/// **allowed** (the fenced child can already reach loopback directly, and the test
669/// origins live there) and global addresses are allowed.
670///
671/// Default-on and unconditional today; a `NetPolicy` opt-out is future work (I13,
672/// #152). Returns the address unchanged when permitted, else `PermissionDenied`.
673fn guard_target(addr: SocketAddr) -> io::Result<SocketAddr> {
674    if is_internal_ip(&addr.ip()) {
675        return Err(io::Error::new(
676            io::ErrorKind::PermissionDenied,
677            "SSRF-guard: refusing to proxy to an internal (non-loopback) address",
678        ));
679    }
680    Ok(addr)
681}
682
683/// `true` if `ip` is on a private/internal range the egress proxy must not pivot
684/// to (see [`guard_target`]). Loopback and global addresses return `false`.
685fn is_internal_ip(ip: &std::net::IpAddr) -> bool {
686    match ip {
687        std::net::IpAddr::V4(v4) => {
688            let o = v4.octets();
689            v4.is_private()
690                || v4.is_link_local()
691                || v4.is_unspecified()
692                || v4.is_broadcast()
693                // 100.64.0.0/10 (CGNAT) — not covered by the std predicates.
694                || (o[0] == 100 && (64..=127).contains(&o[1]))
695        }
696        std::net::IpAddr::V6(v6) => {
697            let seg0 = v6.segments()[0];
698            v6.is_unspecified()
699                || (seg0 & 0xfe00) == 0xfc00 // fc00::/7 unique-local
700                || (seg0 & 0xffc0) == 0xfe80 // fe80::/10 link-local
701        }
702    }
703}
704
705/// Like [`tunnel`] but the client side is a `BufReader` that may already hold
706/// buffered bytes (the `http://` forward case, after the request line was read).
707/// Returns `(bytes_up, bytes_down)` for the audit.
708fn splice_buffered(
709    mut client_reader: BufReader<Box<dyn Conn>>,
710    client: Box<dyn Conn>,
711    origin: Box<dyn Conn>,
712) -> io::Result<(u64, u64)> {
713    let mut o_write = origin.dup()?;
714    let up = thread::spawn(move || {
715        let n = copy_counted(&mut client_reader, &mut o_write);
716        let _ = o_write.shutdown(Shutdown::Write);
717        n
718    });
719    let mut o_read = origin;
720    let mut c_write = client;
721    let down = copy_counted(&mut o_read, &mut c_write);
722    // The origin side has closed, so tear the whole connection down: shut down
723    // BOTH client halves, not just write. `Write` alone leaves the `up` thread
724    // blocked reading the client's (half-open) upload direction until CONN_TIMEOUT
725    // — a plain client that sends its request then only reads the response never
726    // closes its write half until it sees our EOF, and we never see its EOF: a
727    // ~30s deadlock on every forward/tunnel (surfaced by the flaky `audit_records_*`
728    // test, since the audit fires only once `up` joins). `Both` gives `up`'s read
729    // on the shared socket an immediate EOF.
730    let _ = c_write.shutdown(Shutdown::Both);
731    let up = up.join().unwrap_or(0);
732    Ok((up, down))
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use std::collections::VecDeque;
739
740    // ── #257: the caveats-level public entry ────────────────────────────────
741
742    /// `start_egress_proxy` starts NOTHING for the three grants that keep their
743    /// kernel owners: `net: All` (nothing to fence), deny-all (kernel-denied),
744    /// loopback-only (the ADR 0015 fence alone suffices) — criterion 3.
745    #[test]
746    fn start_egress_proxy_is_none_for_non_proxy_grants() {
747        use crate::{Caveats, Scope};
748        let all = Caveats::top();
749        assert!(start_egress_proxy(&all).unwrap().is_none(), "net: All");
750        let deny = Caveats {
751            net: Scope::only([] as [String; 0]),
752            ..Caveats::top()
753        };
754        assert!(start_egress_proxy(&deny).unwrap().is_none(), "deny-all");
755        let loopback = Caveats {
756            net: Scope::only(["localhost".to_string()]),
757            ..Caveats::top()
758        };
759        assert!(
760            start_egress_proxy(&loopback).unwrap().is_none(),
761            "loopback-only"
762        );
763    }
764
765    /// A general remote-host grant starts a live proxy on loopback; a raw
766    /// CONNECT for a non-granted host is refused 403 and recorded in
767    /// `refused_hosts()` — the no-subprocess (reqwest-style) consumption shape,
768    /// exercised with a plain std TcpStream speaking the proxy protocol.
769    #[test]
770    fn start_egress_proxy_serves_a_remote_host_grant_and_refuses_off_list() {
771        use crate::{Caveats, Scope};
772        use std::io::{Read as _, Write as _};
773        let granted = Caveats {
774            net: Scope::only(["api.example.com".to_string()]),
775            ..Caveats::top()
776        };
777        let handle = start_egress_proxy(&granted)
778            .expect("bind loopback")
779            .expect("a remote-host grant calls for the proxy");
780        assert!(
781            handle.addr().ip().is_loopback(),
782            "proxy binds loopback only"
783        );
784
785        let mut client = TcpStream::connect(handle.addr()).expect("dial proxy");
786        client
787            .write_all(
788                b"CONNECT evil.example.net:443 HTTP/1.1\r\nHost: evil.example.net:443\r\n\r\n",
789            )
790            .expect("send CONNECT");
791        // The proxy answers 403 and closes; read to EOF so a short first read
792        // can't truncate the status line.
793        let mut reply = String::new();
794        client
795            .set_read_timeout(Some(Duration::from_secs(10)))
796            .expect("timeout");
797        let _ = client.read_to_string(&mut reply);
798        assert!(
799            reply.contains("403"),
800            "off-list CONNECT must be refused: {reply:?}"
801        );
802        assert!(
803            handle
804                .refused_hosts()
805                .contains(&"evil.example.net".to_string()),
806            "refusal must be recorded: {:?}",
807            handle.refused_hosts()
808        );
809    }
810
811    /// #138 (SSRF pivot): the proxy must refuse to dial an allow-listed name that
812    /// resolves to an internal address (RFC1918 / link-local incl. cloud metadata /
813    /// CGNAT / IPv6 ULA+link-local), while permitting loopback (the fenced child can
814    /// reach it directly + the test origins live there) and global addresses.
815    #[test]
816    fn guard_target_refuses_internal_permits_loopback_and_global() {
817        use std::net::{IpAddr, Ipv4Addr, SocketAddr};
818        // Built from octets (not dotted-string literals) so the internal-specifics
819        // linter doesn't flag the RFC1918/CGNAT probe addresses (docs/PRIVACY.md).
820        let refused: [IpAddr; 7] = [
821            Ipv4Addr::new(10, 0, 0, 5).into(),        // RFC1918
822            Ipv4Addr::new(172, 16, 9, 9).into(),      // RFC1918
823            Ipv4Addr::new(192, 168, 1, 1).into(),     // RFC1918
824            Ipv4Addr::new(169, 254, 169, 254).into(), // link-local: cloud metadata
825            Ipv4Addr::new(100, 64, 0, 1).into(),      // CGNAT
826            "fe80::1".parse().unwrap(),               // v6 link-local
827            "fc00::1".parse().unwrap(),               // v6 unique-local
828        ];
829        for ip in refused {
830            assert!(is_internal_ip(&ip), "{ip} must classify as internal");
831            assert!(
832                guard_target(SocketAddr::new(ip, 80)).is_err(),
833                "{ip} must be refused"
834            );
835        }
836        let allowed = [
837            "127.0.0.1",
838            "::1",
839            "8.8.8.8",
840            "1.1.1.1",
841            "2606:4700:4700::1111",
842        ];
843        for s in allowed {
844            let ip: IpAddr = s.parse().unwrap();
845            assert!(!is_internal_ip(&ip), "{s} must be permitted");
846            assert!(
847                guard_target(SocketAddr::new(ip, 443)).is_ok(),
848                "{s} must be permitted"
849            );
850        }
851    }
852
853    #[test]
854    fn parses_connect() {
855        assert_eq!(
856            parse_request_line("CONNECT example.com:443 HTTP/1.1\r\n"),
857            Some(Target::Connect {
858                host: "example.com".to_string(),
859                port: 443
860            })
861        );
862        // Default port when omitted.
863        assert_eq!(
864            parse_request_line("CONNECT example.com HTTP/1.1"),
865            Some(Target::Connect {
866                host: "example.com".to_string(),
867                port: 443
868            })
869        );
870    }
871
872    #[test]
873    fn parses_http_absolute_form_and_rewrites_to_origin_form() {
874        let t = parse_request_line("GET http://example.com/a/b?q=1 HTTP/1.1\r\n").unwrap();
875        assert_eq!(
876            t,
877            Target::Http {
878                host: "example.com".to_string(),
879                port: 80,
880                origin_line: "GET /a/b?q=1 HTTP/1.1\r\n".to_string(),
881            }
882        );
883        // No path → "/". Explicit port honoured.
884        let t = parse_request_line("HEAD http://h:8080 HTTP/1.0").unwrap();
885        assert_eq!(
886            t,
887            Target::Http {
888                host: "h".to_string(),
889                port: 8080,
890                origin_line: "HEAD / HTTP/1.0\r\n".to_string(),
891            }
892        );
893    }
894
895    #[test]
896    fn parses_ipv6_authority() {
897        assert_eq!(
898            parse_request_line("CONNECT [::1]:8443 HTTP/1.1"),
899            Some(Target::Connect {
900                host: "::1".to_string(),
901                port: 8443
902            })
903        );
904    }
905
906    #[test]
907    fn rejects_malformed_and_unspoken_schemes() {
908        assert!(parse_request_line("GET / HTTP/1.1").is_none()); // origin-form, not a proxy req
909        assert!(parse_request_line("GET https://x/ HTTP/1.1").is_none()); // https absolute-form
910        assert!(parse_request_line("GET ftp://x/ HTTP/1.1").is_none());
911        assert!(parse_request_line("garbage").is_none());
912        assert!(parse_request_line("CONNECT x:notaport HTTP/1.1").is_none());
913    }
914
915    /// A resolver that maps every name to a fixed loopback origin — hermetic.
916    struct FixedResolver(SocketAddr);
917    impl Resolver for FixedResolver {
918        fn resolve(&self, _host: &str, _port: u16) -> io::Result<SocketAddr> {
919            Ok(self.0)
920        }
921    }
922
923    /// Start the proxy with no audit sink (most tests don't inspect the audit).
924    fn start_null(
925        hosts: impl IntoIterator<Item = String>,
926        resolver: Arc<dyn Resolver>,
927    ) -> io::Result<ProxyHandle> {
928        start(hosts, resolver, Arc::new(NullSink))
929    }
930
931    /// An audit sink that collects every event into a shared vec, for assertions.
932    #[derive(Clone, Default)]
933    struct CapturingSink(Arc<Mutex<Vec<NetAuditEvent>>>);
934    impl AuditSink for CapturingSink {
935        fn record(&self, event: &NetAuditEvent) {
936            self.0.lock().unwrap().push(event.clone());
937        }
938    }
939    impl CapturingSink {
940        fn events(&self) -> Vec<NetAuditEvent> {
941            self.0.lock().unwrap().clone()
942        }
943    }
944
945    // ── In-memory connection fakes (#166) ───────────────────────────────────
946    //
947    // The proxy's forward/tunnel/allow-list/audit LOGIC is driven through the
948    // real `handle_conn` against these scripted endpoints — no sockets, no
949    // accept loops, no timing races. `handle_conn` joins its splice thread
950    // before returning, so every assertion below is fully synchronous and
951    // deterministic (the old socket tests polled/slept up to 30s and still
952    // flaked on constrained runners; #135/#155/#165/#166).
953
954    /// A scripted in-memory [`Conn`]: `read` drains a preset script then returns
955    /// EOF (never blocks); `write` captures bytes for assertions; `dup` shares
956    /// both (as `TcpStream::try_clone` shares one socket). `shutdown`/timeouts
957    /// are no-ops — the fake EOFs on script exhaustion, so nothing can block.
958    #[derive(Clone, Default)]
959    struct ScriptedConn {
960        /// Bytes the proxy reads FROM this endpoint (the client's request, or the
961        /// origin's canned response). Drained by `read`; empty ⇒ EOF.
962        to_read: Arc<Mutex<VecDeque<u8>>>,
963        /// Bytes the proxy WROTE to this endpoint (its client response, or the
964        /// request it forwarded to the origin). Captured for assertions.
965        written: Arc<Mutex<Vec<u8>>>,
966    }
967
968    impl ScriptedConn {
969        fn with_script(bytes: &[u8]) -> Self {
970            Self {
971                to_read: Arc::new(Mutex::new(bytes.iter().copied().collect())),
972                written: Arc::new(Mutex::new(Vec::new())),
973            }
974        }
975        /// The bytes the proxy wrote to this endpoint.
976        fn written(&self) -> Vec<u8> {
977            self.written.lock().unwrap().clone()
978        }
979        /// The bytes the proxy wrote, as a lossy string (for readable asserts).
980        fn written_str(&self) -> String {
981            String::from_utf8_lossy(&self.written()).into_owned()
982        }
983    }
984
985    impl Read for ScriptedConn {
986        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
987            let mut q = self.to_read.lock().unwrap();
988            let n = buf.len().min(q.len());
989            for slot in buf.iter_mut().take(n) {
990                *slot = q.pop_front().unwrap();
991            }
992            Ok(n) // n == 0 ⇒ script exhausted ⇒ EOF (never blocks)
993        }
994    }
995
996    impl Write for ScriptedConn {
997        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
998            self.written.lock().unwrap().extend_from_slice(buf);
999            Ok(buf.len())
1000        }
1001        fn flush(&mut self) -> io::Result<()> {
1002            Ok(())
1003        }
1004    }
1005
1006    impl Conn for ScriptedConn {
1007        fn dup(&self) -> io::Result<Box<dyn Conn>> {
1008            Ok(Box::new(self.clone()))
1009        }
1010        fn shutdown(&self, _how: Shutdown) -> io::Result<()> {
1011            Ok(())
1012        }
1013        fn set_timeouts(&self, _dur: Duration) -> io::Result<()> {
1014            Ok(())
1015        }
1016    }
1017
1018    /// A connector that hands back a scripted in-memory origin (no real dial).
1019    struct FakeConnector(ScriptedConn);
1020    impl Connector for FakeConnector {
1021        fn connect(&self, _addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
1022            Ok(Box::new(self.0.clone()))
1023        }
1024    }
1025
1026    /// A connector whose dial always fails — for the `502 Bad Gateway` path.
1027    struct FailingConnector;
1028    impl Connector for FailingConnector {
1029        fn connect(&self, _addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
1030            Err(io::Error::new(
1031                io::ErrorKind::ConnectionRefused,
1032                "origin unreachable",
1033            ))
1034        }
1035    }
1036
1037    /// The outcome of driving one connection through the real `handle_conn`.
1038    struct Driven {
1039        /// What the proxy sent back to the client (response / forwarded origin bytes).
1040        client: ScriptedConn,
1041        /// What the proxy forwarded to the origin (empty if it was never dialled).
1042        origin: ScriptedConn,
1043        /// Hosts refused with 403 (deduped, sorted).
1044        refused: Vec<String>,
1045        /// Audit events emitted (synchronously, before `handle_conn` returned).
1046        audit: Vec<NetAuditEvent>,
1047    }
1048
1049    /// Drive one client `request` through `handle_conn` with `connector` (used
1050    /// for the deny / malformed / 502 paths, where the origin is never
1051    /// successfully forwarded to — `out.origin` stays empty). Deterministic:
1052    /// `handle_conn` joins its splice thread before returning, so all buffers +
1053    /// the audit are fully populated on return. For the ALLOWED forward path
1054    /// (where you want to inspect the forwarded bytes) use [`drive_forward`].
1055    fn drive_with(request: &[u8], allow: &[&str], connector: &dyn Connector) -> Driven {
1056        let client = ScriptedConn::with_script(request);
1057        let policy = HostPolicy::new(allow.iter().map(|s| s.to_string()));
1058        let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
1059        let sink = CapturingSink::default();
1060        let refused = Mutex::new(HashSet::new());
1061        let _ = handle_conn(
1062            Box::new(client.clone()),
1063            &policy,
1064            connector,
1065            &resolver,
1066            &sink,
1067            &refused,
1068        );
1069        let mut refused: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
1070        refused.sort();
1071        Driven {
1072            client,
1073            origin: ScriptedConn::default(),
1074            refused,
1075            audit: sink.events(),
1076        }
1077    }
1078
1079    /// Convenience: drive an ALLOWED forward/tunnel with an explicit origin
1080    /// script, returning the `Driven` outcome (with the origin's captured bytes).
1081    fn drive_forward(request: &[u8], allow: &[&str], origin_script: &[u8]) -> Driven {
1082        let client = ScriptedConn::with_script(request);
1083        let origin = ScriptedConn::with_script(origin_script);
1084        let policy = HostPolicy::new(allow.iter().map(|s| s.to_string()));
1085        let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
1086        let sink = CapturingSink::default();
1087        let refused = Mutex::new(HashSet::new());
1088        let connector = FakeConnector(origin.clone());
1089        let _ = handle_conn(
1090            Box::new(client.clone()),
1091            &policy,
1092            &connector,
1093            &resolver,
1094            &sink,
1095            &refused,
1096        );
1097        let mut refused: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
1098        refused.sort();
1099        Driven {
1100            client,
1101            origin,
1102            refused,
1103            audit: sink.events(),
1104        }
1105    }
1106
1107    #[test]
1108    fn allowed_http_host_is_forwarded_to_origin() {
1109        // The allow-listed host is forwarded; the origin's body reaches the client.
1110        let out = drive_forward(
1111            b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\nConnection: close\r\n\r\n",
1112            &["allowed.test"],
1113            b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1114        );
1115        let client_saw = out.client.written_str();
1116        assert!(client_saw.contains("200"), "client saw: {client_saw}");
1117        assert!(
1118            client_saw.contains("origin"),
1119            "the origin's body must reach the client: {client_saw}"
1120        );
1121        // The request reached the origin (proof the forward actually happened).
1122        assert!(
1123            out.origin.written_str().starts_with("GET /x HTTP/1.1"),
1124            "origin must receive the origin-form request: {}",
1125            out.origin.written_str()
1126        );
1127        assert!(out.refused.is_empty(), "an allowed host is not refused");
1128    }
1129
1130    #[test]
1131    fn disallowed_http_host_is_refused_403_without_reaching_origin() {
1132        // A denied host must never be dialled: FailingConnector would surface as a
1133        // 502 if it were ever called, so a 403 here also proves it was NOT.
1134        let out = drive_with(
1135            b"GET http://evil.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1136            &["allowed.test"],
1137            &FailingConnector,
1138        );
1139        let client_saw = out.client.written_str();
1140        assert!(
1141            client_saw.contains("403"),
1142            "denied host must get 403: {client_saw}"
1143        );
1144        assert!(
1145            !client_saw.contains("502"),
1146            "the origin must not be dialled for a denied host: {client_saw}"
1147        );
1148        assert!(out.origin.written().is_empty(), "origin must see nothing");
1149    }
1150
1151    #[test]
1152    fn unreachable_allowed_origin_yields_502() {
1153        // Allow-listed, but the dial fails → 502 Bad Gateway + an `Error` audit.
1154        let out = drive_with(
1155            b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1156            &["allowed.test"],
1157            &FailingConnector,
1158        );
1159        assert!(
1160            out.client.written_str().contains("502"),
1161            "an unreachable allowed origin must get 502: {}",
1162            out.client.written_str()
1163        );
1164        let ev = out.audit.iter().find(|e| e.host == "allowed.test").unwrap();
1165        assert_eq!(ev.decision, NetDecision::Error);
1166    }
1167
1168    #[test]
1169    fn malformed_request_yields_400_and_no_audit() {
1170        // A request line the proxy does not speak → 400, and (deliberately) NO
1171        // audit event, since there is no host to attribute an egress attempt to.
1172        let out = drive_with(
1173            b"GET / HTTP/1.1\r\n\r\n",
1174            &["allowed.test"],
1175            &FailingConnector,
1176        );
1177        assert!(out.client.written_str().contains("400"));
1178        assert!(
1179            out.audit.is_empty(),
1180            "a malformed request is not an egress event: {:?}",
1181            out.audit
1182        );
1183        assert!(out.refused.is_empty());
1184    }
1185
1186    /// #196: the proxy accumulates every host it REFUSES and surfaces them via
1187    /// `refused_hosts()` (deduped, sorted); allowed hosts are never listed.
1188    #[test]
1189    fn refused_hosts_surfaces_denied_hosts_deduped_and_omits_allowed() {
1190        // Drive three requests through ONE shared refused-set: allowed (forwarded),
1191        // then the same denied host twice (must dedupe to a single entry).
1192        let policy = HostPolicy::new(["allowed.test".to_string()]);
1193        let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
1194        let sink = NullSink;
1195        let refused = Mutex::new(HashSet::new());
1196        let origin = ScriptedConn::with_script(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
1197        let connector = FakeConnector(origin);
1198
1199        for (req, conn) in [
1200            (
1201                &b"GET http://allowed.test/x HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1202                &connector as &dyn Connector,
1203            ),
1204            (
1205                &b"GET http://evil.test/y HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1206                &FailingConnector,
1207            ),
1208            (
1209                &b"GET http://evil.test/z HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1210                &FailingConnector,
1211            ),
1212        ] {
1213            let client = ScriptedConn::with_script(req);
1214            let _ = handle_conn(Box::new(client), &policy, conn, &resolver, &sink, &refused);
1215        }
1216
1217        let mut got: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
1218        got.sort();
1219        assert_eq!(
1220            got,
1221            vec!["evil.test".to_string()],
1222            "only the denied host, deduped; the allowed host must NOT appear: {got:?}"
1223        );
1224    }
1225
1226    #[test]
1227    fn audit_records_allowed_with_bytes_and_denied_attempts() {
1228        // Allowed forward: an `Allowed` Http event on port 80 with response bytes.
1229        let allowed = drive_forward(
1230            b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1231            &["allowed.test"],
1232            b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1233        );
1234        let ev = allowed
1235            .audit
1236            .iter()
1237            .find(|e| e.host == "allowed.test")
1238            .expect("an allowed event");
1239        assert_eq!(ev.decision, NetDecision::Allowed);
1240        assert_eq!(ev.kind, NetKind::Http);
1241        assert_eq!(ev.port, 80);
1242        assert!(
1243            ev.bytes_down > 0,
1244            "an allowed connection records response bytes: {ev:?}"
1245        );
1246
1247        // Denied: a `Denied` event (the exfil-attempt signal) with zero bytes.
1248        let denied = drive_with(
1249            b"GET http://evil.test/y HTTP/1.1\r\nHost: ignored\r\n\r\n",
1250            &["allowed.test"],
1251            &FailingConnector,
1252        );
1253        let ev = denied
1254            .audit
1255            .iter()
1256            .find(|e| e.host == "evil.test")
1257            .expect("a denied event");
1258        assert_eq!(ev.decision, NetDecision::Denied);
1259        assert_eq!(ev.bytes_up, 0);
1260        assert_eq!(ev.bytes_down, 0);
1261    }
1262
1263    #[test]
1264    fn jsonl_sink_appends_one_newline_terminated_json_line_per_event() {
1265        #[derive(Clone, Default)]
1266        struct SharedBuf(Arc<Mutex<Vec<u8>>>);
1267        impl Write for SharedBuf {
1268            fn write(&mut self, b: &[u8]) -> io::Result<usize> {
1269                self.0.lock().unwrap().extend_from_slice(b);
1270                Ok(b.len())
1271            }
1272            fn flush(&mut self) -> io::Result<()> {
1273                Ok(())
1274            }
1275        }
1276        let buf = SharedBuf::default();
1277        let sink = JsonlSink::new(buf.clone());
1278        let mk = |host: &str| NetAuditEvent {
1279            ts_ms: 1,
1280            host: host.into(),
1281            port: 80,
1282            kind: NetKind::Http,
1283            decision: NetDecision::Allowed,
1284            bytes_up: 1,
1285            bytes_down: 2,
1286            dur_ms: 3,
1287        };
1288        sink.record(&mk("a"));
1289        sink.record(&mk("b"));
1290        let text = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
1291        let lines: Vec<&str> = text.lines().collect();
1292        assert_eq!(lines.len(), 2, "one JSON line per event: {text:?}");
1293        assert_eq!(
1294            serde_json::from_str::<NetAuditEvent>(lines[0])
1295                .unwrap()
1296                .host,
1297            "a"
1298        );
1299        assert_eq!(
1300            serde_json::from_str::<NetAuditEvent>(lines[1])
1301                .unwrap()
1302                .host,
1303            "b"
1304        );
1305    }
1306
1307    #[test]
1308    fn audit_event_json_round_trips() {
1309        let e = NetAuditEvent {
1310            ts_ms: 1,
1311            host: "h".into(),
1312            port: 443,
1313            kind: NetKind::Connect,
1314            decision: NetDecision::Allowed,
1315            bytes_up: 10,
1316            bytes_down: 20,
1317            dur_ms: 5,
1318        };
1319        let line = serde_json::to_string(&e).unwrap();
1320        assert!(line.contains("\"decision\":\"allowed\"") && line.contains("\"kind\":\"connect\""));
1321        assert_eq!(serde_json::from_str::<NetAuditEvent>(&line).unwrap(), e);
1322    }
1323
1324    #[test]
1325    fn connect_allowed_host_tunnels_opaque_bytes() {
1326        // The client pipelines "PING" right after the CONNECT header block; the
1327        // (scripted) origin returns "PING" as its side of the opaque exchange.
1328        // Proof of a real bidirectional tunnel: the origin receives the client's
1329        // PING (up-copy) and the client receives the origin's PING (down-copy),
1330        // after a bare 200 status line — driven through the real splice logic.
1331        let out = drive_forward(
1332            b"CONNECT allowed.test:443 HTTP/1.1\r\nHost: allowed.test:443\r\n\r\nPING",
1333            &["allowed.test"],
1334            b"PING",
1335        );
1336        let client_saw = out.client.written_str();
1337        assert!(
1338            client_saw.starts_with("HTTP/1.1 200"),
1339            "CONNECT must be accepted with a bare 200: {client_saw:?}"
1340        );
1341        assert!(
1342            client_saw.contains("PING"),
1343            "the origin's bytes must tunnel back to the client: {client_saw:?}"
1344        );
1345        assert_eq!(
1346            out.origin.written(),
1347            b"PING",
1348            "the client's pipelined bytes must reach the origin through the tunnel"
1349        );
1350    }
1351
1352    #[test]
1353    fn connect_disallowed_host_is_refused_403() {
1354        let out = drive_with(
1355            b"CONNECT evil.test:443 HTTP/1.1\r\n\r\n",
1356            &["allowed.test"],
1357            &FailingConnector,
1358        );
1359        assert!(
1360            out.client.written_str().contains("403"),
1361            "a denied CONNECT must get 403, not a tunnel: {}",
1362            out.client.written_str()
1363        );
1364    }
1365
1366    #[test]
1367    fn http_host_header_is_normalized_to_the_validated_authority() {
1368        // The authority (allowed.test) is validated, but the client LIES with a
1369        // spoofed `Host: evil.test` to domain-front. The origin must see the
1370        // validated authority substituted in, never the spoof.
1371        let out = drive_forward(
1372            b"GET http://allowed.test/ HTTP/1.1\r\nHost: evil.test\r\nConnection: close\r\n\r\n",
1373            &["allowed.test"],
1374            b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1375        );
1376        let origin_saw = out.origin.written_str();
1377        assert!(
1378            origin_saw.contains("Host: allowed.test\r\n"),
1379            "origin must receive the validated Host: {origin_saw:?}"
1380        );
1381        assert!(
1382            !origin_saw.contains("evil.test"),
1383            "the spoofed Host must not reach the origin: {origin_saw:?}"
1384        );
1385    }
1386
1387    /// Serialize the tests that touch REAL loopback sockets (#155, #207). They
1388    /// share the host's loopback and ephemeral-port space, so concurrent
1389    /// siblings can interfere — e.g. a port released by one test can be
1390    /// re-bound by a sibling before the first is done probing it, and the
1391    /// probe then observes the sibling's live listener. One shared lock
1392    /// removes that interference class wholesale; the motivating (never
1393    /// locally reproduced) flake was `Empty reply from server` on the
1394    /// fenced_child test in PR #195's macOS CI. The in-memory tests above
1395    /// (#216) need no lock — only the real-socket tests below take it.
1396    /// Process-local, which suffices because `cargo test` runs test binaries
1397    /// sequentially; a per-test-process runner (e.g. nextest) would NOT be
1398    /// covered, nor would the loopback binds in other crates' test binaries.
1399    /// Poison is ignored so a panicking test does not cascade-fail the rest.
1400    fn net_test_lock() -> std::sync::MutexGuard<'static, ()> {
1401        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1402        LOCK.lock().unwrap_or_else(|e| e.into_inner())
1403    }
1404
1405    #[test]
1406    fn proxy_env_points_at_the_bound_loopback_addr() {
1407        // A REAL-socket test: `proxy_env` is derived from the actually-bound
1408        // loopback address, so this exercises the real `bind` — and its live
1409        // accept-loop listener must not interleave with the port probes of the
1410        // sibling real-socket tests.
1411        let _serial = net_test_lock();
1412        let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
1413        let env = proxy.proxy_env();
1414        let url = format!("http://127.0.0.1:{}", proxy.addr().port());
1415        assert!(env.iter().any(|(k, v)| k == "https_proxy" && *v == url));
1416        assert!(env.iter().any(|(k, v)| k == "HTTPS_PROXY" && *v == url));
1417        assert!(env.iter().all(|(_, v)| v.starts_with("http://127.0.0.1:")));
1418    }
1419
1420    /// A one-shot HTTP origin on loopback that replies 200 with a marker body —
1421    /// used ONLY by the macOS kernel e2e below, which must drive real `curl`
1422    /// through the real proxy over a real socket (the in-memory fakes cannot
1423    /// exercise a kernel fence). Gated to that test's platform so it is not dead
1424    /// code elsewhere.
1425    #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1426    fn spawn_origin() -> SocketAddr {
1427        let l = TcpListener::bind(("127.0.0.1", 0)).unwrap();
1428        let addr = l.local_addr().unwrap();
1429        thread::spawn(move || {
1430            for s in l.incoming().flatten() {
1431                let mut s = s;
1432                let mut b = [0u8; 512];
1433                let _ = s.read(&mut b);
1434                let _ = s.write_all(
1435                    b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1436                );
1437            }
1438        });
1439        addr
1440    }
1441
1442    /// End-to-end kernel proof (#124, ADR 0016): a real `curl` child, confined by
1443    /// the ADR 0015 loopback fence, reaches an ALLOWED host only through the proxy
1444    /// (200), is refused a DENIED host by the proxy (403), and — crucially —
1445    /// **cannot bypass** the proxy: a direct off-box connect is kernel-denied. All
1446    /// hermetic — the resolver maps the allow-listed name to a loopback origin, so
1447    /// no real network is touched. macOS + `macos-seatbelt` only; self-skips if
1448    /// `sandbox-exec`/`curl` are unavailable. This is the one test that genuinely
1449    /// needs the real socket path (the fence is a kernel property); the forward /
1450    /// tunnel / allow-list / audit LOGIC is covered deterministically in-memory
1451    /// above.
1452    #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1453    #[test]
1454    fn fenced_child_reaches_allowed_via_proxy_denied_refused_direct_kernel_blocked() {
1455        use crate::{
1456            best_available_sandbox, loopback_fenced_caveats, seatbelt_is_supported, Caveats,
1457            SandboxPolicy, Scope,
1458        };
1459        // Serialize with sibling loopback tests (issue #207): this is the
1460        // heaviest net test (origin + proxy + real curl child) and must not
1461        // race siblings on loopback. Held for the whole test via RAII.
1462        let _serial = net_test_lock();
1463        if !seatbelt_is_supported() {
1464            eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1465            return;
1466        }
1467        let curl = "/usr/bin/curl";
1468        if !std::path::Path::new(curl).exists() {
1469            eprintln!("skipping: no curl(1)");
1470            return;
1471        }
1472
1473        let origin = spawn_origin();
1474        let proxy = start_null(
1475            ["allowed.test".to_string()],
1476            Arc::new(FixedResolver(origin)),
1477        )
1478        .unwrap();
1479
1480        // The grant is a general remote-host allow-list; its loopback-fenced form
1481        // is what actually confines the child (the ADR 0016 mechanism).
1482        let granted = Caveats {
1483            net: Scope::only(["allowed.test".to_string()]),
1484            ..Caveats::top()
1485        };
1486        let prefix = best_available_sandbox(&Arc::new(SandboxPolicy::default()))
1487            .command_prefix(&loopback_fenced_caveats(&granted))
1488            .expect("seatbelt wrapper");
1489
1490        // Run `curl` wrapped by the fence, with the given env. `-v` surfaces the
1491        // connect-time error on stderr so the FENCE leg can assert a *permission*
1492        // denial (not a mere routing failure).
1493        let run = |proxy_env: bool, url: &str| -> std::process::Output {
1494            let mut cmd = std::process::Command::new(&prefix[0]);
1495            cmd.args(&prefix[1..])
1496                .arg(curl)
1497                .args(["-sv", "--max-time", "5", url])
1498                .env_clear();
1499            if proxy_env {
1500                cmd.envs(proxy.proxy_env());
1501            }
1502            cmd.output().expect("spawn sandbox-exec")
1503        };
1504
1505        // ALLOW: via the proxy, the allow-listed host reaches the loopback origin.
1506        let allow = run(true, "http://allowed.test/");
1507        assert!(
1508            String::from_utf8_lossy(&allow.stdout).contains("origin"),
1509            "allow-listed host must reach the origin through the proxy: {allow:?}"
1510        );
1511        // DENY: via the proxy, a non-allow-listed host gets the proxy's 403 — and
1512        // never reaches any origin.
1513        let deny = run(true, "http://denied.test/");
1514        assert!(
1515            String::from_utf8_lossy(&deny.stdout).contains("403"),
1516            "denied host must get the proxy's 403: {deny:?}"
1517        );
1518        // FENCE: WITHOUT the proxy env the child tries to egress directly; a literal
1519        // off-box IP is kernel-denied at the socket. Assert curl exit 7 AND an EPERM
1520        // signal ("Operation not permitted") — so a no-internet runner (ENETUNREACH,
1521        // also exit 7) cannot make this pass vacuously; it must be a *permission*
1522        // denial, proving the fence (not the network) blocked it.
1523        let direct = run(false, "http://1.1.1.1/");
1524        let stderr = String::from_utf8_lossy(&direct.stderr);
1525        assert_eq!(
1526            direct.status.code(),
1527            Some(7),
1528            "direct off-box egress must be kernel-denied (curl exit 7): {stderr}"
1529        );
1530        assert!(
1531            stderr.contains("Operation not permitted"),
1532            "the block must be a kernel EPERM, not a routing failure: {stderr}"
1533        );
1534
1535        drop(proxy);
1536    }
1537
1538    /// The other always-on REAL-socket test: it must exercise the actual
1539    /// `TcpListener` bind + accept loop + `Drop` teardown, which no in-memory
1540    /// fake can. It probes a just-released ephemeral port, so it serializes on
1541    /// `net_test_lock()` — a concurrent sibling could re-bind that port between
1542    /// the drop and the probe, and the probe would then hit the sibling's live
1543    /// listener (#207).
1544    #[test]
1545    fn dropping_the_handle_stops_the_listener() {
1546        let _serial = net_test_lock();
1547        let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
1548        let addr = proxy.addr();
1549        drop(proxy);
1550        // After shutdown the port is no longer served: a connect either refuses
1551        // or the accept loop has exited. Give the OS a moment, then assert we
1552        // cannot complete an HTTP exchange through it.
1553        thread::sleep(Duration::from_millis(100));
1554        if let Ok(mut c) = TcpStream::connect_timeout(&addr, Duration::from_millis(200)) {
1555            c.set_read_timeout(Some(Duration::from_millis(500)))
1556                .unwrap();
1557            let _ = write!(c, "GET http://x/ HTTP/1.1\r\n\r\n");
1558            let mut resp = Vec::new();
1559            let _ = c.read_to_end(&mut resp);
1560            assert!(resp.is_empty(), "a stopped proxy must not serve requests");
1561        }
1562    }
1563}