Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
76 changes: 41 additions & 35 deletions src/client/hyper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConnMode, ClientError> {
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 =
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<RedirectHop> = 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<String> {
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 {
Expand All @@ -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;
Expand Down
234 changes: 234 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ pub struct RequestConfig {
pub max_redirects: Option<u32>,
pub verify_certs: Option<bool>,
pub proxy: Option<String>,
/// 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<String>,
pub cipher_string: Option<String>,
pub min_tls_version: Option<String>,
pub max_tls_version: Option<String>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand All @@ -102,3 +119,220 @@ 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::<IpAddr>().ok();
let host = host.trim_end_matches('.');

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::<IpAddr>(),
prefix.trim().parse::<u8>(),
) && ip_in_cidr(ip, net_ip, prefix_len)
{
return true;
}
continue;
}

if let Ok(pattern_ip) = pattern.parse::<IpAddr>() {
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('.');
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 `.<suffix>`). 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 {
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<String> {
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);
}
}
Loading
Loading