From 56b32c9517a7f9b826805641b690331c2b311488 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 1 Jun 2026 18:21:20 -0400 Subject: [PATCH 1/4] add no_proxy support Per-request no_proxy list that bypasses the configured proxy. Matches exact hostnames, domain suffixes (*.corp), IPs, and CIDRs. Exposed on request/download/raw_connect/BatchConfig and the --no-proxy CLI flag. Bump to 0.8.0. --- Cargo.lock | 2 +- Cargo.toml | 2 +- pyproject.toml | 2 +- src/client/hyper.rs | 6 +- src/config.rs | 225 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 6 ++ src/python.rs | 16 ++++ 7 files changed, 253 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee28f52..7944ac0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,7 +99,7 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "blasthttp" -version = "0.7.0" +version = "0.8.0" dependencies = [ "brotli", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 3a7df73..11d73b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "blasthttp" -version = "0.7.0" +version = "0.8.0" edition = "2024" description = "Offensive-first HTTP library with Python bindings" license = "GPL-3.0" diff --git a/pyproject.toml b/pyproject.toml index 2002f3e..5355d1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "blasthttp" -version = "0.7.0" +version = "0.8.0" description = "Offensive-first HTTP library" license = "GPL-3.0" requires-python = ">=3.10" diff --git a/src/client/hyper.rs b/src/client/hyper.rs index 43815ce..5555f1d 100644 --- a/src/client/hyper.rs +++ b/src/client/hyper.rs @@ -532,7 +532,7 @@ impl HyperClient { /// Determine the connection mode for a given request config + target URI. fn conn_mode(config: &RequestConfig, target_uri: &http::Uri) -> Result { - match config.proxy.as_deref() { + match config.effective_proxy(target_uri.host().unwrap_or("")) { None => Ok(ConnMode::Direct), Some(proxy_url) => { let proxy_uri: http::Uri = @@ -694,7 +694,7 @@ pub(crate) async fn connect_stream( // tunnel from there. resolve_ip is ignored when a proxy is set — DNS // resolution happens at the proxy for SOCKS5, and CONNECT addresses the // target by hostname. - let proxy_config = match config.proxy.as_ref() { + let proxy_config = match config.effective_proxy(&host) { Some(p) => Some(proxy::parse_proxy_url(p)?), None => None, }; @@ -1267,7 +1267,7 @@ impl HyperClient { })?; debug_record(log, v, 1, &format!("-> {} {}", config.method(), uri)); - if let Some(ref proxy) = config.proxy { + if let Some(proxy) = config.effective_proxy(uri.host().unwrap_or("")) { debug_record(log, v, 1, &format!(" Proxy: {}", proxy)); } if !config.should_verify_certs() { diff --git a/src/config.rs b/src/config.rs index 1966fa2..fd9ee6b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,11 @@ pub struct RequestConfig { pub max_redirects: Option, pub verify_certs: Option, pub proxy: Option, + /// Hosts that bypass `proxy` and connect directly (NO_PROXY equivalent). + /// Entries may be exact hostnames, domain suffixes (`*.corp` / `.corp` / + /// `corp`), single IPs, CIDR ranges (`10.0.0.0/8`), or `*` for all. + #[serde(default)] + pub no_proxy: Vec, pub cipher_string: Option, pub min_tls_version: Option, pub max_tls_version: Option, @@ -52,6 +57,7 @@ impl RequestConfig { max_redirects: None, verify_certs: None, proxy: None, + no_proxy: Vec::new(), cipher_string: None, min_tls_version: None, max_tls_version: None, @@ -90,6 +96,17 @@ impl RequestConfig { self.verify_certs.unwrap_or(false) } + /// The proxy URL to use for a request to `target_host`, or `None` when no + /// proxy is configured or the host matches a `no_proxy` entry. + pub fn effective_proxy(&self, target_host: &str) -> Option<&str> { + let proxy = self.proxy.as_deref()?; + if host_bypasses_proxy(target_host, &self.no_proxy) { + None + } else { + Some(proxy) + } + } + pub fn max_retries(&self) -> u32 { self.retries.unwrap_or(1) } @@ -102,3 +119,211 @@ impl RequestConfig { std::time::Duration::from_millis(self.retry_wait_max_ms.unwrap_or(30000)) } } + +use std::net::IpAddr; + +/// Returns true if `host` matches any `no_proxy` pattern, meaning a request to +/// it should bypass the configured proxy. Matching follows the conventional +/// NO_PROXY rules: +/// +/// - `*` matches every host. +/// - A CIDR (`10.0.0.0/8`, `fd00::/8`) matches when `host` is an IP inside it. +/// - A bare IP matches that exact address. +/// - Anything else is treated as a domain: it matches the domain itself and any +/// subdomain, case-insensitively. Leading `*.` or `.` are accepted and +/// ignored (`*.corp`, `.corp`, and `corp` are equivalent). +pub(crate) fn host_bypasses_proxy(host: &str, patterns: &[String]) -> bool { + if host.is_empty() { + return false; + } + // Strip brackets from IPv6 literals so "[::1]" parses as an address. + let host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + let host_ip = host.parse::().ok(); + let host_lower = host.trim_end_matches('.').to_ascii_lowercase(); + + for pattern in patterns { + let pattern = pattern.trim(); + if pattern.is_empty() { + continue; + } + if pattern == "*" { + return true; + } + + if let Some((net, prefix)) = pattern.split_once('/') { + // CIDR: only meaningful when the target is an IP. + if let (Some(ip), Ok(net_ip), Ok(prefix_len)) = ( + host_ip, + net.trim().parse::(), + prefix.trim().parse::(), + ) && ip_in_cidr(ip, net_ip, prefix_len) + { + return true; + } + continue; + } + + if let Ok(pattern_ip) = pattern.parse::() { + if Some(pattern_ip) == host_ip { + return true; + } + continue; + } + + // Hostname / domain-suffix match. An IP target never matches a name. + if host_ip.is_some() { + continue; + } + let suffix = pattern + .trim_start_matches('*') + .trim_matches('.') + .to_ascii_lowercase(); + if suffix.is_empty() { + continue; + } + if host_lower == suffix || host_lower.ends_with(&format!(".{}", suffix)) { + return true; + } + } + false +} + +/// True if `ip` falls within the `net`/`prefix_len` CIDR block. Mismatched +/// address families (v4 vs v6) never match. +fn ip_in_cidr(ip: IpAddr, net: IpAddr, prefix_len: u8) -> bool { + match (ip, net) { + (IpAddr::V4(ip), IpAddr::V4(net)) => { + if prefix_len > 32 { + return false; + } + if prefix_len == 0 { + return true; + } + let mask = u32::MAX << (32 - prefix_len); + (u32::from(ip) & mask) == (u32::from(net) & mask) + } + (IpAddr::V6(ip), IpAddr::V6(net)) => { + if prefix_len > 128 { + return false; + } + if prefix_len == 0 { + return true; + } + let mask = u128::MAX << (128 - prefix_len); + (u128::from(ip) & mask) == (u128::from(net) & mask) + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pats(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn exact_hostname() { + assert!(host_bypasses_proxy("localhost", &pats(&["localhost"]))); + assert!(host_bypasses_proxy( + "elastic.corp", + &pats(&["elastic.corp"]) + )); + assert!(!host_bypasses_proxy( + "example.com", + &pats(&["elastic.corp"]) + )); + } + + #[test] + fn case_insensitive_and_trailing_dot() { + assert!(host_bypasses_proxy("LocalHost.", &pats(&["localhost"]))); + assert!(host_bypasses_proxy( + "API.Internal.Corp", + &pats(&["*.internal.corp"]) + )); + } + + #[test] + fn domain_suffix_forms_are_equivalent() { + for p in ["*.internal.corp", ".internal.corp", "internal.corp"] { + assert!( + host_bypasses_proxy("api.internal.corp", &pats(&[p])), + "subdomain should match {p}" + ); + assert!( + host_bypasses_proxy("internal.corp", &pats(&[p])), + "apex should match {p}" + ); + } + // A suffix must align on a label boundary. + assert!(!host_bypasses_proxy( + "notinternal.corp", + &pats(&["*.internal.corp"]) + )); + assert!(!host_bypasses_proxy( + "internal.corp.evil.com", + &pats(&["internal.corp"]) + )); + } + + #[test] + fn bare_ip() { + assert!(host_bypasses_proxy("127.0.0.1", &pats(&["127.0.0.1"]))); + assert!(!host_bypasses_proxy("127.0.0.2", &pats(&["127.0.0.1"]))); + assert!(host_bypasses_proxy("::1", &pats(&["::1"]))); + assert!(host_bypasses_proxy("[::1]", &pats(&["::1"]))); + } + + #[test] + fn cidr_v4() { + let p = pats(&["10.0.0.0/8"]); + assert!(host_bypasses_proxy("10.1.2.3", &p)); + assert!(host_bypasses_proxy("10.255.255.255", &p)); + assert!(!host_bypasses_proxy("11.0.0.1", &p)); + // A hostname is never inside a CIDR. + assert!(!host_bypasses_proxy("ten.example.com", &p)); + } + + #[test] + fn cidr_v6_and_zero_prefix() { + assert!(host_bypasses_proxy("fd00::1", &pats(&["fd00::/8"]))); + assert!(!host_bypasses_proxy("fe00::1", &pats(&["fd00::/8"]))); + assert!(host_bypasses_proxy("8.8.8.8", &pats(&["0.0.0.0/0"]))); + } + + #[test] + fn family_mismatch_never_matches() { + assert!(!host_bypasses_proxy("10.0.0.1", &pats(&["fd00::/8"]))); + assert!(!host_bypasses_proxy("fd00::1", &pats(&["10.0.0.0/8"]))); + } + + #[test] + fn wildcard_and_empty() { + assert!(host_bypasses_proxy("anything.com", &pats(&["*"]))); + assert!(!host_bypasses_proxy("anything.com", &pats(&[]))); + assert!(!host_bypasses_proxy("", &pats(&["*"]))); + assert!(!host_bypasses_proxy("host", &pats(&["", " "]))); + } + + #[test] + fn effective_proxy_respects_exclusions() { + let mut cfg = RequestConfig::new("http://x/".into()); + cfg.proxy = Some("http://proxy:8080".into()); + cfg.no_proxy = pats(&["127.0.0.1", "*.internal.corp"]); + assert_eq!( + cfg.effective_proxy("example.com"), + Some("http://proxy:8080") + ); + assert_eq!(cfg.effective_proxy("127.0.0.1"), None); + assert_eq!(cfg.effective_proxy("api.internal.corp"), None); + // No proxy configured -> always None regardless of no_proxy. + cfg.proxy = None; + assert_eq!(cfg.effective_proxy("example.com"), None); + } +} diff --git a/src/main.rs b/src/main.rs index 38b171e..24a5219 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,11 @@ struct Cli { #[arg(short = 'x', long)] proxy: Option, + /// Hosts that bypass the proxy (repeatable). Accepts hostnames, domain + /// suffixes (*.corp), IPs, or CIDRs (10.0.0.0/8). + #[arg(long = "no-proxy", num_args = 1)] + no_proxy: Vec, + /// OpenSSL cipher string (e.g. "ALL", "HIGH", "RC4-SHA") #[arg(long)] ciphers: Option, @@ -109,6 +114,7 @@ fn build_config(cli: &Cli, url: String) -> RequestConfig { config.timeout_seconds = cli.timeout; config.max_body_size = cli.max_body_size; config.proxy = cli.proxy.clone(); + config.no_proxy = cli.no_proxy.clone(); config.cipher_string = cli.ciphers.clone(); config.min_tls_version = cli.min_tls.clone(); config.max_tls_version = cli.max_tls.clone(); diff --git a/src/python.rs b/src/python.rs index cb0ac5a..2a95a0e 100644 --- a/src/python.rs +++ b/src/python.rs @@ -871,6 +871,7 @@ impl BlastHTTP { max_redirects=None, verify_certs=None, proxy=None, + no_proxy=None, cipher_string=None, min_tls_version=None, max_tls_version=None, @@ -896,6 +897,7 @@ impl BlastHTTP { max_redirects: Option, verify_certs: Option, proxy: Option, + no_proxy: Option>, cipher_string: Option, min_tls_version: Option, max_tls_version: Option, @@ -919,6 +921,7 @@ impl BlastHTTP { max_redirects, verify_certs, proxy, + no_proxy: no_proxy.unwrap_or_default(), cipher_string, min_tls_version, max_tls_version, @@ -1079,6 +1082,7 @@ impl BlastHTTP { timeout=None, verify_certs=None, proxy=None, + no_proxy=None, headers=None, retries=None, ))] @@ -1092,6 +1096,7 @@ impl BlastHTTP { timeout: Option, verify_certs: Option, proxy: Option, + no_proxy: Option>, headers: Option>, retries: Option, ) -> PyResult> { @@ -1106,6 +1111,7 @@ impl BlastHTTP { max_redirects: Some(10), verify_certs, proxy, + no_proxy: no_proxy.unwrap_or_default(), cipher_string: None, min_tls_version: None, max_tls_version: None, @@ -1163,6 +1169,7 @@ impl BlastHTTP { max_tls_version=None, resolve_ip=None, proxy=None, + no_proxy=None, alpn_protocols=None, ))] #[allow(clippy::too_many_arguments)] @@ -1176,6 +1183,7 @@ impl BlastHTTP { max_tls_version: Option, resolve_ip: Option, proxy: Option, + no_proxy: Option>, alpn_protocols: Option>, ) -> PyResult> { let mut config = RequestConfig::new(url.clone()); @@ -1185,6 +1193,7 @@ impl BlastHTTP { config.max_tls_version = max_tls_version; config.resolve_ip = resolve_ip; config.proxy = proxy; + config.no_proxy = no_proxy.unwrap_or_default(); config.alpn_protocols = alpn_protocols; let limiter = self.rate_limiter.clone(); @@ -1330,6 +1339,8 @@ struct PyBatchConfig { #[pyo3(get, set)] proxy: Option, #[pyo3(get, set)] + no_proxy: Option>, + #[pyo3(get, set)] cipher_string: Option, #[pyo3(get, set)] min_tls_version: Option, @@ -1363,6 +1374,7 @@ impl PyBatchConfig { max_redirects=None, verify_certs=None, proxy=None, + no_proxy=None, cipher_string=None, min_tls_version=None, max_tls_version=None, @@ -1385,6 +1397,7 @@ impl PyBatchConfig { max_redirects: Option, verify_certs: Option, proxy: Option, + no_proxy: Option>, cipher_string: Option, min_tls_version: Option, max_tls_version: Option, @@ -1406,6 +1419,7 @@ impl PyBatchConfig { max_redirects, verify_certs, proxy, + no_proxy, cipher_string, min_tls_version, max_tls_version, @@ -1432,6 +1446,7 @@ impl Clone for PyBatchConfig { max_redirects: self.max_redirects, verify_certs: self.verify_certs, proxy: self.proxy.clone(), + no_proxy: self.no_proxy.clone(), cipher_string: self.cipher_string.clone(), min_tls_version: self.min_tls_version.clone(), max_tls_version: self.max_tls_version.clone(), @@ -1461,6 +1476,7 @@ impl PyBatchConfig { max_redirects: self.max_redirects, verify_certs: self.verify_certs, proxy: self.proxy, + no_proxy: self.no_proxy.unwrap_or_default(), cipher_string: self.cipher_string, min_tls_version: self.min_tls_version, max_tls_version: self.max_tls_version, From 9cb4fd7b22ed27231ad4a66578e232356d7af040 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 1 Jun 2026 20:08:03 -0400 Subject: [PATCH 2/4] make no_proxy matcher allocation-free Compare host against suffixes on bytes via eq_ignore_ascii_case instead of lowercasing the host and building a format! string per pattern. Runs per request when a proxy is set. --- src/config.rs | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/config.rs b/src/config.rs index fd9ee6b..5fd755e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -142,7 +142,7 @@ pub(crate) fn host_bypasses_proxy(host: &str, patterns: &[String]) -> bool { .and_then(|h| h.strip_suffix(']')) .unwrap_or(host); let host_ip = host.parse::().ok(); - let host_lower = host.trim_end_matches('.').to_ascii_lowercase(); + let host = host.trim_end_matches('.'); for pattern in patterns { let pattern = pattern.trim(); @@ -177,20 +177,29 @@ pub(crate) fn host_bypasses_proxy(host: &str, patterns: &[String]) -> bool { if host_ip.is_some() { continue; } - let suffix = pattern - .trim_start_matches('*') - .trim_matches('.') - .to_ascii_lowercase(); - if suffix.is_empty() { - continue; - } - if host_lower == suffix || host_lower.ends_with(&format!(".{}", suffix)) { + let suffix = pattern.trim_start_matches('*').trim_matches('.'); + if !suffix.is_empty() && host_matches_suffix(host, suffix) { return true; } } false } +/// Case-insensitive check that `host` equals `suffix` or is a subdomain of it +/// (i.e. `host` ends in `.`). Operates on bytes to avoid allocating. +fn host_matches_suffix(host: &str, suffix: &str) -> bool { + let (host, suffix) = (host.as_bytes(), suffix.as_bytes()); + if host.len() == suffix.len() { + return host.eq_ignore_ascii_case(suffix); + } + match host.len().checked_sub(suffix.len()) { + Some(start) if start >= 1 && host[start - 1] == b'.' => { + host[start..].eq_ignore_ascii_case(suffix) + } + _ => false, + } +} + /// True if `ip` falls within the `net`/`prefix_len` CIDR block. Mismatched /// address families (v4 vs v6) never match. fn ip_in_cidr(ip: IpAddr, net: IpAddr, prefix_len: u8) -> bool { From 50554145b9a7ec5be00cd2d3718f99af358f6638 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 1 Jun 2026 20:55:04 -0400 Subject: [PATCH 3/4] add CLI integration test for --no-proxy --- tests/cli_no_proxy.rs | 129 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/cli_no_proxy.rs diff --git a/tests/cli_no_proxy.rs b/tests/cli_no_proxy.rs new file mode 100644 index 0000000..6091d97 --- /dev/null +++ b/tests/cli_no_proxy.rs @@ -0,0 +1,129 @@ +// Integration tests for the `--no-proxy` CLI flag. +// +// These spawn the compiled `blasthttp` binary against a local origin server +// and a hit-counting HTTP proxy, then assert (via the response body and the +// proxy's hit counter) whether each request went through the proxy or +// connected directly. This is the one no_proxy surface the library-level +// tests don't exercise: the CLI arg -> build_config -> effective_proxy path. + +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::process::Command; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread; +use std::time::Duration; + +/// Spawn a minimal HTTP/1.1 server that answers every request with `body`. +/// If `counter` is set, it's incremented once per accepted connection, which +/// is how we tell whether the proxy was hit. Returns the bound port. The listener +/// thread is detached and lives for the rest of the test process. +fn spawn_server(body: &'static str, counter: Option>) -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + if let Some(ref c) = counter { + c.fetch_add(1, Ordering::SeqCst); + } + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + // Drain the request head so the client isn't writing into a + // half-closed socket when we reply. + let mut buf = Vec::new(); + let mut tmp = [0u8; 512]; + while let Ok(n) = stream.read(&mut tmp) { + if n == 0 { + break; + } + buf.extend_from_slice(&tmp[..n]); + if buf.windows(4).any(|w| w == b"\r\n\r\n") || buf.len() > 16384 { + break; + } + } + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + }); + port +} + +/// Run the CLI against `url` through `proxy` with the given `--no-proxy` +/// entries, returning combined stdout+stderr. +fn run_cli(url: &str, proxy: &str, no_proxy: &[&str]) -> String { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_blasthttp")); + cmd.arg(url).arg("-x").arg(proxy); + for entry in no_proxy { + cmd.arg("--no-proxy").arg(entry); + } + cmd.arg("-vv"); // include the response body in the JSON output + let output = cmd.output().expect("failed to run blasthttp binary"); + let mut combined = String::from_utf8_lossy(&output.stdout).into_owned(); + combined.push_str(&String::from_utf8_lossy(&output.stderr)); + combined +} + +/// Fresh origin + proxy per case so the hit counter is isolated. When +/// `expect_direct`, the request must reach the origin (`TARGET_DIRECT`) and +/// never touch the proxy; otherwise it must go through the proxy. +fn assert_routing(no_proxy: &[&str], expect_direct: bool) { + let proxy_hits = Arc::new(AtomicUsize::new(0)); + let target_port = spawn_server("TARGET_DIRECT", None); + let proxy_port = spawn_server("VIA_PROXY", Some(proxy_hits.clone())); + + let url = format!("http://127.0.0.1:{target_port}/foo"); + let proxy = format!("http://127.0.0.1:{proxy_port}"); + let out = run_cli(&url, &proxy, no_proxy); + + if expect_direct { + assert!( + out.contains("TARGET_DIRECT"), + "expected direct connection, got: {out}" + ); + assert_eq!( + proxy_hits.load(Ordering::SeqCst), + 0, + "proxy should have been bypassed" + ); + } else { + assert!( + out.contains("VIA_PROXY"), + "expected proxied connection, got: {out}" + ); + assert_eq!( + proxy_hits.load(Ordering::SeqCst), + 1, + "proxy should have been used once" + ); + } +} + +#[test] +fn no_exclusion_uses_proxy() { + assert_routing(&[], false); +} + +#[test] +fn exact_ip_bypasses_proxy() { + assert_routing(&["127.0.0.1"], true); +} + +#[test] +fn non_matching_cidr_uses_proxy() { + assert_routing(&["10.0.0.0/8"], false); +} + +#[test] +fn matching_cidr_bypasses_proxy() { + assert_routing(&["127.0.0.0/8"], true); +} + +#[test] +fn wildcard_bypasses_proxy() { + assert_routing(&["*"], true); +} From 2c86b72adeb59828310a15809d142c21a6accd16 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 3 Jun 2026 11:12:24 -0400 Subject: [PATCH 4/4] fix: re-evaluate proxy/no_proxy decision on every redirect hop The connection mode (direct vs forward-proxy/tunnel/socks5) was decided once before the redirect loop, so a cross-host redirect kept the first URL's decision. With no_proxy set this could route a redirected request directly past the proxy, or send a no_proxy host through the proxy. Move the conn_mode decision inside the redirect loop so it is recomputed against each hop's host. Clients are still cached per mode, so hops that share a mode reuse the same client. Add CLI (tests/cli_no_proxy.rs) and Python-binding (tests/python/test_no_proxy.py) regression tests covering both directions of a cross-host redirect. --- src/client/hyper.rs | 70 ++++++++++-------- tests/cli_no_proxy.rs | 134 +++++++++++++++++++++++++++++----- tests/python/test_no_proxy.py | 114 +++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 49 deletions(-) create mode 100644 tests/python/test_no_proxy.py diff --git a/src/client/hyper.rs b/src/client/hyper.rs index 5555f1d..0fe1d5b 100644 --- a/src/client/hyper.rs +++ b/src/client/hyper.rs @@ -1321,41 +1321,37 @@ impl HyperClient { }); } - let mode = Self::conn_mode(config, &uri)?; - - // Forward proxy: dispatch directly via TCP + http1::SendRequest - // (bypasses hyper Client's URI normalization to preserve absolute-form) - let is_forward_proxy = matches!(&mode, ConnMode::ForwardProxy(_)); - let proxy_url_for_fwd = if let ConnMode::ForwardProxy(ref url) = mode { - Some(url.clone()) - } else { - None - }; - - // For non-forward-proxy modes, get the cached hyper Client - let cached = if is_forward_proxy { - None - } else { - Some(self.get_or_build(config, &mode)?) - }; - let mut redirect_chain: Vec = Vec::new(); let mut hops = 0u32; - // Per-hop peer IP lookup. Returns None when: - // • the request went through a proxy (the connector recorded - // the proxy IP under the proxy's authority, so the target's - // authority isn't in the map) - // • forward-proxy dispatch (bypasses the connector entirely) - // • peer_addr() failed at connect time (vanishingly rare) - let lookup_peer_ip = |target_uri: &http::Uri| -> Option { - let key = peer_slot_key(target_uri)?; - let cached = cached.as_ref()?; - let map = cached.peer_slot.lock().ok()?; - map.get(&key).map(|ip| ip.to_string()) - }; - loop { + // Decide the connection mode for the *current* target host on every + // hop, not just the first. A redirect can send the request to a + // different host, and the proxy / no_proxy decision has to follow + // it. Freezing the first hop's choice would otherwise let a request + // that started direct keep connecting directly after a redirect onto + // a proxied host (leaking traffic past the proxy), and let a request + // that started proxied keep using the proxy after a redirect onto a + // no_proxy host. Clients are cached by mode, so hops that share a + // mode reuse the same client. + let mode = Self::conn_mode(config, &uri)?; + + // Forward proxy: dispatch directly via TCP + http1::SendRequest + // (bypasses hyper Client's URI normalization to preserve absolute-form) + let is_forward_proxy = matches!(&mode, ConnMode::ForwardProxy(_)); + let proxy_url_for_fwd = if let ConnMode::ForwardProxy(ref url) = mode { + Some(url.clone()) + } else { + None + }; + + // For non-forward-proxy modes, get the cached hyper Client. + let cached = if is_forward_proxy { + None + } else { + Some(self.get_or_build(config, &mode)?) + }; + let resp = if let Some(ref proxy_url) = proxy_url_for_fwd { dispatch_forward_proxy(proxy_url, &uri, config, log).await? } else { @@ -1364,7 +1360,17 @@ impl HyperClient { let hop_ms = start.elapsed().as_millis(); debug_record(log, v, 1, &format!("<- {} ({}ms)", resp.status, hop_ms)); - let hop_peer_ip = lookup_peer_ip(&uri); + // Per-hop peer IP lookup. Returns None when: + // • the request went through a proxy (the connector recorded + // the proxy IP under the proxy's authority, so the target's + // authority isn't in the map) + // • forward-proxy dispatch (bypasses the connector entirely) + // • peer_addr() failed at connect time (vanishingly rare) + let hop_peer_ip = peer_slot_key(&uri).and_then(|key| { + let cached = cached.as_ref()?; + let map = cached.peer_slot.lock().ok()?; + map.get(&key).map(|ip| ip.to_string()) + }); if is_redirect(resp.status) && config.should_follow_redirects() { hops += 1; diff --git a/tests/cli_no_proxy.rs b/tests/cli_no_proxy.rs index 6091d97..853a9ca 100644 --- a/tests/cli_no_proxy.rs +++ b/tests/cli_no_proxy.rs @@ -14,12 +14,13 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; use std::time::Duration; -/// Spawn a minimal HTTP/1.1 server that answers every request with `body`. -/// If `counter` is set, it's incremented once per accepted connection, which -/// is how we tell whether the proxy was hit. Returns the bound port. The listener -/// thread is detached and lives for the rest of the test process. -fn spawn_server(body: &'static str, counter: Option>) -> u16 { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); +/// Listener core: bind a loopback socket on `bind_host`, then for every +/// accepted connection bump `counter` (if set), drain the request head, and +/// write the fixed `response`. The thread is detached and lives for the rest of +/// the test process. Returns the bound port. `counter` is how we tell whether a +/// given host was actually connected to. +fn spawn_raw(bind_host: &str, response: String, counter: Option>) -> u16 { + let listener = TcpListener::bind(format!("{bind_host}:0")).unwrap(); let port = listener.local_addr().unwrap().port(); thread::spawn(move || { for stream in listener.incoming() { @@ -41,26 +42,47 @@ fn spawn_server(body: &'static str, counter: Option>) -> u16 { break; } } - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(resp.as_bytes()); + let _ = stream.write_all(response.as_bytes()); let _ = stream.flush(); } }); port } +/// HTTP/1.1 server on `bind_host` that answers every request with a 200 + `body`. +fn spawn_server(bind_host: &str, body: &str, counter: Option>) -> u16 { + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + spawn_raw(bind_host, response, counter) +} + +/// HTTP/1.1 server on `bind_host` that 302-redirects every request to `location`. +fn spawn_redirect_server( + bind_host: &str, + location: &str, + counter: Option>, +) -> u16 { + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + spawn_raw(bind_host, response, counter) +} + /// Run the CLI against `url` through `proxy` with the given `--no-proxy` -/// entries, returning combined stdout+stderr. -fn run_cli(url: &str, proxy: &str, no_proxy: &[&str]) -> String { +/// entries, returning combined stdout+stderr. When `follow`, pass `-L` so +/// redirects are followed (each hop re-evaluates the proxy decision). +fn run_cli(url: &str, proxy: &str, no_proxy: &[&str], follow: bool) -> String { let mut cmd = Command::new(env!("CARGO_BIN_EXE_blasthttp")); cmd.arg(url).arg("-x").arg(proxy); for entry in no_proxy { cmd.arg("--no-proxy").arg(entry); } + if follow { + cmd.arg("-L"); + } cmd.arg("-vv"); // include the response body in the JSON output let output = cmd.output().expect("failed to run blasthttp binary"); let mut combined = String::from_utf8_lossy(&output.stdout).into_owned(); @@ -73,12 +95,12 @@ fn run_cli(url: &str, proxy: &str, no_proxy: &[&str]) -> String { /// never touch the proxy; otherwise it must go through the proxy. fn assert_routing(no_proxy: &[&str], expect_direct: bool) { let proxy_hits = Arc::new(AtomicUsize::new(0)); - let target_port = spawn_server("TARGET_DIRECT", None); - let proxy_port = spawn_server("VIA_PROXY", Some(proxy_hits.clone())); + let target_port = spawn_server("127.0.0.1", "TARGET_DIRECT", None); + let proxy_port = spawn_server("127.0.0.1", "VIA_PROXY", Some(proxy_hits.clone())); let url = format!("http://127.0.0.1:{target_port}/foo"); let proxy = format!("http://127.0.0.1:{proxy_port}"); - let out = run_cli(&url, &proxy, no_proxy); + let out = run_cli(&url, &proxy, no_proxy, false); if expect_direct { assert!( @@ -127,3 +149,81 @@ fn matching_cidr_bypasses_proxy() { fn wildcard_bypasses_proxy() { assert_routing(&["*"], true); } + +// The proxy decision must be re-made on every redirect hop, not frozen at the +// first URL. Two distinct loopback IPs give the two hops distinct host strings +// so a single `--no-proxy` entry can exclude one but not the other. + +/// Case A — start on an excluded host (direct), which redirects onto a host +/// that is *not* excluded. The post-redirect hop must go through the proxy. +/// With the decision frozen at hop 1 it stayed direct, leaking the redirected +/// request straight out instead of through the proxy. +#[test] +fn redirect_onto_proxied_host_uses_proxy() { + let proxy_hits = Arc::new(AtomicUsize::new(0)); + // Reached only if the post-redirect hop wrongly stays direct. + let target_port = spawn_server("127.0.0.1", "TARGET_DIRECT", None); + // Excluded first host (127.0.0.2) redirects to the non-excluded host. + let redirect_port = spawn_redirect_server( + "127.0.0.2", + &format!("http://127.0.0.1:{target_port}/next"), + None, + ); + let proxy_port = spawn_server("127.0.0.1", "VIA_PROXY", Some(proxy_hits.clone())); + + let url = format!("http://127.0.0.2:{redirect_port}/start"); + let proxy = format!("http://127.0.0.1:{proxy_port}"); + let out = run_cli(&url, &proxy, &["127.0.0.2"], true); + + assert!( + out.contains("VIA_PROXY"), + "post-redirect hop should have been proxied, got: {out}" + ); + assert!( + !out.contains("TARGET_DIRECT"), + "post-redirect hop must not connect directly, got: {out}" + ); + assert_eq!( + proxy_hits.load(Ordering::SeqCst), + 1, + "the proxied (second) hop should have hit the proxy exactly once" + ); +} + +/// Case B — start on a proxied host whose response redirects onto an excluded +/// host. The post-redirect hop must connect directly. With the decision frozen +/// at hop 1 it kept using the proxy, which here re-serves the redirect and +/// loops until the redirect limit instead of reaching the excluded host. +#[test] +fn redirect_onto_no_proxy_host_connects_direct() { + let proxy_hits = Arc::new(AtomicUsize::new(0)); + let direct_hits = Arc::new(AtomicUsize::new(0)); + // Excluded redirect target — reached only via a direct connection. + let target_port = spawn_server("127.0.0.2", "TARGET_DIRECT", Some(direct_hits.clone())); + // The proxy redirects every request to the excluded host. + let proxy_port = spawn_redirect_server( + "127.0.0.1", + &format!("http://127.0.0.2:{target_port}/next"), + Some(proxy_hits.clone()), + ); + + // Start host (127.0.0.1) is not excluded -> hop 1 is proxied. + let url = format!("http://127.0.0.1:{proxy_port}/start"); + let proxy = format!("http://127.0.0.1:{proxy_port}"); + let out = run_cli(&url, &proxy, &["127.0.0.2"], true); + + assert!( + out.contains("TARGET_DIRECT"), + "post-redirect hop should have reached the excluded host directly, got: {out}" + ); + assert_eq!( + direct_hits.load(Ordering::SeqCst), + 1, + "the excluded host should have been connected to directly exactly once" + ); + assert_eq!( + proxy_hits.load(Ordering::SeqCst), + 1, + "the proxy should only have served the first hop, not the redirect loop" + ); +} diff --git a/tests/python/test_no_proxy.py b/tests/python/test_no_proxy.py new file mode 100644 index 0000000..415ce00 --- /dev/null +++ b/tests/python/test_no_proxy.py @@ -0,0 +1,114 @@ +"""Pytest-asyncio tests: the proxy / no_proxy decision is re-made on every +redirect hop, not frozen at the first URL. + +These exercise the PyO3 bindings with the request(proxy=, no_proxy=, +follow_redirects=True) call shape. Two loopback IPs (127.0.0.1 / 127.0.0.2) +give the two redirect hops distinct host strings that a single no_proxy entry +can tell apart. +""" + +import asyncio + +import blasthttp +import pytest +import pytest_asyncio + + +def _http_200(body): + return (f"HTTP/1.1 200 OK\r\nContent-Length: {len(body)}\r\nConnection: close\r\n\r\n{body}").encode() + + +def _http_302(location): + return (f"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").encode() + + +@pytest_asyncio.fixture +async def http_factory(): + """Factory that spawns fixed-response HTTP/1.1 servers and tears them all + down on cleanup. Each call returns (port, counter), where counter is a + one-element list bumped once per accepted connection — how we tell whether a + given host was actually connected to.""" + servers = [] + + async def spawn(bind_host, response): + counter = [0] + + async def handle(reader, writer): + try: + # Drain the request head before replying. + buf = b"" + while b"\r\n\r\n" not in buf and len(buf) < 16384: + chunk = await reader.read(512) + if not chunk: + break + buf += chunk + counter[0] += 1 + writer.write(response) + await writer.drain() + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + server = await asyncio.start_server(handle, bind_host, 0) + servers.append(server) + return server.sockets[0].getsockname()[1], counter + + try: + yield spawn + finally: + for server in servers: + server.close() + for server in servers: + try: + await server.wait_closed() + except Exception: + pass + + +@pytest.fixture +def client(): + return blasthttp.BlastHTTP() + + +async def test_redirect_onto_proxied_host_reevaluates_no_proxy(client, http_factory): + """Case A (no leak): an excluded first host (direct) redirects onto a + non-excluded host. The post-redirect hop must go through the proxy, not + connect directly.""" + # Reached only if the post-redirect hop wrongly stays direct. + target_port, _ = await http_factory("127.0.0.1", _http_200("TARGET_DIRECT")) + redirect_port, _ = await http_factory("127.0.0.2", _http_302(f"http://127.0.0.1:{target_port}/next")) + proxy_port, proxy_hits = await http_factory("127.0.0.1", _http_200("VIA_PROXY")) + + r = await client.request( + f"http://127.0.0.2:{redirect_port}/start", + proxy=f"http://127.0.0.1:{proxy_port}", + no_proxy=["127.0.0.2"], + follow_redirects=True, + ) + + assert r.body == "VIA_PROXY", f"post-redirect hop should be proxied, got {r.body!r}" + assert proxy_hits[0] == 1, f"proxy should be hit once, got {proxy_hits[0]}" + + +async def test_redirect_onto_no_proxy_host_reevaluates_to_direct(client, http_factory): + """Case B: a proxied first host redirects onto an excluded host. The + post-redirect hop must connect directly, not keep using the proxy.""" + # Excluded redirect target — reached only via a direct connection. + target_port, direct_hits = await http_factory("127.0.0.2", _http_200("TARGET_DIRECT")) + # The proxy redirects every request onto the excluded host. + proxy_port, proxy_hits = await http_factory("127.0.0.1", _http_302(f"http://127.0.0.2:{target_port}/next")) + + # Start host (127.0.0.1) is not excluded -> hop 1 is proxied. + r = await client.request( + f"http://127.0.0.1:{proxy_port}/start", + proxy=f"http://127.0.0.1:{proxy_port}", + no_proxy=["127.0.0.2"], + follow_redirects=True, + ) + + assert r.body == "TARGET_DIRECT", f"post-redirect hop should be direct, got {r.body!r}" + assert direct_hits[0] == 1, f"excluded host should be hit directly once, got {direct_hits[0]}" + assert proxy_hits[0] == 1, f"proxy should only serve hop 1, got {proxy_hits[0]}"