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..8be65841 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,27 @@ 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 +HTTPS_PROXY=http://proxy.corp:3128 httpjail --js "true" -- curl https://api.github.com +# Credentials and HTTPS proxies are supported: http://user:pass@proxy.corp:3128, https://proxy.corp:8443 ``` +### Upstream (corporate) proxy + +When httpjail itself runs in an environment with no direct internet access, set +the `HTTP_PROXY` and/or `HTTPS_PROXY` environment variables 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. +- In weak mode, httpjail overwrites proxy env vars inside the jailed process to + point sandboxed processes at httpjail itself. + ## Documentation Docs are stored in the `docs/` directory and served @@ -82,6 +102,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..c1faac7f --- /dev/null +++ b/docs/advanced/upstream-proxy.md @@ -0,0 +1,62 @@ +# 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 the `HTTP_PROXY` and/or `HTTPS_PROXY` environment variables. + +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 HTTPS egress through a corporate proxy +HTTPS_PROXY=http://proxy.corp:3128 httpjail --js "true" -- curl https://api.github.com + +# Route both HTTP and HTTPS egress through the same proxy +HTTP_PROXY=http://proxy.corp:3128 HTTPS_PROXY=http://proxy.corp:3128 \ + httpjail --js "true" -- ./my-app + +# With Basic authentication +HTTPS_PROXY=http://user:pass@proxy.corp:3128 httpjail --js "true" -- ./my-app + +# Through an HTTPS proxy +HTTPS_PROXY=https://proxy.corp:8443 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 ...` | + +`HTTP_PROXY` is used for `http://` destinations. `HTTPS_PROXY` is used for +`https://` destinations. 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 jailed process proxy variables + +The proxy environment variables configure httpjail's own egress. In weak mode, +httpjail overwrites `HTTP_PROXY` and `HTTPS_PROXY` inside the jailed process to +point sandboxed processes at httpjail itself. + +``` +[ jailed process ] --> [ httpjail ] --HTTP_PROXY/HTTPS_PROXY--> [ corporate proxy ] --> internet +``` + +The jailed process talks to httpjail; the proxy env vars only affect the hop +from httpjail to the outside world. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 7338e46b..5993fa0e 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -7,7 +7,7 @@ httpjail's behavior can be configured through command-line options, environment httpjail follows a simple configuration hierarchy: 1. **Command-line options** - Highest priority, override everything -2. **Environment variables** - Set by httpjail for the jailed process +2. **Environment variables** - Configure httpjail and the jailed process ## Key Configuration Areas @@ -72,9 +72,11 @@ httpjail --proc ./rate-limiter.py \ ## Environment Variables -### Set by httpjail +### Set for the jailed process -These are automatically set in the jailed process: +These are set in the jailed process where applicable. In weak mode, httpjail +sets proxy variables so applications talk to httpjail. On Linux strong mode, +traffic is redirected transparently without setting proxy variables. | Variable | Description | Example | | --------------- | ---------------------------- | ------------------------ | @@ -84,14 +86,18 @@ These are automatically set in the jailed process: | `SSL_CERT_DIR` | CA certificate directory | `/tmp/httpjail-certs/` | | `NO_PROXY` | Bypass proxy for these hosts | `localhost,127.0.0.1` | -### Controlling httpjail +### Consumed by httpjail 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` | +| `HTTP_PROXY` | Upstream proxy for httpjail HTTP egress | `http://proxy.corp:3128` | +| `HTTPS_PROXY` | Upstream proxy for httpjail HTTPS egress | `http://proxy.corp:3128` | + +See [Upstream Proxy](../advanced/upstream-proxy.md) for details. ## Platform-Specific Configuration diff --git a/src/jail/linux/docker.rs b/src/jail/linux/docker.rs index 5282cc65..68bb7efa 100644 --- a/src/jail/linux/docker.rs +++ b/src/jail/linux/docker.rs @@ -311,6 +311,13 @@ impl DockerLinux { let mut cmd = Command::new("docker"); cmd.arg("run"); + // The parent process may use proxy env vars for httpjail's own egress. + // Do not leak those credentials or settings into the Docker CLI process; + // Docker network isolation routes container traffic through httpjail. + for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] { + cmd.env_remove(key); + } + // Use our isolated Docker network cmd.args(["--network", &network_name]); diff --git a/src/jail/linux/mod.rs b/src/jail/linux/mod.rs index 7d6165f8..8d7810cd 100644 --- a/src/jail/linux/mod.rs +++ b/src/jail/linux/mod.rs @@ -544,6 +544,13 @@ impl Jail for LinuxJail { cmd.env(key, value); } + // The parent process may use proxy env vars for httpjail's own egress. + // Do not leak those credentials or settings into the jailed command; + // native Linux isolation redirects traffic transparently. + for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] { + cmd.env_remove(key); + } + // Preserve SUDO environment variables for consistency with macOS if let Ok(sudo_user) = std::env::var("SUDO_USER") { cmd.env("SUDO_USER", sudo_user); 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..db25a455 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: @@ -139,6 +140,27 @@ struct RunArgs { exec_command: Vec, } +impl fmt::Debug for RunArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + 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("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 +612,18 @@ async fn main() -> Result<()> { } }; - let mut proxy = ProxyServer::new(http_bind, https_bind, rule_engine); + let upstream_proxies = httpjail::upstream::UpstreamProxies::from_env() + .context("Failed to configure upstream proxy from environment")?; + if upstream_proxies.is_some() { + debug!("Routing httpjail upstream requests through the proxy environment"); + } + + let mut proxy = ProxyServer::new_with_upstream_proxies( + http_bind, + https_bind, + rule_engine, + upstream_proxies, + ); // 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..da7315a3 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, UpstreamProxies}; use anyhow::Result; use bytes::Bytes; use http_body_util::{BodyExt, Full, combinators::BoxBody}; use hyper::body::Incoming; +use hyper::header::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,68 @@ 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, + proxies: UpstreamProxies, + }, +} + +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, proxies } => { + if req.uri().scheme_str() == Some("http") + && let Some(auth) = proxies.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 +309,74 @@ 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 proxies. When a proxy is configured for a destination +/// scheme, matching re-originated requests are routed through it; otherwise +/// destinations are contacted directly. +pub fn init_client_with_ca( + ca_cert_der: CertificateDer<'static>, + upstream_proxies: 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_proxies { + None => { + let https = build_direct_connector(ca_cert_der, dangerous); + debug!("HTTPS connector initialized with webpki roots and httpjail CA"); + UpstreamClient::Direct(build_pooled_client(https)) + } + Some(proxies) => { + // 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 connector = + ProxyConnector::with_config(proxies.clone(), Arc::new(make_config())); + let https = hyper_rustls::HttpsConnector::from((connector, make_config())); + debug!("Upstream client initialized to route through the upstream proxy"); + UpstreamClient::Proxied { + client: build_pooled_client(https), + proxies, + } + } + } }); } -/// 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 +389,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 +482,23 @@ impl ProxyServer { http_bind: Option, https_bind: Option, rule_engine: RuleEngine, + ) -> Self { + Self::new_with_upstream_proxies(http_bind, https_bind, rule_engine, None) + } + + /// Like [`ProxyServer::new`], but routes httpjail's own re-originated + /// requests through the configured upstream proxies when set. + pub fn new_with_upstream_proxies( + http_bind: Option, + https_bind: Option, + rule_engine: RuleEngine, + upstream_proxies: 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_proxies); // Generate a unique nonce for loop detection (Issue #84) // Use 16 random hex characters for a reasonably short but collision-resistant ID @@ -671,7 +764,7 @@ async fn proxy_request( elapsed.as_millis(), e ); - return Err(e.into()); + return Err(e); } }; @@ -752,4 +845,68 @@ 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 = + crate::upstream::UpstreamProxy::parse(&format!("http://user:pass@{}", addr)).unwrap(); + let proxies = UpstreamProxies::all(proxy.clone()); + 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), + proxies, + }; + + 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..ad98cc40 --- /dev/null +++ b/src/upstream.rs @@ -0,0 +1,703 @@ +//! 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, +} + +/// Upstream proxy configuration resolved from the proxy environment. +#[derive(Clone, Debug)] +pub struct UpstreamProxies { + http: Option, + https: Option, +} + +impl UpstreamProxies { + /// Resolve httpjail's own egress proxy settings from the proxy environment. + pub fn from_env() -> Result> { + let http = proxy_from_env("HTTP_PROXY", "http_proxy")?; + let https = proxy_from_env("HTTPS_PROXY", "https_proxy")?; + Ok(Self::from_proxies(http, https)) + } + + fn proxy_for_uri(&self, uri: &Uri) -> Option<&UpstreamProxy> { + match uri.scheme_str() { + Some("http") => self.http.as_ref(), + Some("https") => self.https.as_ref(), + _ => None, + } + } + + pub(crate) fn http_auth(&self) -> Option { + self.http.as_ref().and_then(UpstreamProxy::http_auth) + } + + pub(crate) fn all(proxy: UpstreamProxy) -> Self { + Self { + http: Some(proxy.clone()), + https: Some(proxy), + } + } + + fn from_proxies(http: Option, https: Option) -> Option { + if http.is_none() && https.is_none() { + return None; + } + Some(Self { http, https }) + } + + #[cfg(test)] + fn from_specs(http: Option<&str>, https: Option<&str>) -> Result> { + let http = parse_optional_proxy_spec("HTTP_PROXY", http)?; + let https = parse_optional_proxy_spec("HTTPS_PROXY", https)?; + Ok(Self::from_proxies(http, https)) + } +} + +fn proxy_from_env(primary: &str, fallback: &str) -> Result> { + for name in [primary, fallback] { + if let Ok(value) = std::env::var(name) { + let proxy = parse_optional_proxy_spec(name, Some(&value))?; + if proxy.is_some() { + return Ok(proxy); + } + } + } + Ok(None) +} + +fn parse_optional_proxy_spec(name: &str, spec: Option<&str>) -> Result> { + let Some(spec) = spec.map(str::trim).filter(|spec| !spec.is_empty()) else { + return Ok(None); + }; + UpstreamProxy::parse(spec) + .map(Some) + .with_context(|| format!("Failed to parse {name}")) +} + +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, + proxies: 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 { + Self::with_config(UpstreamProxies::all(proxy), proxy_tls) + } + + pub fn with_config(proxies: UpstreamProxies, 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, + proxies: Arc::new(proxies), + 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 = self.proxies.proxy_for_uri(&dst).cloned(); + let proxy_tls = Arc::clone(&self.proxy_tls); + + Box::pin(async move { + let Some(proxy) = proxy else { + let tcp = match timeout(PROXY_SETUP_TIMEOUT, http.call(dst.clone())).await { + Ok(result) => result?.into_inner(), + Err(_) => return Err(timed_out("connecting directly to destination")), + }; + let _ = tcp.set_nodelay(true); + return Ok(ProxyStream::new(Box::new(tcp), false)); + }; + + // 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()); + } + + #[test] + fn proxy_config_uses_specs_by_scheme() { + let proxies = UpstreamProxies::from_specs( + Some("http://http-proxy.corp:3128"), + Some("http://https-proxy.corp:8443"), + ) + .unwrap() + .unwrap(); + + let http_uri: Uri = "http://example.com/".parse().unwrap(); + let https_uri: Uri = "https://example.com/".parse().unwrap(); + + assert_eq!( + proxies + .proxy_for_uri(&http_uri) + .map(|proxy| proxy.host.as_str()), + Some("http-proxy.corp") + ); + assert_eq!( + proxies + .proxy_for_uri(&https_uri) + .map(|proxy| proxy.host.as_str()), + Some("https-proxy.corp") + ); + } + + #[test] + fn proxy_config_ignores_empty_specs() { + let proxies = UpstreamProxies::from_specs(Some(" "), None).unwrap(); + assert!(proxies.is_none()); + } + + /// 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); + } +}