From 605e8efe70bca6688e4507cbb509f962631accf2 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Fri, 12 Jun 2026 15:58:42 -0400 Subject: [PATCH] http_proxy: Add the allowlisting proxy server Final piece of the crate: the in-process HTTP/HTTPS proxy server that enforces an `Allowlist`. It speaks HTTP CONNECT for HTTPS tunnels and forward proxying for plain HTTP, vets resolved addresses against loopback/private/link-local ranges to prevent DNS-rebinding past the sandbox, pins each connection to the destination approved for its first request (so later keep-alive requests can't escape the policy decision), optionally chains through the `UpstreamProxy`, and bounds header sizes, connection counts, and connect/handshake waits since its sole client is untrusted model-driven code running inside the editor process. Includes end-to-end tests covering allowed/denied CONNECT and HTTP forward, IP-literal handling, DNS-rebinding denial, and upstream chaining. Still has no callers; wired into the agent terminal sandbox in later PRs. Release Notes: - N/A --- Cargo.lock | 4 + Cargo.toml | 1 + crates/http_proxy/Cargo.toml | 4 + crates/http_proxy/src/http_proxy.rs | 58 +- crates/http_proxy/src/proxy.rs | 283 +++++- crates/http_proxy/src/proxy/connection.rs | 1110 +++++++++++++++++++++ crates/http_proxy/tests/end_to_end.rs | 535 ++++++++++ 7 files changed, 1982 insertions(+), 13 deletions(-) create mode 100644 crates/http_proxy/src/proxy/connection.rs create mode 100644 crates/http_proxy/tests/end_to_end.rs diff --git a/Cargo.lock b/Cargo.lock index 2d63ed8faf75ba..eee9337deaba45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8547,7 +8547,11 @@ name = "http_proxy" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", + "futures 0.3.32", + "httparse", "idna", + "log", "percent-encoding", "proxyvars", "thiserror 2.0.17", diff --git a/Cargo.toml b/Cargo.toml index c24323a22d80bc..9353bf69727af9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -606,6 +606,7 @@ human_bytes = "0.4.1" html5ever = "0.27.0" http = "1.1" http-body = "1.0" +httparse = "1.10" idna = "1.0" ignore = "0.4.22" image = "0.25.1" diff --git a/crates/http_proxy/Cargo.toml b/crates/http_proxy/Cargo.toml index 0b3d6dce865389..efce7a8e5c2650 100644 --- a/crates/http_proxy/Cargo.toml +++ b/crates/http_proxy/Cargo.toml @@ -13,7 +13,11 @@ path = "src/http_proxy.rs" [dependencies] anyhow.workspace = true +base64.workspace = true +futures.workspace = true +httparse.workspace = true idna.workspace = true +log.workspace = true percent-encoding.workspace = true proxyvars.workspace = true thiserror.workspace = true diff --git a/crates/http_proxy/src/http_proxy.rs b/crates/http_proxy/src/http_proxy.rs index fd845700fa079b..1739d846c193cb 100644 --- a/crates/http_proxy/src/http_proxy.rs +++ b/crates/http_proxy/src/http_proxy.rs @@ -1,17 +1,55 @@ -//! Hostname-allowlisting primitives for confining sandboxed network access. +//! In-process HTTP/HTTPS proxy that enforces a hostname allowlist. //! -//! This crate grows over a short stack of PRs: +//! Spawned per terminal command from the parent process. The sandbox is +//! configured to permit network only to this proxy's port; everything the +//! sandboxed command tries to reach the network for has to come through here. //! -//! - [`allowlist`]: the policy types ([`HostPattern`], [`Allowlist`]) that -//! decide which hosts a sandboxed command may reach. -//! - [`UpstreamProxy`]: parsing an upstream HTTP proxy from the environment -//! (`HTTPS_PROXY` / `NO_PROXY` etc.) to chain through. -//! - the proxy server itself (next): an in-process HTTP/HTTPS proxy that -//! enforces an [`Allowlist`] and is the only network egress a sandboxed -//! command is permitted. +//! The proxy: +//! +//! - Speaks HTTP CONNECT for HTTPS tunnels and HTTP forward proxying for +//! plain HTTP. Other protocols cannot reach it (the seatbelt rule limits +//! the sandboxed process to this one TCP destination, and this proxy only +//! speaks HTTP). +//! - Checks the destination hostname against an allowlist of exact hostnames +//! and leading-`*.` subdomain wildcards. Unless the allowlist allows any +//! host, IP-literal targets are denied, and hostnames whose DNS resolves +//! only into loopback / private / link-local space are denied too +//! (DNS-rebinding protection — the proxy runs outside the sandbox, so it +//! must not reopen the local network the Seatbelt rule closed off). +//! - Pins each TCP connection to the destination approved for its first +//! request: directly (to the vetted resolved addresses) or via a CONNECT +//! tunnel through an optional upstream HTTP proxy from the parent's +//! environment (`HTTPS_PROXY` / `HTTP_PROXY`), honoring `NO_PROXY`. Plain +//! HTTP is also tunneled when chaining, so keep-alive requests after the +//! first can never be routed to a different host by the upstream. +//! - Reports per-connection events (allowed, denied, completed) over an +//! mpsc supplied by the caller. +//! +//! ## Trust assumptions +//! +//! The proxy's sole client is model-driven code running inside the sandbox — +//! exactly the party the sandbox distrusts — and the proxy itself runs inside +//! the editor process. It therefore caps request header sizes and concurrent +//! connections, and bounds connect/handshake waits with timeouts, so a +//! malicious command can't exhaust the editor's memory, threads, or file +//! descriptors through it. Bandwidth is deliberately not capped; the +//! command's lifetime bounds it. +//! +//! ## "No proxy here" principle +//! +//! The agent and tools running inside the sandbox should not need to know +//! that a proxy is in front of them. The only response code the proxy +//! synthesizes itself is `511 Network Authentication Required`, used solely +//! for policy denials (with `Via:` and `Proxy-Status:` headers and a +//! plain-text body explaining the policy decision). Other failure modes +//! (upstream connection failure, malformed input from the client, etc.) are +//! handled by silently closing the connection — same behavior the client +//! would see from a direct network failure, no proxy fingerprint. mod allowlist; mod proxy; pub use allowlist::{Allowlist, HostPattern, HostPatternError}; -pub use proxy::UpstreamProxy; +pub use proxy::{ + DenyReason, ProxyConfig, ProxyEvent, ProxyHandle, RequestMethod, RequestOutcome, UpstreamProxy, +}; diff --git a/crates/http_proxy/src/proxy.rs b/crates/http_proxy/src/proxy.rs index a0f997cfc0ae78..bb51bf995a4501 100644 --- a/crates/http_proxy/src/proxy.rs +++ b/crates/http_proxy/src/proxy.rs @@ -1,7 +1,284 @@ -//! The proxy module. For now it holds only the upstream-proxy configuration -//! type; the proxy server (listener, connection handling) lands in a later -//! PR. +//! The proxy itself: listener, connection handlers, upstream chaining. +//! +//! All synchronous, thread-per-connection. `ProxyHandle::spawn` binds a +//! `std::net::TcpListener` on `127.0.0.1:0` and returns once the listener +//! is bound and the listener thread has been spawned. Drop the handle to +//! shut everything down — the listener thread stops accepting new +//! connections; in-flight connection threads finish on their own when +//! either side closes. +//! +//! See the crate-level docs for trust assumptions and the "no proxy here" +//! principle. +mod connection; mod upstream; +use crate::allowlist::Allowlist; +use anyhow::{Context, Result}; +use futures::channel::mpsc; +use std::net::{Ipv4Addr, TcpListener, TcpStream}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::thread; + +/// Cap on concurrently handled connections. Each connection costs the +/// editor process two threads and two pump buffers; the cap keeps a +/// runaway (or malicious) sandboxed command from exhausting the editor's +/// thread/fd budget. Well above what parallel package managers open. +const MAX_CONCURRENT_CONNECTIONS: usize = 256; + pub use upstream::UpstreamProxy; + +/// Configuration for spawning a proxy. +#[derive(Debug, Clone)] +pub struct ProxyConfig { + /// Hosts the proxy will allow to be reached. + pub allowlist: Allowlist, + /// Optional upstream HTTP proxy to chain through, with `NO_PROXY`-style + /// bypasses for hosts that should connect direct. + pub upstream: Option, + /// Where the proxy reports per-connection events. Use + /// [`mpsc::unbounded`] so connection threads (which are sync) never + /// block on send. The receiver is async-friendly so `gpui` / `tokio` + /// callers can poll it from their executor of choice. + pub events: mpsc::UnboundedSender, +} + +/// A request method seen by the proxy. +/// +/// Either a CONNECT (HTTPS tunnel) or an HTTP forward request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RequestMethod { + Connect, + Http(String), +} + +impl RequestMethod { + pub fn as_str(&self) -> &str { + match self { + RequestMethod::Connect => "CONNECT", + RequestMethod::Http(method) => method.as_str(), + } + } +} + +/// Outcome of a single connection's policy decision. +#[derive(Debug, Clone)] +pub enum RequestOutcome { + Allowed, + Denied { reason: DenyReason }, +} + +/// Why an attempted connection was denied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DenyReason { + /// Hostname (in punycode form on the wire) wasn't in the allowlist. + HostNotInAllowlist { host: String }, + /// CONNECT or HTTP request targeted an IP literal. Denied unless the + /// allowlist allows any host. + IpLiteralRejected { target: String }, + /// The hostname resolved only to loopback / private / link-local + /// addresses, which the sandbox policy never reaches via the allowlist + /// (DNS-rebinding protection). Not applied when the allowlist allows + /// any host. + ResolvedToForbiddenIp { host: String }, +} + +impl DenyReason { + pub(crate) fn proxy_status_error(&self) -> &'static str { + match self { + DenyReason::HostNotInAllowlist { .. } => "destination_ip_prohibited", + DenyReason::IpLiteralRejected { .. } => "destination_ip_prohibited", + DenyReason::ResolvedToForbiddenIp { .. } => "destination_ip_prohibited", + } + } + + pub(crate) fn human_explanation(&self) -> String { + match self { + DenyReason::HostNotInAllowlist { host } => { + format!("host '{host}' is not in this conversation's network allowlist") + } + DenyReason::IpLiteralRejected { target } => format!( + "target '{target}' is an IP literal; only hostnames are permitted by sandbox policy" + ), + DenyReason::ResolvedToForbiddenIp { host } => format!( + "host '{host}' resolves only to loopback/private/link-local addresses, \ + which sandbox policy blocks" + ), + } + } +} + +/// Events emitted by the proxy as it handles connections. +#[derive(Debug, Clone)] +pub enum ProxyEvent { + /// Sent once after the listener is bound. Always the first event for + /// a given proxy instance. + Ready { port: u16 }, + + /// Emitted at policy-decision time, before bytes flow to the upstream. + RequestAttempt { + host: String, + port: u16, + method: RequestMethod, + outcome: RequestOutcome, + }, + + /// Emitted after an `Allowed` connection finishes. Carries throughput + /// totals for diagnostics. Not emitted for denied connections. + RequestCompleted { + host: String, + port: u16, + method: RequestMethod, + bytes_to_remote: u64, + bytes_from_remote: u64, + duration_ms: u64, + }, +} + +/// Handle to a running proxy. Drop to stop the listener; in-flight +/// connection threads finish on their own as soon as either side closes. +pub struct ProxyHandle { + port: u16, + /// Listener thread sees this flip to `true` after `accept` returns and + /// then exits. + shutdown: Arc, + /// Joined on drop to make shutdown deterministic in tests; ignored if + /// the listener has already exited. + listener_thread: Option>, +} + +impl ProxyHandle { + /// Spawns the proxy: binds a listener on `127.0.0.1:0`, spawns the + /// listener thread, sends a `Ready` event, and returns. The returned + /// port is what callers should use for `HTTPS_PROXY`/`HTTP_PROXY` env + /// vars and for the seatbelt rule narrowing `localhost:`. + pub fn spawn(config: ProxyConfig) -> Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .context("failed to bind proxy listener on 127.0.0.1:0")?; + let port = listener + .local_addr() + .context("failed to read proxy local addr")? + .port(); + + // Inform the parent the proxy is ready before starting the accept + // loop. Send is fire-and-forget on an unbounded channel — never + // blocks, never errors meaningfully. + let _ = config.events.unbounded_send(ProxyEvent::Ready { port }); + + let shutdown = Arc::new(AtomicBool::new(false)); + let runtime_state = Arc::new(RuntimeState { + allowlist: config.allowlist, + upstream: config.upstream, + events: config.events, + active_connections: AtomicUsize::new(0), + }); + + let listener_thread = thread::Builder::new() + .name("http-proxy-listener".to_string()) + // Listener thread does almost nothing on its stack — accept, + // spawn, loop. 128 KiB is plenty. + .stack_size(128 * 1024) + .spawn({ + let shutdown = shutdown.clone(); + move || run_listener(listener, runtime_state, shutdown) + }) + .context("failed to spawn proxy listener thread")?; + + Ok(ProxyHandle { + port, + shutdown, + listener_thread: Some(listener_thread), + }) + } + + /// The bound port. Stable for the lifetime of this handle. + pub fn port(&self) -> u16 { + self.port + } +} + +impl Drop for ProxyHandle { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::SeqCst); + // The listener is blocked in `accept()`. Waking it up cleanly via + // a flag alone isn't possible with `std::net::TcpListener` — there's + // no way to interrupt the syscall. Connect to ourselves: the + // listener wakes up, accepts the connection, sees the shutdown + // flag, breaks the loop. The accepted connection's worker thread + // will read the empty stream and exit too. + let _ = TcpStream::connect((Ipv4Addr::LOCALHOST, self.port)); + + if let Some(thread) = self.listener_thread.take() { + // Give the listener a chance to clean up. A join error means the + // listener thread panicked; there's nothing to recover, but it + // shouldn't pass unnoticed. + if thread.join().is_err() { + log::warn!("[http_proxy] listener thread panicked"); + } + } + } +} + +/// State shared across all connection threads for a single proxy instance. +pub(crate) struct RuntimeState { + pub(crate) allowlist: Allowlist, + pub(crate) upstream: Option, + pub(crate) events: mpsc::UnboundedSender, + active_connections: AtomicUsize, +} + +/// Decrements the active-connection count when a connection thread finishes +/// (normally or by panic). +struct ConnectionSlot(Arc); + +impl Drop for ConnectionSlot { + fn drop(&mut self) { + self.0.active_connections.fetch_sub(1, Ordering::SeqCst); + } +} + +fn run_listener(listener: TcpListener, state: Arc, shutdown: Arc) { + for stream in listener.incoming() { + if shutdown.load(Ordering::SeqCst) { + log::debug!("[http_proxy] listener stopping (shutdown signaled)"); + break; + } + match stream { + Ok(stream) => { + let previous = state.active_connections.fetch_add(1, Ordering::SeqCst); + if previous >= MAX_CONCURRENT_CONNECTIONS { + state.active_connections.fetch_sub(1, Ordering::SeqCst); + log::warn!( + "[http_proxy] dropping connection: {MAX_CONCURRENT_CONNECTIONS} \ + connections already active" + ); + drop(stream); + continue; + } + let slot = ConnectionSlot(state.clone()); + let state = state.clone(); + let result = thread::Builder::new() + .name("http-proxy-conn".to_string()) + // Connection workers do bidir copy with a 64 KiB buffer + // and a few syscall stack frames. 128 KiB is plenty. + .stack_size(128 * 1024) + .spawn(move || { + let _slot = slot; + if let Err(e) = connection::handle(stream, state) { + log::debug!("[http_proxy] connection handler error: {e}"); + } + }); + if let Err(e) = result { + log::warn!("[http_proxy] failed to spawn connection thread: {e}"); + } + } + Err(e) => { + // EMFILE / per-process fd exhaustion is the realistic + // failure here. Log and keep going — accept errors are + // usually transient. + log::warn!("[http_proxy] accept failed: {e}"); + } + } + } +} diff --git a/crates/http_proxy/src/proxy/connection.rs b/crates/http_proxy/src/proxy/connection.rs new file mode 100644 index 00000000000000..65aa0a0b02c88c --- /dev/null +++ b/crates/http_proxy/src/proxy/connection.rs @@ -0,0 +1,1110 @@ +//! Per-connection logic: parse the first request, decide allowed/denied, +//! either pump bytes through to the upstream or close (with an explanatory +//! 511 for policy denials). +//! +//! Both CONNECT and HTTP forward go through here. After the policy decision, +//! the TCP connection is pinned to the approved destination — directly, or +//! via a CONNECT tunnel through the upstream proxy — and everything becomes +//! opaque byte pumping. We don't parse anything else (chunked encoding, +//! keep-alive HTTP requests after the first, etc.); because the whole +//! connection can only reach the one approved host, later requests the +//! client sends on it cannot escape the policy decision. Per-TCP-connection +//! event granularity, by design. + +use crate::proxy::{ + DenyReason, ProxyEvent, RequestMethod, RequestOutcome, RuntimeState, UpstreamProxy, +}; +use anyhow::{Context, Result, anyhow, bail}; +use base64::Engine as _; +use std::io::{Read, Write}; +use std::net::{IpAddr, Ipv4Addr, Shutdown, SocketAddr, TcpStream, ToSocketAddrs as _}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; +use url::Url; + +/// Buffer size for each direction of bidir copy. 64 KiB is the sweet spot +/// for most networks — large enough to keep the pipe full, small enough +/// not to balloon memory under many concurrent connections. +const PUMP_BUFFER_SIZE: usize = 64 * 1024; + +/// Cap on request/response header bytes. The proxy runs inside the editor +/// process and its sole client is model-driven code — exactly the party the +/// sandbox distrusts — so an unbounded header read would let a malicious +/// command balloon the editor's memory. 64 KiB is far beyond what real HTTP +/// clients send. +const MAX_HEADER_BYTES: usize = 64 * 1024; + +/// How long to wait for the client's request headers. Pooled connections +/// that never send a request get closed; well-behaved clients retry on a +/// fresh connection. Cleared before the pump phase so long-lived idle +/// tunnels (long polls, slow downloads) are unaffected. +const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(60); + +/// Timeout for outbound TCP connects (direct or to the upstream proxy), +/// so a black-holed destination doesn't pin a connection thread for the +/// OS default (~75s or more). +const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); + +/// How long to wait for the upstream proxy's CONNECT response. +const UPSTREAM_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Top-level entry from the listener thread. Owns the client connection +/// and the runtime state; emits events; never returns errors that escape +/// the connection (those are logged at the listener level). +pub(crate) fn handle(client: TcpStream, state: Arc) -> Result<()> { + // Performance baseline: TCP_NODELAY eliminates the Nagle delay that + // otherwise stalls small interactive payloads (git protocol negotiation, + // npm metadata pings, etc.). + if let Err(error) = client.set_nodelay(true) { + log::debug!("[http_proxy] failed to set TCP_NODELAY on client socket: {error}"); + } + + let mut client = client; + if let Err(error) = client.set_read_timeout(Some(HEADER_READ_TIMEOUT)) { + log::debug!("[http_proxy] failed to set client header read timeout: {error}"); + } + + // Read until end-of-headers (`\r\n\r\n`). The first request determines + // the destination for this entire TCP connection. + let (header_buf, header_end) = read_request_headers(&mut client)?; + + // The header timeout must not apply to the pump phase. (Socket options + // are shared with the `try_clone` handles used by the pump.) + if let Err(error) = client.set_read_timeout(None) { + log::debug!("[http_proxy] failed to clear client read timeout: {error}"); + } + + let request = ParsedRequest::parse(&header_buf[..header_end])?; + + // Bytes after the request headers — the start of an HTTP request body + // for forwarding cases, or (for unusually eager clients) bytes pipelined + // ahead of the CONNECT response. Replayed to the upstream either way. + let leftover_body = header_buf[header_end..].to_vec(); + + match request { + ParsedRequest::Connect { host, port } => { + handle_connect(client, host, port, leftover_body, state) + } + ParsedRequest::Http { + method, + host, + port, + request_bytes, + } => handle_http_forward( + client, + method, + host, + port, + request_bytes, + leftover_body, + state, + ), + } +} + +/// Reads request headers (until `\r\n\r\n`) from the client. Returns the +/// full buffer (which may include some bytes after the headers) and the +/// offset where the headers ended. Capped at [`MAX_HEADER_BYTES`]. +fn read_request_headers(client: &mut TcpStream) -> Result<(Vec, usize)> { + let mut buf = Vec::with_capacity(4096); + let mut tmp = [0u8; 4096]; + let mut searched = 0usize; + loop { + let n = client.read(&mut tmp)?; + if n == 0 { + bail!("client closed before sending complete request headers"); + } + buf.extend_from_slice(&tmp[..n]); + // Only scan the new bytes (plus 3 bytes of overlap for a delimiter + // straddling the read boundary), keeping the search linear overall. + let scan_start = searched.saturating_sub(3); + if let Some(end) = find_double_crlf(&buf[scan_start..]) { + return Ok((buf, scan_start + end)); + } + searched = buf.len(); + if buf.len() > MAX_HEADER_BYTES { + bail!("request headers exceed {MAX_HEADER_BYTES} bytes"); + } + } +} + +fn find_double_crlf(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4) +} + +/// Parsed first request from the client. +enum ParsedRequest { + Connect { + host: String, + port: u16, + }, + Http { + method: String, + host: String, + port: u16, + /// Request bytes (line + headers) to forward to the origin. + /// Absolute-form requests are rewritten to origin-form (see + /// [`build_origin_form_request`]); origin-form requests pass + /// through verbatim. + request_bytes: Vec, + }, +} + +impl ParsedRequest { + fn parse(headers: &[u8]) -> Result { + let mut header_storage = [httparse::EMPTY_HEADER; 64]; + let mut req = httparse::Request::new(&mut header_storage); + let status = req.parse(headers).context("malformed HTTP request")?; + if !status.is_complete() { + bail!("incomplete HTTP request after \\r\\n\\r\\n boundary"); + } + + let method = req + .method + .ok_or_else(|| anyhow!("missing HTTP method"))? + .to_string(); + let target = req.path.ok_or_else(|| anyhow!("missing request target"))?; + + if method.eq_ignore_ascii_case("CONNECT") { + let (host, port) = parse_authority_form(target)?; + Ok(ParsedRequest::Connect { host, port }) + } else if target.starts_with("http://") || target.starts_with("https://") { + let (host, port, url) = parse_absolute_form_target(target)?; + let request_bytes = build_origin_form_request(&method, &url, req.version, req.headers); + Ok(ParsedRequest::Http { + method, + host, + port, + request_bytes, + }) + } else { + // Origin-form request: destination comes from the Host: header, + // and the bytes are already in the form an origin server expects, + // so forward them verbatim. + let host_hdr = req + .headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case("host")) + .ok_or_else(|| anyhow!("origin-form request missing Host: header"))?; + let value = + std::str::from_utf8(host_hdr.value).context("Host: header is not valid UTF-8")?; + let (host, port) = parse_host_header(value)?; + Ok(ParsedRequest::Http { + method, + host, + port, + request_bytes: headers.to_vec(), + }) + } + } +} + +/// Parse `host:port` (CONNECT authority-form). Port is required, per RFC 9112 +/// §3.2.3. +/// +/// The port is split off manually rather than via `Url::parse`, because `Url` +/// elides scheme-default ports — `host:80` parsed with an `http://` prefix +/// becomes indistinguishable from a missing port, and `CONNECT host:80` is +/// legitimate (e.g. `ws://` through a proxy). `Url` still canonicalizes the +/// host part (bracketed IPv6, IDN-to-punycode, lowercasing). +fn parse_authority_form(input: &str) -> Result<(String, u16)> { + let (host_part, port_part) = input + .rsplit_once(':') + .ok_or_else(|| anyhow!("CONNECT target '{input}' must include a port"))?; + let port: u16 = port_part + .parse() + .with_context(|| format!("CONNECT target '{input}' has an invalid port"))?; + let parsed = Url::parse(&format!("http://{host_part}")) + .with_context(|| format!("parsing CONNECT target '{input}'"))?; + let host = parsed + .host_str() + .ok_or_else(|| anyhow!("CONNECT target '{input}' has no host"))? + .to_string(); + Ok((host, port)) +} + +/// Parse an absolute-form HTTP request target like `http://foo.com/path`. +/// Returns the parsed URL too, for the origin-form rewrite. +fn parse_absolute_form_target(target: &str) -> Result<(String, u16, Url)> { + let parsed = + Url::parse(target).with_context(|| format!("parsing absolute-form target '{target}'"))?; + let host = parsed + .host_str() + .ok_or_else(|| anyhow!("absolute-form target '{target}' has no host"))? + .to_string(); + // `port_or_known_default` covers `http://foo.com` (→ 80) without forcing + // the agent to spell it. + let port = parsed + .port_or_known_default() + .ok_or_else(|| anyhow!("absolute-form target '{target}' has no port"))?; + Ok((host, port, parsed)) +} + +/// Rewrite an absolute-form proxy request into origin-form for the origin +/// server. +/// +/// RFC 9112 §3.2.2 requires origin servers to accept absolute-form, but many +/// real servers and frameworks don't handle it; every production proxy +/// rewrites, and so do we. The `Host` header is regenerated from the URL +/// (per the same section, a proxy must use the URI host and ignore a +/// mismatched `Host`), and `Proxy-*` headers — which are addressed to us and +/// may carry credentials (`Proxy-Authorization`) — are stripped rather than +/// leaked to the origin. +fn build_origin_form_request( + method: &str, + url: &Url, + version: Option, + headers: &[httparse::Header], +) -> Vec { + let mut target = url.path().to_string(); + if let Some(query) = url.query() { + target.push('?'); + target.push_str(query); + } + // `host_str` keeps brackets on IPv6 literals, which is what the Host + // header wants. `url.port()` is None for scheme-default ports, matching + // the convention of omitting them. + let host_value = match (url.host_str(), url.port()) { + (Some(host), Some(port)) => format!("{host}:{port}"), + (Some(host), None) => host.to_string(), + // Unreachable in practice: callers parsed the host already. + (None, _) => String::new(), + }; + let minor_version = version.unwrap_or(1); + + let mut out = Vec::with_capacity(256); + out.extend_from_slice(format!("{method} {target} HTTP/1.{minor_version}\r\n").as_bytes()); + out.extend_from_slice(format!("Host: {host_value}\r\n").as_bytes()); + for header in headers { + let name = header.name; + if name.eq_ignore_ascii_case("host") + || name + .get(.."proxy-".len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("proxy-")) + { + continue; + } + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(b": "); + out.extend_from_slice(header.value); + out.extend_from_slice(b"\r\n"); + } + out.extend_from_slice(b"\r\n"); + out +} + +/// Parse a `Host:` header value into `(host, port)`. Default port is 80 +/// since this is only called for HTTP forward, never CONNECT. +fn parse_host_header(value: &str) -> Result<(String, u16)> { + let value = value.trim(); + if value.is_empty() { + bail!("empty Host header"); + } + let parsed = Url::parse(&format!("http://{value}")) + .with_context(|| format!("parsing Host header '{value}'"))?; + let host = parsed + .host_str() + .ok_or_else(|| anyhow!("Host header '{value}' has no host"))? + .to_string(); + let port = parsed.port().unwrap_or(80); + Ok((host, port)) +} + +/// Normalize a hostname for allowlist matching. Strips brackets from IPv6 +/// literals and a single trailing dot. Lowercasing happens inside the +/// allowlist matcher. +fn normalize_host(host: &str) -> String { + let stripped = host + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .unwrap_or(host); + stripped.trim_end_matches('.').to_string() +} + +/// Whether a hostname is an IP literal, per our policy. +fn is_ip_literal(host: &str) -> bool { + let stripped = host + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .unwrap_or(host); + stripped.parse::().is_ok() +} + +/// The policy check shared by CONNECT and HTTP forward: IP-literal targets +/// and hosts outside the allowlist are denied. Both checks are skipped when +/// the allowlist allows any host — that grant means unrestricted egress, +/// including IP literals (matching the pre-allowlist `allow_network` +/// behavior). +fn policy_denial(host: &str, port: u16, state: &RuntimeState) -> Option { + if state.allowlist.allows_any() { + return None; + } + if is_ip_literal(host) { + return Some(DenyReason::IpLiteralRejected { + target: format!("{host}:{port}"), + }); + } + if !state.allowlist.allows(host) { + return Some(DenyReason::HostNotInAllowlist { + host: host.to_string(), + }); + } + None +} + +/// How an approved request will reach its destination. +enum Route { + /// Connect directly to one of these resolved-and-vetted addresses. + Direct(Vec), + /// Tunnel through the upstream proxy with a CONNECT handshake. + ViaUpstream(UpstreamProxy), +} + +enum RouteFailure { + /// Policy denial — respond with 511. + Denied(DenyReason), + /// Network-level failure — close silently, per "no proxy here". + Error(anyhow::Error), +} + +/// Decide how to reach `host:port`, resolving and vetting addresses for +/// direct connections. +/// +/// Resolution happens here, in the unsandboxed editor process, so this is +/// also where we keep allowlisted hostnames from smuggling the sandboxed +/// command onto the local machine or local network: a hostname whose DNS +/// points into loopback / private / link-local space (DNS rebinding) is +/// denied, and the connection later uses the vetted addresses rather than +/// re-resolving. The filter is skipped when the allowlist allows any host, +/// since that grant means unrestricted egress. Upstream-proxied destinations +/// aren't resolved locally at all — the upstream does its own resolution +/// inside the user's trusted network. +fn plan_route(host: &str, port: u16, state: &RuntimeState) -> Result { + if let Some(upstream) = &state.upstream + && !upstream.bypasses(host, port) + { + return Ok(Route::ViaUpstream(upstream.clone())); + } + + let resolved: Vec = (host, port) + .to_socket_addrs() + .map_err(|error| RouteFailure::Error(anyhow!("resolving {host}:{port}: {error}")))? + .collect(); + if resolved.is_empty() { + return Err(RouteFailure::Error(anyhow!( + "{host}:{port} did not resolve to any address" + ))); + } + + let vetted: Vec = if state.allowlist.allows_any() { + resolved + } else { + resolved + .into_iter() + .filter(|addr| !is_forbidden_ip(addr.ip())) + .collect() + }; + if vetted.is_empty() { + return Err(RouteFailure::Denied(DenyReason::ResolvedToForbiddenIp { + host: host.to_string(), + })); + } + Ok(Route::Direct(vetted)) +} + +/// Whether a resolved address is in loopback / private / link-local space — +/// destinations a hostname allowlist must never reach. The Seatbelt rule +/// already blocks them for direct connections from the sandbox; the proxy +/// (which runs outside the sandbox) must not reopen them. +fn is_forbidden_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => is_forbidden_ipv4(v4), + IpAddr::V6(v6) => { + if let Some(v4) = v6.to_ipv4_mapped() { + return is_forbidden_ipv4(v4); + } + v6.is_loopback() + || v6.is_unspecified() + // Link-local (fe80::/10) and unique-local (fc00::/7); the + // dedicated `is_unicast_link_local` / `is_unique_local` + // methods are not yet stable. + || (v6.segments()[0] & 0xffc0) == 0xfe80 + || (v6.segments()[0] & 0xfe00) == 0xfc00 + } + } +} + +fn is_forbidden_ipv4(ip: Ipv4Addr) -> bool { + let octets = ip.octets(); + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() // includes 169.254.169.254 cloud metadata + || ip.is_unspecified() + || ip.is_broadcast() + // Shared address space (RFC 6598, 100.64.0.0/10): CGNAT, and notably + // Tailscale-style overlay networks. + || (octets[0] == 100 && (octets[1] & 0xc0) == 64) +} + +fn handle_connect( + mut client: TcpStream, + host: String, + port: u16, + leftover_body: Vec, + state: Arc, +) -> Result<()> { + let normalized = normalize_host(&host); + + if let Some(reason) = policy_denial(&normalized, port, &state) { + return deny_request( + &mut client, + &state, + normalized, + port, + RequestMethod::Connect, + reason, + ); + } + + let route = match plan_route(&normalized, port, &state) { + Ok(route) => route, + Err(RouteFailure::Denied(reason)) => { + return deny_request( + &mut client, + &state, + normalized, + port, + RequestMethod::Connect, + reason, + ); + } + Err(RouteFailure::Error(error)) => { + log::debug!("[http_proxy] routing failed for CONNECT {normalized}:{port}: {error:#}"); + // Per "no proxy here" — close abruptly. Client sees a connection drop. + return Ok(()); + } + }; + + emit( + &state, + ProxyEvent::RequestAttempt { + host: normalized.clone(), + port, + method: RequestMethod::Connect, + outcome: RequestOutcome::Allowed, + }, + ); + + let (mut upstream, upstream_leftover) = match open_route(&route, &normalized, port) { + Ok(opened) => opened, + Err(error) => { + log::debug!( + "[http_proxy] upstream open failed for CONNECT {normalized}:{port}: {error:#}" + ); + return Ok(()); + } + }; + + if let Err(error) = upstream.set_nodelay(true) { + log::debug!("[http_proxy] failed to set TCP_NODELAY on upstream socket: {error}"); + } + + // Tell the client the tunnel is up, then replay anything the upstream + // sent past its CONNECT response and anything the client pipelined ahead + // of ours. + client.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n")?; + if !upstream_leftover.is_empty() { + client.write_all(&upstream_leftover)?; + } + if !leftover_body.is_empty() { + upstream.write_all(&leftover_body)?; + } + + let started = Instant::now(); + let (pumped_to_remote, pumped_from_remote) = pump_bidir(client, upstream); + + emit( + &state, + ProxyEvent::RequestCompleted { + host: normalized, + port, + method: RequestMethod::Connect, + bytes_to_remote: pumped_to_remote + leftover_body.len() as u64, + bytes_from_remote: pumped_from_remote + upstream_leftover.len() as u64, + duration_ms: started.elapsed().as_millis() as u64, + }, + ); + + Ok(()) +} + +fn handle_http_forward( + mut client: TcpStream, + method: String, + host: String, + port: u16, + request_bytes: Vec, + leftover_body: Vec, + state: Arc, +) -> Result<()> { + let normalized = normalize_host(&host); + + if let Some(reason) = policy_denial(&normalized, port, &state) { + return deny_request( + &mut client, + &state, + normalized, + port, + RequestMethod::Http(method), + reason, + ); + } + + let route = match plan_route(&normalized, port, &state) { + Ok(route) => route, + Err(RouteFailure::Denied(reason)) => { + return deny_request( + &mut client, + &state, + normalized, + port, + RequestMethod::Http(method), + reason, + ); + } + Err(RouteFailure::Error(error)) => { + log::debug!("[http_proxy] routing failed for {method} {normalized}:{port}: {error:#}"); + return Ok(()); + } + }; + + emit( + &state, + ProxyEvent::RequestAttempt { + host: normalized.clone(), + port, + method: RequestMethod::Http(method.clone()), + outcome: RequestOutcome::Allowed, + }, + ); + + let (mut upstream, upstream_leftover) = match open_route(&route, &normalized, port) { + Ok(opened) => opened, + Err(error) => { + log::debug!( + "[http_proxy] upstream open failed for {method} {normalized}:{port}: {error:#}" + ); + return Ok(()); + } + }; + + if let Err(error) = upstream.set_nodelay(true) { + log::debug!("[http_proxy] failed to set TCP_NODELAY on upstream socket: {error}"); + } + + if !upstream_leftover.is_empty() { + client.write_all(&upstream_leftover)?; + } + upstream.write_all(&request_bytes)?; + if !leftover_body.is_empty() { + upstream.write_all(&leftover_body)?; + } + + let started = Instant::now(); + let (pumped_to_remote, pumped_from_remote) = pump_bidir(client, upstream); + let to_remote = pumped_to_remote + request_bytes.len() as u64 + leftover_body.len() as u64; + + emit( + &state, + ProxyEvent::RequestCompleted { + host: normalized, + port, + method: RequestMethod::Http(method), + bytes_to_remote: to_remote, + bytes_from_remote: pumped_from_remote + upstream_leftover.len() as u64, + duration_ms: started.elapsed().as_millis() as u64, + }, + ); + + Ok(()) +} + +/// Open the connection that will carry this request's bytes to the origin — +/// a direct TCP connection to a vetted address, or a CONNECT tunnel through +/// the upstream proxy. Returns the stream plus any bytes the upstream sent +/// past its CONNECT response (rare; replayed to the client by the caller). +/// +/// HTTP forward also goes through a CONNECT tunnel when chaining: handing +/// the upstream a routable absolute-form byte stream would let later +/// keep-alive requests on this connection name a different (unapproved) +/// host and have the upstream route them there. A tunnel pins the whole +/// connection to the approved `host:port` — and shares the upstream auth +/// handshake with the CONNECT path. +fn open_route(route: &Route, host: &str, port: u16) -> Result<(TcpStream, Vec)> { + match route { + Route::Direct(addrs) => Ok((connect_to_any(addrs, host, port)?, Vec::new())), + Route::ViaUpstream(upstream) => connect_via_upstream(host, port, upstream), + } +} + +/// Connect to the first address that accepts, with a per-attempt timeout. +fn connect_to_any(addrs: &[SocketAddr], host: &str, port: u16) -> Result { + let mut last_error = None; + for addr in addrs { + match TcpStream::connect_timeout(addr, CONNECT_TIMEOUT) { + Ok(stream) => return Ok(stream), + Err(error) => last_error = Some(error), + } + } + match last_error { + Some(error) => Err(anyhow!("connect to {host}:{port}: {error}")), + None => Err(anyhow!("no addresses to connect to for {host}:{port}")), + } +} + +/// Open a TCP connection to the upstream proxy and complete a CONNECT +/// handshake to (host, port). Returns once the upstream has confirmed `200`, +/// along with any bytes it sent past its response headers. +fn connect_via_upstream( + host: &str, + port: u16, + upstream: &UpstreamProxy, +) -> Result<(TcpStream, Vec)> { + let addrs: Vec = (upstream.host.as_str(), upstream.port) + .to_socket_addrs() + .with_context(|| format!("resolving upstream proxy {upstream}"))? + .collect(); + let mut stream = connect_to_any(&addrs, &upstream.host, upstream.port) + .with_context(|| format!("connect to upstream proxy {upstream}"))?; + + if let Err(error) = stream.set_read_timeout(Some(UPSTREAM_HANDSHAKE_TIMEOUT)) { + log::debug!("[http_proxy] failed to set upstream handshake timeout: {error}"); + } + + let auth_header = upstream.auth.as_ref().map(|auth| { + let creds = format!("{}:{}", auth.user, auth.password); + format!( + "Proxy-Authorization: Basic {}\r\n", + base64::engine::general_purpose::STANDARD.encode(creds.as_bytes()) + ) + }); + + let request = format!( + "CONNECT {host}:{port} HTTP/1.1\r\n\ + Host: {host}:{port}\r\n\ + {}\ + \r\n", + auth_header.unwrap_or_default() + ); + stream.write_all(request.as_bytes())?; + + // Read upstream's response status line + headers (up to \r\n\r\n). + let mut buf = Vec::with_capacity(512); + let mut tmp = [0u8; 512]; + let header_end = loop { + let n = stream.read(&mut tmp)?; + if n == 0 { + bail!("upstream closed before responding to CONNECT"); + } + buf.extend_from_slice(&tmp[..n]); + if let Some(end) = find_double_crlf(&buf) { + break end; + } + if buf.len() > MAX_HEADER_BYTES { + bail!("upstream CONNECT response headers exceed {MAX_HEADER_BYTES} bytes"); + } + }; + + let mut header_storage = [httparse::EMPTY_HEADER; 16]; + let mut response = httparse::Response::new(&mut header_storage); + response + .parse(&buf[..header_end]) + .context("malformed upstream CONNECT response")?; + let status = response + .code + .ok_or_else(|| anyhow!("upstream CONNECT response missing status code"))?; + if status != 200 { + let reason = response.reason.unwrap_or(""); + bail!("upstream CONNECT refused: HTTP {status} {reason}"); + } + + if let Err(error) = stream.set_read_timeout(None) { + log::debug!("[http_proxy] failed to clear upstream handshake timeout: {error}"); + } + + // Bytes past the response headers already belong to the tunnel (an + // origin could in principle speak first); hand them back for replay. + Ok((stream, buf[header_end..].to_vec())) +} + +/// Send a 511 response with `Via` and `Proxy-Status` headers and an +/// explanatory body. Closes the connection afterwards. +fn deny_request( + client: &mut TcpStream, + state: &RuntimeState, + host: String, + port: u16, + method: RequestMethod, + reason: DenyReason, +) -> Result<()> { + emit( + state, + ProxyEvent::RequestAttempt { + host, + port, + method, + outcome: RequestOutcome::Denied { + reason: reason.clone(), + }, + }, + ); + + let body = format!( + "Request blocked by the Zed sandbox network policy.\n\n \ + Reason: {}\n\n \ + This is not a network or server failure — it's a policy decision.\n \ + To proceed, ask the user to approve the host on the next terminal call.\n", + reason.human_explanation() + ); + let response = format!( + "HTTP/1.1 511 Network Authentication Required\r\n\ + Via: 1.1 zed-sandbox-proxy\r\n\ + Proxy-Status: zed-sandbox-proxy; error={}; details=\"{}\"\r\n\ + Content-Type: text/plain; charset=utf-8\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\r\n{body}", + reason.proxy_status_error(), + proxy_status_details(&reason), + body.len(), + ); + client.write_all(response.as_bytes())?; + Ok(()) +} + +fn proxy_status_details(reason: &DenyReason) -> String { + reason + .human_explanation() + .replace(['\r', '\n'], " ") + .replace('"', "'") +} + +fn emit(state: &RuntimeState, event: ProxyEvent) { + // Unbounded send is sync and never blocks. Only fails if the receiver + // has been dropped, which we silently ignore — events are diagnostic, + // not load-bearing. + let _ = state.events.unbounded_send(event); +} + +/// Bidirectional byte pump. +/// +/// The client→remote direction runs on a spawned thread; remote→client runs +/// on the current (connection) thread, halving the thread count per +/// connection. Each direction reads from one socket and writes to the other +/// in a tight loop with a fixed-size buffer. When one direction reaches EOF +/// (peer closed write-half), the receiving side shuts down the other side's +/// write-half so the partner eventually sees EOF too. This mirrors what +/// `tokio::io::copy_bidirectional` does, with explicit thread join. +/// +/// Returns `(client→remote bytes, remote→client bytes)`. Errors are +/// swallowed — partial transfer is fine, the caller just emits whatever +/// totals we got. +fn pump_bidir(client: TcpStream, upstream: TcpStream) -> (u64, u64) { + // Two clones per direction so each side owns the half it touches. + // `try_clone` dups the underlying fd, so reads/writes on the two + // halves don't contend for the same kernel state. + let client_read = match client.try_clone() { + Ok(s) => s, + Err(e) => { + log::debug!("[http_proxy] failed to clone client socket for bidir pump: {e}"); + return (0, 0); + } + }; + let upstream_read = match upstream.try_clone() { + Ok(s) => s, + Err(e) => { + log::debug!("[http_proxy] failed to clone upstream socket for bidir pump: {e}"); + return (0, 0); + } + }; + let client_write = client; + let upstream_write = upstream; + + let to_remote_handle = match thread::Builder::new() + .name("http-proxy-pump-out".to_string()) + .stack_size(128 * 1024) + .spawn(move || copy_one_way(client_read, upstream_write)) + { + Ok(handle) => handle, + Err(error) => { + // Returning drops all stream handles, closing both sockets. + log::warn!("[http_proxy] failed to spawn pump thread: {error}"); + return (0, 0); + } + }; + let from_remote = copy_one_way(upstream_read, client_write); + let to_remote = to_remote_handle.join().unwrap_or_else(|_| { + log::warn!("[http_proxy] pump thread panicked"); + 0 + }); + (to_remote, from_remote) +} + +/// Copy bytes from `from` to `to` until EOF on the read side or write +/// failure. Half-closes `to` for writes when done so the partner sees EOF. +fn copy_one_way(mut from: TcpStream, mut to: TcpStream) -> u64 { + let mut total = 0u64; + let mut buf = vec![0u8; PUMP_BUFFER_SIZE]; + loop { + let n = match from.read(&mut buf) { + Ok(0) => break, + Ok(n) => n, + Err(_) => break, + }; + if to.write_all(&buf[..n]).is_err() { + break; + } + total += n as u64; + } + // Half-close the write side. Mirrors what tokio's copy_bidirectional + // does on EOF: the partner's read returns EOF eventually, completing + // the other direction. + let _ = to.shutdown(Shutdown::Write); + total +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::allowlist::{Allowlist, HostPattern}; + use std::sync::atomic::AtomicUsize; + + #[test] + fn plan_route_denies_host_resolving_to_loopback() { + // `localhost` resolves to loopback without real DNS, standing in for + // any allowlisted hostname whose DNS points at the local machine + // (DNS rebinding). + let state = runtime_state(Allowlist::from_patterns([ + HostPattern::parse("github.com").unwrap() + ])); + match plan_route("localhost", 80, &state) { + Err(RouteFailure::Denied(DenyReason::ResolvedToForbiddenIp { host })) => { + assert_eq!(host, "localhost"); + } + Ok(_) => panic!("expected denial, got a route"), + Err(RouteFailure::Denied(reason)) => panic!("unexpected deny reason: {reason:?}"), + Err(RouteFailure::Error(error)) => panic!("expected denial, got error: {error}"), + } + } + + #[test] + fn plan_route_allows_loopback_when_allowlist_allows_any() { + let state = runtime_state(Allowlist::any()); + match plan_route("localhost", 80, &state) { + Ok(Route::Direct(addrs)) => { + assert!(!addrs.is_empty()); + assert!(addrs.iter().all(|addr| addr.ip().is_loopback())); + } + Ok(Route::ViaUpstream(_)) => panic!("expected direct route"), + Err(RouteFailure::Denied(reason)) => panic!("unexpected denial: {reason:?}"), + Err(RouteFailure::Error(error)) => panic!("unexpected error: {error}"), + } + } + + fn runtime_state(allowlist: Allowlist) -> RuntimeState { + let (events, _receiver) = futures::channel::mpsc::unbounded(); + RuntimeState { + allowlist, + upstream: None, + events, + active_connections: AtomicUsize::new(0), + } + } + + #[test] + fn parse_authority_form_basic() { + let (h, p) = parse_authority_form("github.com:443").unwrap(); + assert_eq!(h, "github.com"); + assert_eq!(p, 443); + } + + #[test] + fn parse_authority_form_ipv6() { + let (h, p) = parse_authority_form("[::1]:443").unwrap(); + assert_eq!(h, "[::1]"); + assert_eq!(p, 443); + } + + #[test] + fn parse_authority_form_accepts_scheme_default_ports() { + // Regression test: `Url::parse` elides scheme-default ports, so a + // naive URL round-trip would reject `host:80` as "missing a port". + let (h, p) = parse_authority_form("example.com:80").unwrap(); + assert_eq!(h, "example.com"); + assert_eq!(p, 80); + } + + #[test] + fn parse_authority_form_requires_port() { + assert!(parse_authority_form("github.com").is_err()); + assert!(parse_authority_form("[::1]").is_err()); + } + + #[test] + fn parse_absolute_form_basic() { + let (h, p, _) = parse_absolute_form_target("http://example.com/path").unwrap(); + assert_eq!(h, "example.com"); + assert_eq!(p, 80); + } + + #[test] + fn parse_absolute_form_with_port() { + let (h, p, _) = parse_absolute_form_target("http://example.com:8080/").unwrap(); + assert_eq!(h, "example.com"); + assert_eq!(p, 8080); + } + + #[test] + fn host_header_default_port() { + let (h, p) = parse_host_header("example.com").unwrap(); + assert_eq!(h, "example.com"); + assert_eq!(p, 80); + } + + #[test] + fn host_header_explicit_port() { + let (h, p) = parse_host_header("example.com:8080").unwrap(); + assert_eq!(h, "example.com"); + assert_eq!(p, 8080); + } + + #[test] + fn host_header_ipv6() { + let (h, p) = parse_host_header("[::1]:443").unwrap(); + assert_eq!(h, "[::1]"); + assert_eq!(p, 443); + } + + #[test] + fn detects_ip_literals() { + assert!(is_ip_literal("1.2.3.4")); + assert!(is_ip_literal("[::1]")); + assert!(is_ip_literal("::1")); + assert!(!is_ip_literal("github.com")); + assert!(!is_ip_literal("localhost")); + } + + #[test] + fn forbidden_ips_cover_local_space() { + for forbidden in [ + "127.0.0.1", + "10.1.2.3", + "172.16.0.1", + "192.168.1.1", + "169.254.169.254", + "100.100.1.1", + "0.0.0.0", + "::1", + "::", + "fe80::1", + "fd00::1", + "::ffff:127.0.0.1", + "::ffff:10.0.0.1", + ] { + assert!( + is_forbidden_ip(forbidden.parse().unwrap()), + "{forbidden} should be forbidden" + ); + } + for public in ["140.82.112.3", "8.8.8.8", "2606:4700::6810:84e5"] { + assert!( + !is_forbidden_ip(public.parse().unwrap()), + "{public} should be allowed" + ); + } + } + + #[test] + fn parsed_request_recognizes_connect() { + let req = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"; + match ParsedRequest::parse(req).unwrap() { + ParsedRequest::Connect { host, port } => { + assert_eq!(host, "example.com"); + assert_eq!(port, 443); + } + ParsedRequest::Http { .. } => panic!("expected Connect"), + } + } + + #[test] + fn parsed_request_recognizes_http_absolute_form() { + let req = b"GET http://example.com/foo HTTP/1.1\r\nHost: example.com\r\n\r\n"; + match ParsedRequest::parse(req).unwrap() { + ParsedRequest::Http { + method, host, port, .. + } => { + assert_eq!(method, "GET"); + assert_eq!(host, "example.com"); + assert_eq!(port, 80); + } + ParsedRequest::Connect { .. } => panic!("expected Http"), + } + } + + #[test] + fn absolute_form_is_rewritten_to_origin_form() { + let req = b"GET http://example.com/foo?q=1 HTTP/1.1\r\n\ + Host: wrong.example\r\n\ + Proxy-Connection: keep-alive\r\n\ + Proxy-Authorization: Basic c2VjcmV0\r\n\ + User-Agent: test\r\n\r\n"; + match ParsedRequest::parse(req).unwrap() { + ParsedRequest::Http { request_bytes, .. } => { + let text = String::from_utf8(request_bytes).unwrap(); + assert!(text.starts_with("GET /foo?q=1 HTTP/1.1\r\n"), "{text}"); + // Host is regenerated from the URI, not trusted from the + // (mismatched) header. + assert!(text.contains("Host: example.com\r\n"), "{text}"); + assert!(!text.contains("wrong.example"), "{text}"); + // Proxy-* headers are addressed to us (and may carry + // credentials); they must not leak to the origin. + assert!(!text.to_ascii_lowercase().contains("proxy-"), "{text}"); + assert!(text.contains("User-Agent: test\r\n"), "{text}"); + assert!(text.ends_with("\r\n\r\n"), "{text}"); + } + ParsedRequest::Connect { .. } => panic!("expected Http"), + } + } + + #[test] + fn absolute_form_rewrite_keeps_non_default_port_in_host_header() { + let req = b"GET http://example.com:8080/ HTTP/1.1\r\nHost: example.com:8080\r\n\r\n"; + match ParsedRequest::parse(req).unwrap() { + ParsedRequest::Http { request_bytes, .. } => { + let text = String::from_utf8(request_bytes).unwrap(); + assert!(text.starts_with("GET / HTTP/1.1\r\n"), "{text}"); + assert!(text.contains("Host: example.com:8080\r\n"), "{text}"); + } + ParsedRequest::Connect { .. } => panic!("expected Http"), + } + } + + #[test] + fn parsed_request_recognizes_http_origin_form_via_host_header() { + let req = b"GET /foo HTTP/1.1\r\nHost: example.com:8080\r\n\r\n"; + match ParsedRequest::parse(req).unwrap() { + ParsedRequest::Http { + host, + port, + request_bytes, + .. + } => { + assert_eq!(host, "example.com"); + assert_eq!(port, 8080); + // Origin-form bytes pass through verbatim. + assert_eq!(request_bytes, req.to_vec()); + } + ParsedRequest::Connect { .. } => panic!("expected Http"), + } + } +} diff --git a/crates/http_proxy/tests/end_to_end.rs b/crates/http_proxy/tests/end_to_end.rs new file mode 100644 index 00000000000000..a350324e4eea15 --- /dev/null +++ b/crates/http_proxy/tests/end_to_end.rs @@ -0,0 +1,535 @@ +//! End-to-end tests for the proxy crate. +//! +//! Each test spawns a real proxy on `127.0.0.1:0` and makes real TCP +//! connections to it, optionally also spawning a tiny stub origin server +//! (or stub upstream proxy) to act as the destination. Everything is sync — +//! std::net + threads + std::time::Duration timeouts. + +use futures::channel::mpsc; +use futures::stream::StreamExt; +use http_proxy::{ + Allowlist, DenyReason, HostPattern, ProxyConfig, ProxyEvent, ProxyHandle, RequestMethod, + RequestOutcome, UpstreamProxy, +}; +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::thread; +use std::time::Duration; + +const TEST_TIMEOUT: Duration = Duration::from_secs(5); + +/// Spin up a tiny TCP server that serves one connection: it reads the +/// client's first request (until `\r\n\r\n`), echoes a fixed HTTP +/// response, and returns the request bytes it saw. +fn spawn_echo_origin(response: &'static [u8]) -> (SocketAddr, thread::JoinHandle>) { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let join = thread::spawn(move || { + let (mut sock, _) = listener.accept().unwrap(); + sock.set_read_timeout(Some(TEST_TIMEOUT)).unwrap(); + let buf = read_until_double_crlf(&mut sock); + sock.write_all(response).unwrap(); + sock.shutdown(std::net::Shutdown::Write).unwrap(); + buf + }); + (addr, join) +} + +/// Spin up a stub upstream HTTP proxy that serves one connection: it reads +/// a CONNECT request, replies `200`, then acts like the requested origin — +/// reading one more request through the "tunnel" and echoing a fixed +/// response. Returns the CONNECT headers and the tunneled request bytes. +fn spawn_stub_upstream_proxy( + tunnel_response: &'static [u8], +) -> (SocketAddr, thread::JoinHandle<(Vec, Vec)>) { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let join = thread::spawn(move || { + let (mut sock, _) = listener.accept().unwrap(); + sock.set_read_timeout(Some(TEST_TIMEOUT)).unwrap(); + let connect_request = read_until_double_crlf(&mut sock); + assert!( + connect_request.starts_with(b"CONNECT "), + "expected CONNECT, got: {:?}", + String::from_utf8_lossy(&connect_request) + ); + sock.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .unwrap(); + let tunneled_request = read_until_double_crlf(&mut sock); + sock.write_all(tunnel_response).unwrap(); + sock.shutdown(std::net::Shutdown::Write).unwrap(); + (connect_request, tunneled_request) + }); + (addr, join) +} + +fn read_until_double_crlf(sock: &mut TcpStream) -> Vec { + let mut buf = Vec::with_capacity(4096); + let mut tmp = [0u8; 4096]; + loop { + let n = sock.read(&mut tmp).unwrap_or(0); + if n == 0 { + break; + } + buf.extend_from_slice(&tmp[..n]); + if buf.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + buf +} + +fn spawn_proxy(allowlist: Allowlist) -> (ProxyHandle, mpsc::UnboundedReceiver) { + spawn_proxy_with_upstream(allowlist, None) +} + +fn spawn_proxy_with_upstream( + allowlist: Allowlist, + upstream: Option, +) -> (ProxyHandle, mpsc::UnboundedReceiver) { + let (events_tx, mut events_rx) = mpsc::unbounded(); + let proxy = ProxyHandle::spawn(ProxyConfig { + allowlist, + upstream, + events: events_tx, + }) + .expect("proxy spawn"); + + // Drain the Ready event so callers see RequestAttempt as the first. + let ready = futures::executor::block_on(events_rx.next()); + match ready { + Some(ProxyEvent::Ready { port }) => assert_eq!(port, proxy.port()), + other => panic!("expected Ready event first, got {other:?}"), + } + (proxy, events_rx) +} + +fn next_event(events: &mut mpsc::UnboundedReceiver) -> ProxyEvent { + futures::executor::block_on(events.next()).expect("events channel closed") +} + +#[test] +fn connect_allowed_host_completes_tunnel_and_emits_events() { + let (origin_addr, origin_join) = spawn_echo_origin( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello", + ); + + // `localhost` isn't a permitted allowlist *pattern*, but + // `Allowlist::any()` skips all policy checks (including the + // forbidden-resolved-IP filter), which is what lets this test reach a + // loopback origin. Denied paths are exercised in separate tests. + let (proxy, mut events) = spawn_proxy(Allowlist::any()); + let target = format!("localhost:{}", origin_addr.port()); + + let mut client = TcpStream::connect((Ipv4Addr::LOCALHOST, proxy.port())).unwrap(); + client.set_read_timeout(Some(TEST_TIMEOUT)).unwrap(); + client.set_write_timeout(Some(TEST_TIMEOUT)).unwrap(); + + let req = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n"); + client.write_all(req.as_bytes()).unwrap(); + + // Read the proxy's CONNECT response headers. + let response = read_until_double_crlf(&mut client); + let resp_text = String::from_utf8_lossy(&response); + assert!( + resp_text.starts_with("HTTP/1.1 200"), + "expected 200 from proxy, got: {resp_text}" + ); + + // Send tunnel payload. + client.write_all(b"hi origin\r\n\r\n").unwrap(); + client.shutdown(std::net::Shutdown::Write).unwrap(); + let _ = client.read_to_end(&mut Vec::new()); + + let origin_received = origin_join.join().unwrap(); + assert!( + String::from_utf8_lossy(&origin_received).contains("hi origin"), + "origin saw: {:?}", + String::from_utf8_lossy(&origin_received) + ); + + match next_event(&mut events) { + ProxyEvent::RequestAttempt { + host, + port, + method, + outcome, + } => { + assert_eq!(host, "localhost"); + assert_eq!(port, origin_addr.port()); + assert_eq!(method, RequestMethod::Connect); + assert!(matches!(outcome, RequestOutcome::Allowed)); + } + other => panic!("expected RequestAttempt, got {other:?}"), + } + match next_event(&mut events) { + ProxyEvent::RequestCompleted { host, .. } => { + assert_eq!(host, "localhost"); + } + other => panic!("expected RequestCompleted, got {other:?}"), + } +} + +#[test] +fn connect_denied_host_returns_511_with_via_header() { + let allowlist = Allowlist::from_patterns([HostPattern::parse("github.com").unwrap()]); + let (proxy, mut events) = spawn_proxy(allowlist); + + let mut client = TcpStream::connect((Ipv4Addr::LOCALHOST, proxy.port())).unwrap(); + client.set_read_timeout(Some(TEST_TIMEOUT)).unwrap(); + client + .write_all(b"CONNECT denied.example:443 HTTP/1.1\r\nHost: denied.example:443\r\n\r\n") + .unwrap(); + + let mut response = String::new(); + client.read_to_string(&mut response).unwrap(); + assert!( + response.starts_with("HTTP/1.1 511 "), + "expected 511, got: {response}" + ); + assert!(response.contains("Via: 1.1 zed-sandbox-proxy")); + assert!(response.contains("Proxy-Status: zed-sandbox-proxy")); + assert!(response.contains("denied.example")); + assert!(response.contains("not in this conversation's network allowlist")); + + match next_event(&mut events) { + ProxyEvent::RequestAttempt { + host, + port, + method, + outcome, + } => { + assert_eq!(host, "denied.example"); + assert_eq!(port, 443); + assert_eq!(method, RequestMethod::Connect); + assert!( + matches!( + outcome, + RequestOutcome::Denied { + reason: DenyReason::HostNotInAllowlist { .. } + } + ), + "outcome was {outcome:?}" + ); + } + other => panic!("expected RequestAttempt(Denied), got {other:?}"), + } +} + +#[test] +fn http_forward_denied_host_returns_511() { + let allowlist = Allowlist::from_patterns([HostPattern::parse("github.com").unwrap()]); + let (proxy, mut events) = spawn_proxy(allowlist); + + let mut client = TcpStream::connect((Ipv4Addr::LOCALHOST, proxy.port())).unwrap(); + client.set_read_timeout(Some(TEST_TIMEOUT)).unwrap(); + client + .write_all(b"GET http://denied.example/ HTTP/1.1\r\nHost: denied.example\r\n\r\n") + .unwrap(); + + let mut response = String::new(); + client.read_to_string(&mut response).unwrap(); + assert!( + response.starts_with("HTTP/1.1 511 "), + "expected 511, got: {response}" + ); + assert!(response.contains("not in this conversation's network allowlist")); + + match next_event(&mut events) { + ProxyEvent::RequestAttempt { + host, + method: RequestMethod::Http(method), + outcome: + RequestOutcome::Denied { + reason: DenyReason::HostNotInAllowlist { .. }, + }, + .. + } => { + assert_eq!(host, "denied.example"); + assert_eq!(method, "GET"); + } + other => panic!("expected RequestAttempt(Denied), got {other:?}"), + } +} + +#[test] +fn ip_literal_connect_is_denied_for_pattern_allowlists() { + let allowlist = Allowlist::from_patterns([HostPattern::parse("github.com").unwrap()]); + let (proxy, mut events) = spawn_proxy(allowlist); + + let mut client = TcpStream::connect((Ipv4Addr::LOCALHOST, proxy.port())).unwrap(); + client.set_read_timeout(Some(TEST_TIMEOUT)).unwrap(); + client + .write_all(b"CONNECT 1.2.3.4:443 HTTP/1.1\r\nHost: 1.2.3.4:443\r\n\r\n") + .unwrap(); + + let mut response = String::new(); + client.read_to_string(&mut response).unwrap(); + assert!(response.starts_with("HTTP/1.1 511 ")); + assert!(response.contains("IP literal")); + + match next_event(&mut events) { + ProxyEvent::RequestAttempt { + outcome: + RequestOutcome::Denied { + reason: DenyReason::IpLiteralRejected { target }, + }, + .. + } => { + assert_eq!(target, "1.2.3.4:443"); + } + other => panic!("expected IpLiteralRejected, got {other:?}"), + } +} + +#[test] +fn ip_literal_connect_is_allowed_when_allowlist_allows_any() { + // `allow_all_hosts` matches the pre-allowlist `allow_network: true` + // semantics: unrestricted egress, IP literals included. + let (origin_addr, origin_join) = spawn_echo_origin(b"HTTP/1.1 200 OK\r\n\r\n"); + let (proxy, mut events) = spawn_proxy(Allowlist::any()); + + let mut client = TcpStream::connect((Ipv4Addr::LOCALHOST, proxy.port())).unwrap(); + client.set_read_timeout(Some(TEST_TIMEOUT)).unwrap(); + let target = format!("127.0.0.1:{}", origin_addr.port()); + let req = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n"); + client.write_all(req.as_bytes()).unwrap(); + + let response = read_until_double_crlf(&mut client); + let resp_text = String::from_utf8_lossy(&response); + assert!( + resp_text.starts_with("HTTP/1.1 200"), + "expected 200 from proxy, got: {resp_text}" + ); + + client.write_all(b"ping\r\n\r\n").unwrap(); + client.shutdown(std::net::Shutdown::Write).unwrap(); + let _ = client.read_to_end(&mut Vec::new()); + origin_join.join().unwrap(); + + match next_event(&mut events) { + ProxyEvent::RequestAttempt { host, outcome, .. } => { + assert_eq!(host, "127.0.0.1"); + assert!(matches!(outcome, RequestOutcome::Allowed)); + } + other => panic!("expected RequestAttempt(Allowed), got {other:?}"), + } +} + +#[test] +fn host_resolving_to_loopback_is_denied_for_pattern_allowlists() { + // DNS-rebinding protection end-to-end: even when a hostname slips past + // the allowlist (here `localhost`, which patterns can't express but the + // wire can carry), the proxy must deny anything that resolves into the + // local machine rather than connect. The denial surfaces at the + // allowlist layer for this probe; the resolved-IP layer behind it is + // unit-tested in `connection.rs` (`plan_route` tests), since + // exercising it end-to-end would require an allowlisted hostname whose + // real DNS points at loopback. + let allowlist = Allowlist::from_patterns([HostPattern::parse("github.com").unwrap()]); + let (proxy, _events) = spawn_proxy(allowlist); + + let mut client = TcpStream::connect((Ipv4Addr::LOCALHOST, proxy.port())).unwrap(); + client.set_read_timeout(Some(TEST_TIMEOUT)).unwrap(); + client + .write_all(b"CONNECT localhost:80 HTTP/1.1\r\nHost: localhost:80\r\n\r\n") + .unwrap(); + + let mut response = String::new(); + client.read_to_string(&mut response).unwrap(); + assert!( + response.starts_with("HTTP/1.1 511 "), + "expected 511, got: {response}" + ); +} + +#[test] +fn http_forward_allowed_rewrites_to_origin_form() { + let (origin_addr, origin_join) = spawn_echo_origin( + b"HTTP/1.1 200 OK\r\nContent-Length: 11\r\nConnection: close\r\n\r\nhello world", + ); + + let (proxy, mut events) = spawn_proxy(Allowlist::any()); + + let mut client = TcpStream::connect((Ipv4Addr::LOCALHOST, proxy.port())).unwrap(); + client.set_read_timeout(Some(TEST_TIMEOUT)).unwrap(); + let req = format!( + "GET http://localhost:{}/foo HTTP/1.1\r\nHost: localhost:{}\r\n\r\n", + origin_addr.port(), + origin_addr.port() + ); + client.write_all(req.as_bytes()).unwrap(); + + let mut response = Vec::new(); + client.read_to_end(&mut response).unwrap(); + let response_str = String::from_utf8_lossy(&response); + assert!(response_str.contains("HTTP/1.1 200")); + assert!(response_str.contains("hello world")); + // Mirror what real HTTP clients do after a `Connection: close` + // response: close the socket so the proxy's bidir-pump finishes the + // client→upstream half. (Threads handle this fine — when the client's + // socket goes out of scope the pump's read returns EOF.) + drop(client); + + let origin_saw = origin_join.join().unwrap(); + let origin_saw_str = String::from_utf8_lossy(&origin_saw); + // The absolute-form proxy request must reach the origin rewritten to + // origin-form — many real servers don't accept absolute-form. + assert!( + origin_saw_str.starts_with("GET /foo HTTP/1.1\r\n"), + "origin saw: {origin_saw_str}" + ); + assert!( + origin_saw_str.contains(&format!("Host: localhost:{}\r\n", origin_addr.port())), + "origin saw: {origin_saw_str}" + ); + + match next_event(&mut events) { + ProxyEvent::RequestAttempt { + method: RequestMethod::Http(m), + outcome: RequestOutcome::Allowed, + .. + } => { + assert_eq!(m, "GET"); + } + other => panic!("expected RequestAttempt(Allowed), got {other:?}"), + } + match next_event(&mut events) { + ProxyEvent::RequestCompleted { .. } => {} + other => panic!("expected RequestCompleted, got {other:?}"), + } +} + +#[test] +fn connect_chains_through_upstream_proxy_with_auth() { + let (upstream_addr, upstream_join) = spawn_stub_upstream_proxy(b"tunnel says hi"); + let upstream = UpstreamProxy::parse( + Some(&format!( + "http://alice:s3cret@127.0.0.1:{}", + upstream_addr.port() + )), + // The stub upstream is on loopback, which `proxyvars` implicitly + // bypasses; the destination decides bypassing, and `proxied.example` + // isn't local, so the chain is used. + None, + ) + .unwrap() + .unwrap(); + + let allowlist = Allowlist::from_patterns([HostPattern::parse("proxied.example").unwrap()]); + let (proxy, mut events) = spawn_proxy_with_upstream(allowlist, Some(upstream)); + + let mut client = TcpStream::connect((Ipv4Addr::LOCALHOST, proxy.port())).unwrap(); + client.set_read_timeout(Some(TEST_TIMEOUT)).unwrap(); + client + .write_all(b"CONNECT proxied.example:443 HTTP/1.1\r\nHost: proxied.example:443\r\n\r\n") + .unwrap(); + + let response = read_until_double_crlf(&mut client); + let resp_text = String::from_utf8_lossy(&response); + assert!( + resp_text.starts_with("HTTP/1.1 200"), + "expected 200 from proxy, got: {resp_text}" + ); + + client.write_all(b"client tunnel bytes\r\n\r\n").unwrap(); + client.shutdown(std::net::Shutdown::Write).unwrap(); + let mut tunneled_back = Vec::new(); + let _ = client.read_to_end(&mut tunneled_back); + assert_eq!(tunneled_back, b"tunnel says hi"); + + let (connect_request, tunneled_request) = upstream_join.join().unwrap(); + let connect_text = String::from_utf8_lossy(&connect_request); + assert!( + connect_text.starts_with("CONNECT proxied.example:443 HTTP/1.1\r\n"), + "upstream saw: {connect_text}" + ); + // Basic auth from the upstream URL is injected on the CONNECT. + assert!( + connect_text.contains("Proxy-Authorization: Basic YWxpY2U6czNjcmV0\r\n"), + "upstream saw: {connect_text}" + ); + assert!( + String::from_utf8_lossy(&tunneled_request).contains("client tunnel bytes"), + "tunneled: {:?}", + String::from_utf8_lossy(&tunneled_request) + ); + + match next_event(&mut events) { + ProxyEvent::RequestAttempt { + host, + outcome: RequestOutcome::Allowed, + .. + } => assert_eq!(host, "proxied.example"), + other => panic!("expected RequestAttempt(Allowed), got {other:?}"), + } +} + +#[test] +fn http_forward_chains_through_upstream_via_connect_tunnel() { + // Plain HTTP through the upstream must also use a CONNECT tunnel pinned + // to the approved host. Handing the upstream a routable absolute-form + // stream would let keep-alive requests after the first reach hosts the + // allowlist never approved. + let (upstream_addr, upstream_join) = + spawn_stub_upstream_proxy(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"); + let upstream = UpstreamProxy::parse( + Some(&format!("http://127.0.0.1:{}", upstream_addr.port())), + None, + ) + .unwrap() + .unwrap(); + + let allowlist = Allowlist::from_patterns([HostPattern::parse("proxied.example").unwrap()]); + let (proxy, _events) = spawn_proxy_with_upstream(allowlist, Some(upstream)); + + let mut client = TcpStream::connect((Ipv4Addr::LOCALHOST, proxy.port())).unwrap(); + client.set_read_timeout(Some(TEST_TIMEOUT)).unwrap(); + client + .write_all(b"GET http://proxied.example/data HTTP/1.1\r\nHost: proxied.example\r\n\r\n") + .unwrap(); + + let mut response = Vec::new(); + client.read_to_end(&mut response).unwrap(); + let response_str = String::from_utf8_lossy(&response); + assert!( + response_str.contains("HTTP/1.1 200") && response_str.contains("ok"), + "client got: {response_str}" + ); + drop(client); + + let (connect_request, tunneled_request) = upstream_join.join().unwrap(); + let connect_text = String::from_utf8_lossy(&connect_request); + assert!( + connect_text.starts_with("CONNECT proxied.example:80 HTTP/1.1\r\n"), + "upstream saw: {connect_text}" + ); + // Inside the tunnel, the origin sees an origin-form request. + let tunneled_text = String::from_utf8_lossy(&tunneled_request); + assert!( + tunneled_text.starts_with("GET /data HTTP/1.1\r\n"), + "tunneled: {tunneled_text}" + ); + assert!( + tunneled_text.contains("Host: proxied.example\r\n"), + "tunneled: {tunneled_text}" + ); +} + +#[test] +fn dropping_handle_stops_listener() { + let (proxy, _events) = spawn_proxy(Allowlist::any()); + let port = proxy.port(); + drop(proxy); + + // After Drop, the listener has been signaled and woken; new + // connections should be refused. + let result = TcpStream::connect_timeout( + &SocketAddr::from((Ipv4Addr::LOCALHOST, port)), + Duration::from_millis(500), + ); + assert!( + result.is_err(), + "listener should have stopped after ProxyHandle drop, but new connection succeeded" + ); +}