diff --git a/Cargo.lock b/Cargo.lock index d8dbd4f8..007ef279 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1005,6 +1005,7 @@ dependencies = [ "assert_cmd", "async-trait", "atty", + "base64", "bytes", "camino", "chrono", @@ -1020,6 +1021,7 @@ dependencies = [ "hyper-util", "libc", "lru", + "percent-encoding", "pprof", "predicates", "rand", @@ -1034,6 +1036,7 @@ dependencies = [ "tls-parser", "tokio", "tokio-rustls", + "tower-service", "tracing", "tracing-subscriber", "url", diff --git a/Cargo.toml b/Cargo.toml index b04dfbbc..b2ffec42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,15 +27,18 @@ webpki-roots = "0.26" lru = "0.12" rand = "0.8" anyhow = "1.0" +base64 = "0.22" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono"] } chrono = "0.4" dirs = "6.0.0" hyper-rustls = "0.27.7" +tower-service = "0.3" tls-parser = "0.12.2" camino = "1.1.11" filetime = "0.2" ctrlc = "3.4" +percent-encoding = "2.3" url = "2.5" v8 = "129" serde = { version = "1.0", features = ["derive"] } diff --git a/README.md b/README.md index 619c87ec..33b5651f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Or download a pre-built binary from the [releases page](https://github.com/coder - 🌐 **HTTP/HTTPS interception** - Transparent proxy with TLS certificate injection - 🛡️ **DNS exfiltration protection** - Prevents data leakage through DNS queries - 🔧 **Multiple evaluation approaches** - JS expressions or custom programs +- 🏢 **Upstream proxy support** - Chain httpjail's egress through a corporate proxy - 🖥️ **Cross-platform** - Native support for Linux and macOS ## Quick Start @@ -61,8 +62,28 @@ httpjail --server --js "true" # Run Docker containers with network isolation (Linux only) httpjail --js "r.host === 'api.github.com'" --docker-run -- --rm alpine:latest wget -qO- https://api.github.com + +# Route httpjail's own egress through an upstream (corporate) proxy +httpjail --upstream-proxy http://proxy.corp:3128 --js "true" -- curl https://api.github.com +# Credentials and HTTPS proxies are supported: http://user:pass@proxy.corp:3128, https://proxy.corp:8443 +# May also be set via the HTTPJAIL_UPSTREAM_PROXY environment variable ``` +### Upstream (corporate) proxy + +When httpjail itself runs in an environment with no direct internet access, use +`--upstream-proxy ` (or the `HTTPJAIL_UPSTREAM_PROXY` environment variable) +to route httpjail's outbound requests through an upstream proxy. Rule evaluation +still happens locally on the intercepted traffic; only the re-originated request +is forwarded through the proxy. + +- `http://`, `https://` and bare `host:port` (http assumed) forms are accepted. +- Basic authentication is supported via `http://user:pass@host:port`. +- HTTPS destinations are reached via a `CONNECT` tunnel through the proxy, while + plain HTTP destinations are forwarded in absolute-form. +- This is independent of the `HTTP_PROXY`/`HTTPS_PROXY` variables that httpjail + sets *inside* the jail to point sandboxed processes at itself. + ## Documentation Docs are stored in the `docs/` directory and served @@ -82,6 +103,7 @@ Table of Contents: - [TLS Interception](https://coder.github.io/httpjail/advanced/tls-interception.html) - [DNS Exfiltration](https://coder.github.io/httpjail/advanced/dns-exfiltration.html) - [Server Mode](https://coder.github.io/httpjail/advanced/server-mode.html) +- [Upstream Proxy](https://coder.github.io/httpjail/advanced/upstream-proxy.html) ## License diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 92a3d246..86ce2d77 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -20,6 +20,7 @@ - [TLS Interception](./advanced/tls-interception.md) - [DNS Exfiltration](./advanced/dns-exfiltration.md) - [Server Mode](./advanced/server-mode.md) +- [Upstream Proxy](./advanced/upstream-proxy.md) --- diff --git a/docs/advanced/upstream-proxy.md b/docs/advanced/upstream-proxy.md new file mode 100644 index 00000000..35061ff2 --- /dev/null +++ b/docs/advanced/upstream-proxy.md @@ -0,0 +1,61 @@ +# Upstream Proxy + +By default httpjail contacts destination servers directly. When httpjail itself +runs in an environment that has no direct internet access — for example behind a +corporate proxy — you can route httpjail's own outbound requests through an +upstream proxy with `--upstream-proxy` (or the `HTTPJAIL_UPSTREAM_PROXY` +environment variable). + +Rule evaluation still happens locally on the intercepted traffic. Only the +request that httpjail re-originates towards the real destination is forwarded +through the upstream proxy. + +```bash +# Route httpjail's egress through a corporate proxy +httpjail --upstream-proxy http://proxy.corp:3128 --js "true" -- curl https://api.github.com + +# With Basic authentication +httpjail --upstream-proxy http://user:pass@proxy.corp:3128 --js "true" -- ./my-app + +# Through an HTTPS proxy +httpjail --upstream-proxy https://proxy.corp:8443 --js "true" -- ./my-app + +# Via the environment variable (equivalent to --upstream-proxy) +HTTPJAIL_UPSTREAM_PROXY=http://proxy.corp:3128 httpjail --js "true" -- ./my-app +``` + +## Accepted formats + +| Form | Example | Notes | +| --- | --- | --- | +| `http://host:port` | `http://proxy.corp:3128` | Plain HTTP proxy | +| `https://host:port` | `https://proxy.corp:8443` | Connection to the proxy is wrapped in TLS | +| `host:port` | `proxy.corp:3128` | Bare authority, `http` scheme assumed | +| With credentials | `http://user:pass@proxy.corp:3128` | Sends `Proxy-Authorization: Basic ...` | + +The command-line flag takes precedence over the environment variable. Credentials +are never written to the logs. + +## How it works + +- **HTTPS destinations** are reached by issuing a `CONNECT` to the upstream + proxy to obtain a raw TCP tunnel; httpjail then performs the destination TLS + handshake over that tunnel. TLS is validated against Mozilla's webpki roots + plus the httpjail CA, exactly as for a direct connection. +- **Plain HTTP destinations** are forwarded to the proxy in absolute-form, with + the `Proxy-Authorization` header attached when credentials are configured. +- Only connection setup (TCP connect, optional TLS to the proxy, and the + `CONNECT` exchange) is bounded by a timeout. The established tunnel carries no + timeout, so long-running connections such as WebSocket and gRPC keep working. + +## Relationship to `HTTP_PROXY` / `HTTPS_PROXY` + +This feature is independent of the `HTTP_PROXY` and `HTTPS_PROXY` variables that +httpjail sets *inside* the jail to point sandboxed processes at httpjail itself. + +``` +[ jailed process ] --HTTP_PROXY/HTTPS_PROXY--> [ httpjail ] --upstream-proxy--> [ corporate proxy ] --> internet +``` + +The jailed process always talks to httpjail; `--upstream-proxy` only affects the +hop from httpjail to the outside world. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 7338e46b..527d3655 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -88,10 +88,15 @@ These are automatically set in the jailed process: These affect httpjail's behavior: -| Variable | Description | Example | -| ------------------ | -------------------------- | -------------------------------- | -| `RUST_LOG` | Logging level | `debug`, `info`, `warn`, `error` | -| `HTTPJAIL_CA_CERT` | Custom CA certificate path | `/etc/pki/custom-ca.pem` | +| Variable | Description | Example | +| ------------------------ | -------------------------------------- | -------------------------------- | +| `RUST_LOG` | Logging level | `debug`, `info`, `warn`, `error` | +| `HTTPJAIL_CA_CERT` | Custom CA certificate path | `/etc/pki/custom-ca.pem` | +| `HTTPJAIL_UPSTREAM_PROXY`| Upstream proxy for httpjail's egress | `http://proxy.corp:3128` | + +The `--upstream-proxy` command-line flag takes precedence over +`HTTPJAIL_UPSTREAM_PROXY`. See [Upstream Proxy](../advanced/upstream-proxy.md) +for details. ## Platform-Specific Configuration diff --git a/src/lib.rs b/src/lib.rs index 40c97467..764f41d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,5 +9,6 @@ pub mod proxy_tls; pub mod rules; pub mod sys_resource; pub mod tls; +pub mod upstream; pub mod test_utils; diff --git a/src/main.rs b/src/main.rs index 3fc1fb92..6fd6dce0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use httpjail::rules::shell::ShellRuleEngine; use httpjail::rules::v8_js::V8JsRuleEngine; use httpjail::rules::{Action, RuleEngine}; use hyper::Method; +use std::fmt; use std::fs::OpenOptions; use std::os::unix::process::ExitStatusExt; use std::sync::atomic::{AtomicBool, Ordering}; @@ -40,7 +41,7 @@ enum Command { }, } -#[derive(Parser, Debug)] +#[derive(Parser)] struct RunArgs { /// Use shell script for evaluating requests /// The script receives environment variables: @@ -85,6 +86,13 @@ struct RunArgs { #[arg(long = "request-log", value_name = "FILE")] request_log: Option, + /// Route httpjail's own upstream requests through an upstream (corporate) proxy. + /// Accepts http://host:port, https://host:port, or host:port (http assumed), + /// optionally with credentials: http://user:pass@host:port. + /// Falls back to the HTTPJAIL_UPSTREAM_PROXY environment variable. + #[arg(long = "upstream-proxy", value_name = "URL")] + upstream_proxy: Option, + /// Use weak mode (environment variables only, no system isolation) #[arg(long = "weak")] weak: bool, @@ -139,6 +147,32 @@ struct RunArgs { exec_command: Vec, } +impl fmt::Debug for RunArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let upstream_proxy = self + .upstream_proxy + .as_deref() + .map(httpjail::upstream::redact_proxy_spec); + f.debug_struct("RunArgs") + .field("sh", &self.sh) + .field("proc", &self.proc) + .field("js", &self.js) + .field("js_file", &self.js_file) + .field("request_log", &self.request_log) + .field("upstream_proxy", &upstream_proxy) + .field("weak", &self.weak) + .field("verbose", &self.verbose) + .field("timeout", &self.timeout) + .field("no_jail_cleanup", &self.no_jail_cleanup) + .field("cleanup", &self.cleanup) + .field("server", &self.server) + .field("test", &self.test) + .field("docker_run", &self.docker_run) + .field("exec_command", &self.exec_command) + .finish() + } +} + fn setup_logging(verbosity: u8) { use tracing_subscriber::fmt::time::FormatTime; @@ -590,7 +624,27 @@ async fn main() -> Result<()> { } }; - let mut proxy = ProxyServer::new(http_bind, https_bind, rule_engine); + // Resolve the optional upstream (corporate) proxy from the flag or env var. + // This is independent of the HTTP_PROXY/HTTPS_PROXY variables httpjail sets + // *inside* the jail to point sandboxed processes at itself. + let upstream_proxy_spec = args + .run_args + .upstream_proxy + .clone() + .or_else(|| std::env::var("HTTPJAIL_UPSTREAM_PROXY").ok()); + let upstream_proxy = match upstream_proxy_spec { + Some(spec) => { + let proxy = httpjail::upstream::UpstreamProxy::parse(&spec) + .with_context(|| format!("Failed to parse upstream proxy: {}", spec))?; + // Avoid logging the spec verbatim as it may contain credentials. + info!("Routing httpjail upstream requests through the configured upstream proxy"); + Some(proxy) + } + None => None, + }; + + let mut proxy = + ProxyServer::new_with_upstream_proxy(http_bind, https_bind, rule_engine, upstream_proxy); // Start proxy in background if running as server; otherwise start with random ports let (actual_http_port, actual_https_port) = proxy.start().await?; diff --git a/src/proxy.rs b/src/proxy.rs index 373251eb..bebb5beb 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -3,17 +3,21 @@ use crate::dangerous_verifier::create_dangerous_client_config; use crate::rules::{Action, RuleEngine}; #[allow(unused_imports)] use crate::tls::CertificateManager; +use crate::upstream::{ProxyConnector, UpstreamProxy}; use anyhow::Result; use bytes::Bytes; use http_body_util::{BodyExt, Full, combinators::BoxBody}; use hyper::body::Incoming; +use hyper::header::{HeaderValue, PROXY_AUTHORIZATION}; use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::{Error as HyperError, Request, Response, StatusCode, Uri}; use hyper_rustls::HttpsConnectorBuilder; use hyper_util::client::legacy::Client; +use hyper_util::client::legacy::connect::HttpConnector; use hyper_util::rt::{TokioExecutor, TokioIo}; use rand::Rng; +use rustls::pki_types::CertificateDer; #[cfg(target_os = "linux")] use std::os::fd::AsRawFd; @@ -158,13 +162,70 @@ pub fn apply_request_byte_limit( ))) } +/// Direct (no upstream proxy) upstream client type. +type DirectClient = Client, BoxBody>; + +/// Upstream client that routes every re-originated request through an upstream +/// (corporate) proxy via a [`ProxyConnector`]. +type ProxiedClient = + Client, BoxBody>; + +/// Upstream client: either contacts destinations directly or routes through a +/// configured upstream proxy. Both variants are high-level pooled clients. +pub enum UpstreamClient { + Direct(DirectClient), + Proxied { + client: ProxiedClient, + /// Attached to plain-HTTP requests forwarded through the proxy in + /// absolute-form (HTTPS carries credentials on the CONNECT instead). + http_auth: Option, + }, +} + +impl UpstreamClient { + /// Forward a prepared request upstream. No timeout is applied here so that + /// long-running connections (WebSocket, gRPC, ...) keep working. + pub async fn request( + &self, + mut req: Request>, + ) -> Result> { + match self { + UpstreamClient::Direct(client) => client.request(req).await.map_err(Into::into), + UpstreamClient::Proxied { client, http_auth } => { + if req.uri().scheme_str() == Some("http") + && let Some(auth) = http_auth + { + req.headers_mut().insert(PROXY_AUTHORIZATION, auth.clone()); + } + client.request(req).await.map_err(Into::into) + } + } + } +} + // Shared HTTP/HTTPS client for upstream requests -static HTTPS_CLIENT: OnceLock< - Client< - hyper_rustls::HttpsConnector, - BoxBody, - >, -> = OnceLock::new(); +static HTTPS_CLIENT: OnceLock = OnceLock::new(); + +/// Build a pooled hyper client over the given connector with the shared tuning. +fn build_pooled_client(connector: C) -> Client> +where + C: tower_service::Service + Clone + Send + Sync + 'static, + C::Response: hyper_util::client::legacy::connect::Connection + + hyper::rt::Read + + hyper::rt::Write + + Unpin + + Send + + 'static, + C::Future: Send + Unpin + 'static, + C::Error: Into>, +{ + Client::builder(TokioExecutor::new()) + .pool_idle_timeout(Duration::from_secs(5)) + .pool_max_idle_per_host(1) + .http1_title_case_headers(false) + .http1_preserve_header_case(true) + .build(connector) +} /// Prepare a request for forwarding to upstream server /// Removes proxy-specific headers and converts body to BoxBody @@ -250,45 +311,73 @@ fn create_client_config_with_ca( .with_no_client_auth() } -/// Initialize the HTTP client with the httpjail CA certificate -pub fn init_client_with_ca(ca_cert_der: rustls::pki_types::CertificateDer<'static>) { +/// Build the direct (no upstream proxy) HTTPS connector: webpki roots plus the +/// httpjail CA, with fast IPv6->IPv4 fallback (or the dangerous no-verification +/// config for testing). +fn build_direct_connector( + ca_cert_der: CertificateDer<'static>, + dangerous: bool, +) -> hyper_rustls::HttpsConnector { + if dangerous { + let config = create_dangerous_client_config(); + HttpsConnectorBuilder::new() + .with_tls_config(config) + .https_or_http() + .enable_http1() + .build() + } else { + let config = create_client_config_with_ca(ca_cert_der); + // Build an HttpConnector with fast IPv6->IPv4 fallback + let mut http = HttpConnector::new(); + http.enforce_http(false); + http.set_happy_eyeballs_timeout(Some(Duration::from_millis(250))); + hyper_rustls::HttpsConnector::from((http, config)) + } +} + +/// Initialize the shared upstream client with the httpjail CA certificate and an +/// optional upstream proxy. When a proxy is configured, all re-originated +/// requests are routed through it; otherwise destinations are contacted directly. +pub fn init_client_with_ca( + ca_cert_der: CertificateDer<'static>, + upstream_proxy: Option, +) { HTTPS_CLIENT.get_or_init(|| { // Check if we should dangerously disable cert validation (TESTING ONLY!) - let https = if std::env::var("HTTPJAIL_DANGER_DISABLE_CERT_VALIDATION").is_ok() { - let config = create_dangerous_client_config(); - - hyper_rustls::HttpsConnectorBuilder::new() - .with_tls_config(config) - .https_or_http() - .enable_http1() - .build() - } else { - // Normal path - use webpki roots + httpjail CA - let config = create_client_config_with_ca(ca_cert_der); - // Build an HttpConnector with fast IPv6->IPv4 fallback - let mut http = hyper_util::client::legacy::connect::HttpConnector::new(); - http.enforce_http(false); - http.set_happy_eyeballs_timeout(Some(Duration::from_millis(250))); - let https = hyper_rustls::HttpsConnector::from((http, config)); - info!("HTTPS connector initialized with webpki roots and httpjail CA"); - https - }; + let dangerous = std::env::var("HTTPJAIL_DANGER_DISABLE_CERT_VALIDATION").is_ok(); - Client::builder(TokioExecutor::new()) - // Keep minimal pooling but with shorter timeouts - .pool_idle_timeout(Duration::from_secs(5)) - .pool_max_idle_per_host(1) - .http1_title_case_headers(false) - .http1_preserve_header_case(true) - .build(https) + match upstream_proxy { + None => { + let https = build_direct_connector(ca_cert_der, dangerous); + info!("HTTPS connector initialized with webpki roots and httpjail CA"); + UpstreamClient::Direct(build_pooled_client(https)) + } + Some(proxy) => { + // Both the destination TLS (layered over the CONNECT tunnel by + // the HttpsConnector) and the optional https:// proxy TLS trust + // the same roots as the direct client. + let make_config = || { + if dangerous { + create_dangerous_client_config() + } else { + create_client_config_with_ca(ca_cert_der.clone()) + } + }; + let http_auth = proxy.http_auth(); + let connector = ProxyConnector::new(proxy, Arc::new(make_config())); + let https = hyper_rustls::HttpsConnector::from((connector, make_config())); + info!("Upstream client initialized to route through the upstream proxy"); + UpstreamClient::Proxied { + client: build_pooled_client(https), + http_auth, + } + } + } }); } -/// Get or create the shared HTTP/HTTPS client -pub fn get_client() -> &'static Client< - hyper_rustls::HttpsConnector, - BoxBody, -> { +/// Get or create the shared upstream client +pub fn get_client() -> &'static UpstreamClient { HTTPS_CLIENT.get_or_init(|| { // Fallback initialization if not already initialized with CA // This should not happen in normal operation @@ -301,13 +390,7 @@ pub fn get_client() -> &'static Client< .enable_http1() .build(); - Client::builder(TokioExecutor::new()) - // Keep minimal pooling but with shorter timeouts - .pool_idle_timeout(Duration::from_secs(5)) - .pool_max_idle_per_host(1) - .http1_title_case_headers(false) - .http1_preserve_header_case(true) - .build(https) + UpstreamClient::Direct(build_pooled_client(https)) }) } @@ -400,12 +483,23 @@ impl ProxyServer { http_bind: Option, https_bind: Option, rule_engine: RuleEngine, + ) -> Self { + Self::new_with_upstream_proxy(http_bind, https_bind, rule_engine, None) + } + + /// Like [`ProxyServer::new`], but routes httpjail's own re-originated + /// requests through the given upstream (corporate) proxy when set. + pub fn new_with_upstream_proxy( + http_bind: Option, + https_bind: Option, + rule_engine: RuleEngine, + upstream_proxy: Option, ) -> Self { let cert_manager = CertificateManager::new().expect("Failed to create certificate manager"); // Initialize the HTTP client with our CA certificate let ca_cert_der = cert_manager.get_ca_cert_der(); - init_client_with_ca(ca_cert_der); + init_client_with_ca(ca_cert_der, upstream_proxy); // Generate a unique nonce for loop detection (Issue #84) // Use 16 random hex characters for a reasonably short but collision-resistant ID @@ -671,7 +765,7 @@ async fn proxy_request( elapsed.as_millis(), e ); - return Err(e.into()); + return Err(e); } }; @@ -752,4 +846,67 @@ mod tests { assert!((8000..=8999).contains(&https_port)); assert_ne!(http_port, https_port); } + + /// A plain-HTTP request routed through an upstream proxy must be forwarded in + /// absolute-form with the configured `Proxy-Authorization` header. + #[tokio::test] + async fn proxied_http_uses_absolute_form_with_auth() { + use http_body_util::Empty; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + // Fake upstream proxy: capture the forwarded request, then reply 200. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + while sock.read(&mut byte).await.unwrap() != 0 { + buf.push(byte[0]); + if buf.ends_with(b"\r\n\r\n") { + break; + } + } + sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + sock.flush().await.unwrap(); + String::from_utf8_lossy(&buf).into_owned() + }); + + let proxy = UpstreamProxy::parse(&format!("http://user:pass@{}", addr)).unwrap(); + let http_auth = proxy.http_auth(); + let connector = ProxyConnector::new(proxy, Arc::new(create_dangerous_client_config())); + let https = + hyper_rustls::HttpsConnector::from((connector, create_dangerous_client_config())); + let client = UpstreamClient::Proxied { + client: build_pooled_client(https), + http_auth, + }; + + let body = Empty::::new() + .map_err(|never| match never {}) + .boxed(); + let req = Request::builder() + .uri("http://target.example/path") + .body(body) + .unwrap(); + let resp = client.request(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let forwarded = server.await.unwrap(); + assert!( + forwarded.starts_with("GET http://target.example/path HTTP/1.1\r\n"), + "expected absolute-form request line, got: {forwarded}" + ); + // Header names are case-insensitive; base64("user:pass") == dXNlcjpwYXNz + assert!( + forwarded.lines().any(|l| { + l.to_ascii_lowercase().starts_with("proxy-authorization:") + && l.contains("Basic dXNlcjpwYXNz") + }), + "missing proxy auth, got: {forwarded}" + ); + } } diff --git a/src/proxy_tls.rs b/src/proxy_tls.rs index f7c1da56..005dec17 100644 --- a/src/proxy_tls.rs +++ b/src/proxy_tls.rs @@ -595,7 +595,7 @@ async fn proxy_https_request( // The hyper_util error doesn't expose underlying IO errors directly - return Err(e.into()); + return Err(e); } }; diff --git a/src/upstream.rs b/src/upstream.rs new file mode 100644 index 00000000..ced970e2 --- /dev/null +++ b/src/upstream.rs @@ -0,0 +1,588 @@ +//! Forward httpjail's own re-originated requests through an upstream +//! (e.g. corporate) HTTP proxy. +//! +//! httpjail terminates the jailed process's traffic and then re-originates the +//! request towards the real destination. When httpjail itself has no direct +//! egress, that re-originated request must instead traverse an upstream proxy. +//! +//! This is implemented as a hyper *connector* ([`ProxyConnector`]) so that the +//! same high-level `hyper_util` `Client` used for direct egress can be reused +//! unchanged: connection pooling, request serialization and (for HTTPS) the +//! destination TLS handshake are all handled by hyper's own machinery. The +//! connector only decides how the underlying byte stream is obtained: +//! +//! * for `https://` destinations it issues a `CONNECT` to the proxy to obtain a +//! raw TCP tunnel; the surrounding `hyper_rustls::HttpsConnector` then performs +//! the destination TLS handshake over that tunnel, and +//! * for `http://` destinations it returns the proxy connection marked as +//! proxied, so hyper emits the request in absolute-form for the proxy to +//! forward. +//! +//! Following the streaming style used elsewhere in httpjail (see `proxy_tls.rs`) +//! every bounded read during setup is guarded by a timeout, while the +//! established tunnel itself is left unbounded so long-running connections +//! (WebSocket, gRPC, ...) keep working. + +use anyhow::{Context as _, Result, anyhow, bail}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use hyper::Uri; +use hyper::header::HeaderValue; +use hyper::rt::{Read, ReadBufCursor, Write}; +use hyper_util::client::legacy::connect::{Connected, Connection, HttpConnector}; +use hyper_util::rt::TokioIo; +use percent_encoding::percent_decode_str; +use rustls::pki_types::ServerName; +use std::future::Future; +use std::io; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::time::{Duration, timeout}; +use tokio_rustls::TlsConnector; +use tower_service::Service; +use tracing::debug; +use url::{Host, Url}; + +type BoxError = Box; + +/// Timeout for establishing the tunnel through the upstream proxy (TCP connect, +/// optional TLS to the proxy and the `CONNECT` exchange). This bounds setup +/// only; the resulting tunnel carries no timeout so long-running connections +/// keep working. +const PROXY_SETUP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Upper bound on the size of the upstream proxy's `CONNECT` response headers. +/// A well-behaved proxy answers with a short status line and a few headers. +const MAX_CONNECT_RESPONSE_BYTES: usize = 16 * 1024; + +/// Object-safe combination of the async byte-stream traits we erase over so the +/// connector can hold either a plain TCP stream or a TLS stream (when the proxy +/// itself is reached over `https://`) behind a single type. +trait IoStream: AsyncRead + AsyncWrite + Unpin + Send {} +impl IoStream for T {} + +/// A heap-erased byte stream carrying the connection to the proxy. +type BoxedIo = Box; + +/// Parsed configuration for an upstream proxy. +#[derive(Clone, Debug)] +pub struct UpstreamProxy { + /// Proxy host (DNS name or IP literal) to dial. + host: String, + /// Proxy port. + port: u16, + /// Whether the connection to the proxy itself is wrapped in TLS (an + /// `https://` proxy URL). + tls: bool, + /// Pre-built `Proxy-Authorization` header value when credentials are given. + auth: Option, +} + +impl UpstreamProxy { + /// Parse an upstream proxy specification such as `http://proxy.corp:3128`, + /// `http://user:pass@proxy.corp:3128`, `https://proxy.corp:8443` or a bare + /// `proxy.corp:3128` (the `http` scheme is then assumed). + pub fn parse(spec: &str) -> Result { + let spec = spec.trim(); + if spec.is_empty() { + bail!("Upstream proxy specification is empty"); + } + let redacted_spec = redact_proxy_spec(spec); + let normalized = if spec.contains("://") { + spec.to_string() + } else { + format!("http://{spec}") + }; + + let url = Url::parse(&normalized) + .with_context(|| format!("Invalid upstream proxy URL: {}", redacted_spec))?; + + let tls = match url.scheme() { + "http" => false, + "https" => true, + other => bail!( + "Unsupported upstream proxy scheme '{}': {}", + other, + redacted_spec + ), + }; + + let host = match url.host() { + Some(Host::Domain(host)) => host.to_string(), + Some(Host::Ipv4(host)) => host.to_string(), + Some(Host::Ipv6(host)) => host.to_string(), + None => bail!("Invalid upstream proxy authority: {}", redacted_spec), + }; + + let port = url + .port_or_known_default() + .ok_or_else(|| anyhow!("Invalid upstream proxy authority: {}", redacted_spec))?; + + let auth = if !url.username().is_empty() || url.password().is_some() { + Some(build_basic_auth(url.username(), url.password())?) + } else { + None + }; + + Ok(UpstreamProxy { + host, + port, + tls, + auth, + }) + } + + /// The `Proxy-Authorization` header value, if credentials were supplied. + /// + /// Needed by the plain-HTTP forwarding path, where the header travels on the + /// forwarded request itself rather than on a `CONNECT`. + pub fn http_auth(&self) -> Option { + self.auth.clone() + } +} + +/// Redact userinfo from a proxy URL-like string before it is logged or included +/// in an error message. +pub fn redact_proxy_spec(spec: &str) -> String { + let spec = spec.trim(); + let (prefix, rest) = match spec.split_once("://") { + Some((scheme, rest)) => (format!("{scheme}://"), rest), + None => (String::new(), spec), + }; + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let (authority, suffix) = rest.split_at(authority_end); + let Some((_, host_port)) = authority.rsplit_once('@') else { + return spec.to_string(); + }; + format!("{prefix}@{host_port}{suffix}") +} + +/// Build a `Proxy-Authorization: Basic ...` header value from `user:pass` +/// userinfo, percent-decoding each component first. +fn build_basic_auth(user: &str, pass: Option<&str>) -> Result { + let user = percent_decode_str(user).decode_utf8_lossy(); + let pass = percent_decode_str(pass.unwrap_or("")).decode_utf8_lossy(); + let token = STANDARD.encode(format!("{user}:{pass}")); + HeaderValue::from_str(&format!("Basic {}", token)) + .context("Invalid characters in upstream proxy credentials") +} + +/// A hyper connector that routes outbound connections through an +/// [`UpstreamProxy`]. +/// +/// It is intended to be used as the inner connector of a +/// `hyper_rustls::HttpsConnector`: this connector yields a raw byte stream (the +/// proxy connection for HTTP, or a `CONNECT` tunnel for HTTPS) and the +/// surrounding HTTPS connector layers the destination TLS on top when needed. +#[derive(Clone)] +pub struct ProxyConnector { + /// Used solely to dial the proxy's `host:port` (never the destination). + http: HttpConnector, + proxy: Arc, + /// TLS configuration used only when the proxy itself is `https://`. + proxy_tls: Arc, +} + +impl ProxyConnector { + pub fn new(proxy: UpstreamProxy, proxy_tls: Arc) -> Self { + let mut http = HttpConnector::new(); + // The proxy is addressed via an http(s) URL; allow non-http schemes so + // the connector does not reject the dial target. + http.enforce_http(false); + http.set_happy_eyeballs_timeout(Some(Duration::from_millis(250))); + ProxyConnector { + http, + proxy: Arc::new(proxy), + proxy_tls, + } + } +} + +impl Service for ProxyConnector { + type Response = ProxyStream; + type Error = BoxError; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.http.poll_ready(cx).map_err(Into::into) + } + + fn call(&mut self, dst: Uri) -> Self::Future { + let mut http = self.http.clone(); + let proxy = Arc::clone(&self.proxy); + let proxy_tls = Arc::clone(&self.proxy_tls); + + Box::pin(async move { + // Dial the proxy (TCP). The destination scheme is irrelevant here; + // we always connect to the proxy's host:port. + let proxy_uri: Uri = format!("http://{}", host_port_authority(&proxy.host, proxy.port)) + .parse()?; + let tcp = match timeout(PROXY_SETUP_TIMEOUT, http.call(proxy_uri)).await { + Ok(result) => result?.into_inner(), + Err(_) => return Err(timed_out("connecting to upstream proxy")), + }; + let _ = tcp.set_nodelay(true); + + // Optionally negotiate TLS with the proxy itself. + let mut stream: BoxedIo = if proxy.tls { + let name = ServerName::try_from(proxy.host.clone()).map_err(|_| { + BoxError::from(format!("Invalid proxy host for TLS SNI: {}", proxy.host)) + })?; + let connector = TlsConnector::from(Arc::clone(&proxy_tls)); + let tls = match timeout(PROXY_SETUP_TIMEOUT, connector.connect(name, tcp)).await { + Ok(result) => result?, + Err(_) => return Err(timed_out("during TLS handshake with upstream proxy")), + }; + Box::new(tls) + } else { + Box::new(tcp) + }; + + let proxied = if dst.scheme_str() == Some("https") { + let host = dst.host().ok_or_else(|| { + BoxError::from(format!("CONNECT target has no host: {}", dst)) + })?; + let port = dst.port_u16().unwrap_or(443); + establish_connect_tunnel(&mut stream, host, port, proxy.auth.as_ref()) + .await + .map_err(|e| -> BoxError { e.into() })?; + // The tunnel is transparent end-to-end; destination TLS is + // layered on top by the surrounding HttpsConnector and the + // request is sent in origin-form, so do not mark it proxied. + false + } else { + // Plain HTTP: the proxy forwards absolute-form requests. Mark the + // connection proxied so hyper emits absolute-form request lines. + true + }; + + Ok(ProxyStream::new(stream, proxied)) + }) + } +} + +/// Build a timeout error for the upstream proxy setup phase. +fn timed_out(phase: &str) -> BoxError { + format!("Timeout {} with upstream proxy", phase).into() +} + +/// The connector's response: a byte stream plus the proxied flag that hyper +/// consults to decide between absolute-form and origin-form request lines. +pub struct ProxyStream { + io: TokioIo, + proxied: bool, +} + +impl ProxyStream { + fn new(io: BoxedIo, proxied: bool) -> Self { + ProxyStream { + io: TokioIo::new(io), + proxied, + } + } +} + +impl Connection for ProxyStream { + fn connected(&self) -> Connected { + Connected::new().proxy(self.proxied) + } +} + +impl Read for ProxyStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: ReadBufCursor<'_>, + ) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_read(cx, buf) + } +} + +impl Write for ProxyStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_shutdown(cx) + } + + fn is_write_vectored(&self) -> bool { + self.io.is_write_vectored() + } + + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[io::IoSlice<'_>], + ) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_write_vectored(cx, bufs) + } +} + +/// Send a `CONNECT` request to the upstream proxy and validate its response, +/// leaving `stream` positioned at the start of the tunnel payload on success. +async fn establish_connect_tunnel( + stream: &mut S, + host: &str, + port: u16, + auth: Option<&HeaderValue>, +) -> Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + // Bracket IPv6 literals in the request-target and Host header. + let target = host_port_authority(host, port); + + let mut request = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n"); + if let Some(value) = auth { + let value = value + .to_str() + .context("Proxy-Authorization contains non-ASCII bytes")?; + request.push_str("Proxy-Authorization: "); + request.push_str(value); + request.push_str("\r\n"); + } + request.push_str("\r\n"); + + match timeout(PROXY_SETUP_TIMEOUT, stream.write_all(request.as_bytes())).await { + Ok(result) => result.context("Failed to write CONNECT request")?, + Err(_) => bail!("Timeout writing CONNECT request to upstream proxy"), + } + match timeout(PROXY_SETUP_TIMEOUT, stream.flush()).await { + Ok(result) => result.context("Failed to flush CONNECT request")?, + Err(_) => bail!("Timeout flushing CONNECT request to upstream proxy"), + } + + let status = match timeout(PROXY_SETUP_TIMEOUT, read_connect_status(stream)).await { + Ok(result) => result?, + Err(_) => bail!("Timeout reading CONNECT response from upstream proxy"), + }; + + if !(200..300).contains(&status) { + bail!( + "Upstream proxy refused CONNECT to {}:{} with status {}", + host, + port, + status + ); + } + + debug!( + "Established CONNECT tunnel to {}:{} via upstream proxy", + host, port + ); + Ok(()) +} + +/// Format a host and port for use as an HTTP authority, bracketing IPv6 +/// literals as required by URI syntax. +fn host_port_authority(host: &str, port: u16) -> String { + if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +/// Read the proxy's `CONNECT` response up to the end of its headers and return +/// the HTTP status code. Reads are bounded by [`MAX_CONNECT_RESPONSE_BYTES`] to +/// avoid consuming tunnel payload and to bound memory. +async fn read_connect_status(stream: &mut S) -> Result +where + S: AsyncRead + Unpin, +{ + let mut buf = Vec::with_capacity(128); + let mut byte = [0u8; 1]; + loop { + let n = stream.read(&mut byte).await?; + if n == 0 { + bail!("Upstream proxy closed connection during CONNECT"); + } + buf.push(byte[0]); + if buf.ends_with(b"\r\n\r\n") { + break; + } + if buf.len() > MAX_CONNECT_RESPONSE_BYTES { + bail!("Upstream proxy CONNECT response exceeded size limit"); + } + } + + // Parse the status code from the first line, e.g. + // `HTTP/1.1 200 Connection established`. + let head = std::str::from_utf8(&buf).context("Non-UTF8 CONNECT response")?; + let first_line = head.lines().next().unwrap_or(""); + first_line + .split_whitespace() + .nth(1) + .and_then(|code| code.parse::().ok()) + .ok_or_else(|| anyhow!("Malformed CONNECT status line: {:?}", first_line)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_plain_proxy() { + let p = UpstreamProxy::parse("http://proxy.corp:3128").unwrap(); + assert_eq!(p.host, "proxy.corp"); + assert_eq!(p.port, 3128); + assert!(!p.tls); + assert!(p.auth.is_none()); + } + + #[test] + fn parse_proxy_ignores_path_query_and_fragment() { + let p = UpstreamProxy::parse("http://proxy.corp:3128/path?ignored=true#frag").unwrap(); + assert_eq!(p.host, "proxy.corp"); + assert_eq!(p.port, 3128); + assert!(!p.tls); + } + + #[test] + fn parse_bare_hostport_defaults_to_http() { + let p = UpstreamProxy::parse("proxy.corp:8080").unwrap(); + assert_eq!(p.host, "proxy.corp"); + assert_eq!(p.port, 8080); + assert!(!p.tls); + } + + #[test] + fn parse_https_proxy_default_port() { + let p = UpstreamProxy::parse("https://proxy.corp").unwrap(); + assert!(p.tls); + assert_eq!(p.port, 443); + } + + #[test] + fn parse_ipv6_literal_with_port() { + let p = UpstreamProxy::parse("http://[::1]:3128").unwrap(); + assert_eq!(p.host, "::1"); + assert_eq!(p.port, 3128); + } + + #[test] + fn host_port_authority_brackets_ipv6_literal() { + assert_eq!(host_port_authority("::1", 3128), "[::1]:3128"); + assert_eq!(host_port_authority("proxy.corp", 3128), "proxy.corp:3128"); + } + + #[test] + fn parse_proxy_with_credentials() { + let p = UpstreamProxy::parse("http://alice:s3cr3t@proxy.corp:3128").unwrap(); + // base64("alice:s3cr3t") + assert_eq!(p.auth.unwrap().to_str().unwrap(), "Basic YWxpY2U6czNjcjN0"); + } + + #[test] + fn parse_credentials_are_percent_decoded() { + // "p@ss:word" encoded in the userinfo. + let p = UpstreamProxy::parse("http://user:p%40ss%3Aword@proxy.corp:3128").unwrap(); + assert_eq!( + p.auth.unwrap().to_str().unwrap(), + "Basic dXNlcjpwQHNzOndvcmQ=" + ); + } + + #[test] + fn redact_proxy_spec_removes_userinfo() { + assert_eq!( + redact_proxy_spec("http://user:secret@proxy.corp:3128/path"), + "http://@proxy.corp:3128/path" + ); + assert_eq!( + redact_proxy_spec("user:secret@proxy.corp:3128"), + "@proxy.corp:3128" + ); + assert_eq!( + redact_proxy_spec("http://proxy.corp:3128"), + "http://proxy.corp:3128" + ); + } + + #[test] + fn reject_unknown_scheme() { + assert!(UpstreamProxy::parse("ftp://proxy.corp:21").is_err()); + } + + #[test] + fn reject_empty_spec() { + assert!(UpstreamProxy::parse(" ").is_err()); + } + + /// Drive the proxy side of an in-memory duplex: read request headers up to + /// the blank line, then reply with `response`. Returns the request text. + async fn fake_proxy(mut end: tokio::io::DuplexStream, response: &'static [u8]) -> String { + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = end.read(&mut byte).await.unwrap(); + if n == 0 { + break; + } + buf.push(byte[0]); + if buf.ends_with(b"\r\n\r\n") { + break; + } + } + end.write_all(response).await.unwrap(); + end.flush().await.unwrap(); + String::from_utf8_lossy(&buf).into_owned() + } + + #[tokio::test] + async fn connect_tunnel_sends_request_and_accepts_2xx() { + let (mut client_end, proxy_end) = tokio::io::duplex(1024); + let proxy = tokio::spawn(fake_proxy( + proxy_end, + b"HTTP/1.1 200 Connection established\r\n\r\n", + )); + + let auth = HeaderValue::from_static("Basic dXNlcjpwYXNz"); + establish_connect_tunnel(&mut client_end, "example.com", 443, Some(&auth)) + .await + .unwrap(); + + let request = proxy.await.unwrap(); + assert!(request.starts_with("CONNECT example.com:443 HTTP/1.1\r\n")); + assert!(request.contains("Host: example.com:443\r\n")); + assert!(request.contains("Proxy-Authorization: Basic dXNlcjpwYXNz\r\n")); + } + + #[tokio::test] + async fn connect_tunnel_brackets_ipv6_literal() { + let (mut client_end, proxy_end) = tokio::io::duplex(1024); + let proxy = tokio::spawn(fake_proxy( + proxy_end, + b"HTTP/1.1 200 Connection established\r\n\r\n", + )); + + establish_connect_tunnel(&mut client_end, "::1", 443, None) + .await + .unwrap(); + + let request = proxy.await.unwrap(); + assert!(request.starts_with("CONNECT [::1]:443 HTTP/1.1\r\n")); + } + + #[tokio::test] + async fn connect_tunnel_rejects_non_2xx() { + let (mut client_end, proxy_end) = tokio::io::duplex(1024); + tokio::spawn(fake_proxy(proxy_end, b"HTTP/1.1 403 Forbidden\r\n\r\n")); + + let err = establish_connect_tunnel(&mut client_end, "blocked.test", 443, None) + .await + .unwrap_err(); + assert!(err.to_string().contains("403"), "unexpected error: {}", err); + } +}