//! 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}; #[cfg(unix)] use std::os::unix::net::UnixStream; 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_ready(&proxy, &mut events_rx); (proxy, events_rx) } #[cfg(unix)] fn spawn_unix_proxy(allowlist: Allowlist) -> (ProxyHandle, mpsc::UnboundedReceiver) { let (events_tx, mut events_rx) = mpsc::unbounded(); let proxy = ProxyHandle::spawn_unix_temp(ProxyConfig { allowlist, upstream: None, events: events_tx, }) .expect("proxy spawn"); drain_ready(&proxy, &mut events_rx); (proxy, events_rx) } fn drain_ready(proxy: &ProxyHandle, events_rx: &mut mpsc::UnboundedReceiver) { 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:?}"), } } 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:?}"), } } #[cfg(unix)] #[test] fn unix_listener_denied_host_returns_511() { let allowlist = Allowlist::from_patterns([HostPattern::parse("github.com").unwrap()]); let (proxy, mut events) = spawn_unix_proxy(allowlist); let socket_path = proxy .socket_path() .expect("Unix proxy socket path") .to_path_buf(); let mut client = UnixStream::connect(socket_path).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("denied.example")); 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 { .. })); } other => panic!("expected RequestAttempt(Denied), 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() { // `Allowlist::any()` preserves unrestricted proxy semantics, 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" ); }