From 2ba4afab0aa3470f586e455e3366025d4987f436 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:42:38 +0000 Subject: [PATCH 1/5] chore(deps): bump http from 1.4.1 to 1.4.2 Bumps [http](https://github.com/hyperium/http) from 1.4.1 to 1.4.2. - [Release notes](https://github.com/hyperium/http/releases) - [Changelog](https://github.com/hyperium/http/blob/master/CHANGELOG.md) - [Commits](https://github.com/hyperium/http/compare/v1.4.1...v1.4.2) --- updated-dependencies: - dependency-name: http dependency-version: 1.4.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 79d78c0..a813a57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -385,9 +385,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", From 3b985af6bbc3a2487f3c9d77db2fa189fe451361 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:42:44 +0000 Subject: [PATCH 2/5] chore(deps-dev): bump ruff from 0.15.15 to 0.15.17 Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.15 to 0.15.17. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.15...0.15.17) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.17 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d2dcc0b..10f36ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ managed = false dev = [ "pytest>=8", "pytest-asyncio>=0.23", - "ruff==0.15.15", + "ruff==0.15.17", ] [tool.pytest.ini_options] From 42695299894d4d7a7b3f7b25d13e0d12d6171c7d Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 15 Jun 2026 16:23:22 -0400 Subject: [PATCH 3/5] Bump version to 0.9.0 --- Cargo.toml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 11d73b9..d8a7259 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "blasthttp" -version = "0.8.0" +version = "0.9.0" edition = "2024" description = "Offensive-first HTTP library with Python bindings" license = "GPL-3.0" diff --git a/pyproject.toml b/pyproject.toml index 10f36ea..04de51b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "blasthttp" -version = "0.8.0" +version = "0.9.0" description = "Offensive-first HTTP library" license = "GPL-3.0" requires-python = ">=3.10" From a34fef145a93a5cbb7e4ba56ef30ccacbc684858 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:28:59 -0400 Subject: [PATCH 4/5] chore(deps): bump openssl from 0.10.80 to 0.10.81 (#45) Bumps [openssl](https://github.com/rust-openssl/rust-openssl) from 0.10.80 to 0.10.81. - [Release notes](https://github.com/rust-openssl/rust-openssl/releases) - [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.80...openssl-v0.10.81) --- updated-dependencies: - dependency-name: openssl dependency-version: 0.10.81 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a813a57..4543d06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -565,9 +565,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags", "cfg-if", @@ -590,9 +590,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", From ca48fb670be9d1fd4b98c4a6dc2847a2c644e9a7 Mon Sep 17 00:00:00 2001 From: Paul Mueller Date: Mon, 15 Jun 2026 17:19:20 -0400 Subject: [PATCH 5/5] Fix SSL certificate verification (#47) * Fix SSL certificate verification (verify_certs=True) - Load system CA certs explicitly instead of relying on set_default_verify_paths(), which fails when OpenSSL is vendored (compiled-in OPENSSLDIR points to nonexistent path) - Include verify_certs in ConnMode cache key so connections with different verify settings don't share a cached hyper Client - Add timeout parameter to raw_connect Python binding - Wrap TCP connect in a timeout (was missing, causing hangs) - Add Rust and Python tests for all connection types * Replace deprecated as_utf8() with to_string() * Update Cargo.lock for 0.9.0 * Add hostname verification, timeout coverage, and TLS cache key fixes F1: verify_certs=True now checks hostname (set_host/set_ip on both TLS paths) F2: TLS and proxy handshakes bounded by connect timeout (pooled + raw paths) F3: ConnMode cache key includes cipher_string, TLS versions, and ALPN F4: load_system_ca_certs tracks dir loading to avoid unnecessary fallback * Fix rustfmt in tls_server.rs --- Cargo.lock | 2 +- src/client/hyper.rs | 255 ++++++++++++++++++--- src/python.rs | 3 + tests/python/test_ssl_verify.py | 387 ++++++++++++++++++++++++++++++++ tests/tls_integration.rs | 133 +++++++++++ tests/tls_server.rs | 24 +- 6 files changed, 760 insertions(+), 44 deletions(-) create mode 100644 tests/python/test_ssl_verify.py diff --git a/Cargo.lock b/Cargo.lock index 4543d06..504f57b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,7 +99,7 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "blasthttp" -version = "0.8.0" +version = "0.9.0" dependencies = [ "brotli", "bytes", diff --git a/src/client/hyper.rs b/src/client/hyper.rs index f189dc3..1845a30 100644 --- a/src/client/hyper.rs +++ b/src/client/hyper.rs @@ -112,8 +112,7 @@ fn extract_cert_info(ssl: &openssl::ssl::SslRef) -> Option { .subject_name() .entries_by_nid(openssl::nid::Nid::COMMONNAME) .next() - .and_then(|e| e.data().as_utf8().ok()) - .map(|s| s.to_string()); + .and_then(|e| e.data().to_string().ok()); // Subject Alternative Names (DNS entries) let sans = cert @@ -132,18 +131,18 @@ fn extract_cert_info(ssl: &openssl::ssl::SslRef) -> Option { .subject_name() .entries_by_nid(openssl::nid::Nid::PKCS9_EMAILADDRESS) { - if let Ok(s) = entry.data().as_utf8() { - emails.push(s.to_string()); + if let Ok(s) = entry.data().to_string() { + emails.push(s); } } for entry in cert .issuer_name() .entries_by_nid(openssl::nid::Nid::PKCS9_EMAILADDRESS) { - if let Ok(s) = entry.data().as_utf8() - && !emails.contains(&s.to_string()) + if let Ok(s) = entry.data().to_string() + && !emails.contains(&s) { - emails.push(s.to_string()); + emails.push(s); } } // Also check SANs for email addresses @@ -163,8 +162,7 @@ fn extract_cert_info(ssl: &openssl::ssl::SslRef) -> Option { .issuer_name() .entries_by_nid(openssl::nid::Nid::COMMONNAME) .next() - .and_then(|e| e.data().as_utf8().ok()) - .map(|s| s.to_string()); + .and_then(|e| e.data().to_string().ok()); // Validity dates (ASN1 time -> string) let not_before = cert.not_before().to_string(); @@ -207,6 +205,7 @@ struct OpenSslConnector { // opened, read after each redirect hop so the right IP gets stamped // on the `RedirectHop` (or final `Response`). peer_slot: PeerSlot, + connect_timeout: Duration, } /// Encode a list of ALPN protocol names into the wire format OpenSSL @@ -249,6 +248,81 @@ fn parse_tls_version(s: &str) -> Result { } } +/// Load system CA certificates into an SSL builder. +/// +/// OpenSSL's `set_default_verify_paths` looks at the OPENSSLDIR compiled into +/// the library, which may not match the system cert store (e.g. when OpenSSL +/// is vendored). This function checks `SSL_CERT_FILE` / `SSL_CERT_DIR` env +/// vars first, then probes well-known system paths. +fn load_system_ca_certs( + builder: &mut openssl::ssl::SslConnectorBuilder, +) -> Result<(), ClientError> { + use std::path::Path; + + static CA_FILE_PATHS: &[&str] = &[ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/ssl/cert.pem", + ]; + + static CA_DIR_PATHS: &[&str] = &[ + "/etc/ssl/certs", + "/etc/pki/tls/certs", + "/system/etc/security/cacerts", + ]; + + let mut loaded = false; + + // SSL_CERT_FILE env var takes priority + if let Ok(cert_file) = std::env::var("SSL_CERT_FILE") + && Path::new(&cert_file).is_file() + { + builder.set_ca_file(&cert_file).map_err(|e| { + ClientError::tls(format!("failed to load CA file '{}': {}", cert_file, e)) + })?; + loaded = true; + } + + if !loaded { + for path in CA_FILE_PATHS { + if Path::new(path).is_file() && builder.set_ca_file(path).is_ok() { + loaded = true; + break; + } + } + } + + // Also try SSL_CERT_DIR / well-known cert directories + if let Ok(cert_dir) = std::env::var("SSL_CERT_DIR") { + if Path::new(&cert_dir).is_dir() + && builder + .load_verify_locations(None, Some(Path::new(&cert_dir))) + .is_ok() + { + loaded = true; + } + } else { + for path in CA_DIR_PATHS { + let p = Path::new(path); + if p.is_dir() && builder.load_verify_locations(None, Some(p)).is_ok() { + loaded = true; + break; + } + } + } + + if !loaded { + // Last resort: try the compiled-in OpenSSL defaults + builder + .set_default_verify_paths() + .map_err(|e| ClientError::tls(format!("no system CA certificates found: {}", e)))?; + } + + Ok(()) +} + impl OpenSslConnector { fn new( config: &RequestConfig, @@ -268,6 +342,8 @@ impl OpenSslConnector { if !config.should_verify_certs() { builder.set_verify(openssl::ssl::SslVerifyMode::NONE); + } else { + load_system_ca_certs(&mut builder)?; } if let Some(ref ciphers) = config.cipher_string { @@ -297,14 +373,17 @@ impl OpenSslConnector { .map_err(|e| ClientError::tls(format!("failed to set ALPN: {}", e)))?; let ssl = builder.build(); + let connect_timeout = Duration::from_secs(config.timeout()); let mut http = HttpConnector::new(); http.enforce_http(false); + http.set_connect_timeout(Some(connect_timeout)); Ok(OpenSslConnector { http, ssl, cert_slot, peer_slot, + connect_timeout, }) } } @@ -416,6 +495,7 @@ impl tower_service::Service for OpenSslConnector { let ssl_connector = self.ssl.clone(); let cert_slot = self.cert_slot.clone(); let peer_slot = self.peer_slot.clone(); + let connect_timeout = self.connect_timeout; Box::pin(async move { let tcp = http_fut.await?; @@ -442,13 +522,35 @@ impl tower_service::Service for OpenSslConnector { .set_hostname(&host) .map_err(|e| -> Box { Box::new(e) })?; } + // Hostname verification: when verify mode is PEER, pin the + // expected identity so OpenSSL checks the cert's SAN/CN. + if ssl_conf.verify_mode() != openssl::ssl::SslVerifyMode::NONE { + let param = ssl_conf.param_mut(); + match host.parse::() { + Ok(ip) => param + .set_ip(ip) + .map_err(|e| -> Box { Box::new(e) })?, + Err(_) => param + .set_host(&host) + .map_err(|e| -> Box { Box::new(e) })?, + } + } let mut stream = tokio_openssl::SslStream::new(ssl_conf, tcp_stream) .map_err(|e| -> Box { Box::new(e) })?; - Pin::new(&mut stream) - .connect() + tokio::time::timeout(connect_timeout, Pin::new(&mut stream).connect()) .await + .map_err(|_| -> Box { + Box::new(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "TLS handshake with {} timed out after {}s", + host, + connect_timeout.as_secs() + ), + )) + })? .map_err(|e| -> Box { Box::new(e) })?; // Extract cert info after successful handshake @@ -497,17 +599,40 @@ enum AnyClient { Socks5(Socks5ProxyClient), } +/// TLS config fields that must be part of the client cache key. +/// Requests with different TLS settings must not share a cached client. +#[derive(Clone, Hash, Eq, PartialEq)] +struct TlsKey { + verify_certs: bool, + cipher_string: Option, + min_tls_version: Option, + max_tls_version: Option, + alpn_protocols: Option>, +} + +impl TlsKey { + fn from_config(config: &RequestConfig) -> Self { + TlsKey { + verify_certs: config.should_verify_certs(), + cipher_string: config.cipher_string.clone(), + min_tls_version: config.min_tls_version.clone(), + max_tls_version: config.max_tls_version.clone(), + alpn_protocols: config.alpn_protocols.clone(), + } + } +} + /// Which connection mode to use for a given request. #[derive(Clone, Hash, Eq, PartialEq)] enum ConnMode { /// No proxy — connect directly to target. - Direct, + Direct(TlsKey), /// HTTP proxy + HTTP target — forward proxy (absolute-form URI to proxy). ForwardProxy(String), /// HTTP proxy + HTTPS target — CONNECT tunnel through proxy. - Tunnel(String), + Tunnel { proxy_url: String, tls: TlsKey }, /// SOCKS5 proxy — works for both HTTP and HTTPS targets. - Socks5(String), + Socks5 { proxy_url: String, tls: TlsKey }, } pub struct HyperClient { @@ -532,8 +657,9 @@ impl HyperClient { /// Determine the connection mode for a given request config + target URI. fn conn_mode(config: &RequestConfig, target_uri: &http::Uri) -> Result { + let tls = TlsKey::from_config(config); match config.effective_proxy(target_uri.host().unwrap_or("")) { - None => Ok(ConnMode::Direct), + None => Ok(ConnMode::Direct(tls)), Some(proxy_url) => { let proxy_uri: http::Uri = proxy_url.parse().map_err(|e: http::uri::InvalidUri| { @@ -545,12 +671,18 @@ impl HyperClient { // HTTP proxy: use forward proxy for HTTP targets, tunnel for HTTPS let target_is_https = target_uri.scheme_str() == Some("https"); if target_is_https { - Ok(ConnMode::Tunnel(proxy_url.to_string())) + Ok(ConnMode::Tunnel { + proxy_url: proxy_url.to_string(), + tls, + }) } else { Ok(ConnMode::ForwardProxy(proxy_url.to_string())) } } - "socks5" | "socks5h" => Ok(ConnMode::Socks5(proxy_url.to_string())), + "socks5" | "socks5h" => Ok(ConnMode::Socks5 { + proxy_url: proxy_url.to_string(), + tls, + }), _ => Err(ClientError::other(format!( "unsupported proxy scheme '{}' (use http, https, socks5)", proxy_scheme @@ -581,14 +713,14 @@ impl HyperClient { let builder = Client::builder(TokioExecutor::new()); let inner = match mode { - ConnMode::Direct => AnyClient::Direct(builder.build(connector)), + ConnMode::Direct(_) => AnyClient::Direct(builder.build(connector)), ConnMode::ForwardProxy(_) => { // Forward proxy doesn't use a cached hyper Client — it dispatches // directly via http1::SendRequest in send_inner. This branch should // never be reached. unreachable!("ForwardProxy uses dispatch_forward_proxy, not get_or_build") } - ConnMode::Tunnel(proxy_url) => { + ConnMode::Tunnel { proxy_url, .. } => { let proxy_uri: http::Uri = proxy_url.parse().map_err(|e: http::uri::InvalidUri| { ClientError::invalid_url(format!("invalid proxy URL: {}", e)) @@ -597,7 +729,7 @@ impl HyperClient { let tunnel = Tunnel::new(proxy_uri, connector); AnyClient::Tunnel(builder.build(tunnel)) } - ConnMode::Socks5(proxy_url) => { + ConnMode::Socks5 { proxy_url, .. } => { let proxy_uri: http::Uri = proxy_url.parse().map_err(|e: http::uri::InvalidUri| { ClientError::invalid_url(format!("invalid proxy URL: {}", e)) @@ -726,11 +858,22 @@ pub(crate) async fn connect_stream( ), ); - let mut tcp = tokio::net::TcpStream::connect(&connect_addr) - .await - .map_err(|e| { - ClientError::connection(format!("failed to connect to {}: {}", connect_addr, e)) - })?; + let connect_timeout = Duration::from_secs(config.timeout()); + let mut tcp = tokio::time::timeout( + connect_timeout, + tokio::net::TcpStream::connect(&connect_addr), + ) + .await + .map_err(|_| { + ClientError::timeout(format!( + "TCP connect to {} timed out after {}s", + connect_addr, + config.timeout() + )) + })? + .map_err(|e| { + ClientError::connection(format!("failed to connect to {}: {}", connect_addr, e)) + })?; // Capture peer IP for the target. When a proxy is in use, peer_addr // points at the proxy — useless to callers asking "what IP served @@ -760,17 +903,38 @@ pub(crate) async fn connect_stream( if let Some(ref p) = proxy_config { match p.scheme { ProxyScheme::Http => { - proxy::perform_http_connect(&mut tcp, &host, port).await?; + tokio::time::timeout( + connect_timeout, + proxy::perform_http_connect(&mut tcp, &host, port), + ) + .await + .map_err(|_| { + ClientError::timeout(format!( + "proxy CONNECT to {}:{} timed out after {}s", + host, + port, + config.timeout() + )) + })??; } ProxyScheme::Socks5 => { - proxy::perform_socks5( - &mut tcp, - &host, - port, - p.username.as_deref(), - p.password.as_deref(), + tokio::time::timeout( + connect_timeout, + proxy::perform_socks5( + &mut tcp, + &host, + port, + p.username.as_deref(), + p.password.as_deref(), + ), ) - .await?; + .await + .map_err(|_| { + ClientError::timeout(format!( + "SOCKS5 handshake timed out after {}s", + config.timeout() + )) + })??; } } debug_record( @@ -795,6 +959,8 @@ pub(crate) async fn connect_stream( if !config.should_verify_certs() { ssl_builder.set_verify(openssl::ssl::SslVerifyMode::NONE); + } else { + load_system_ca_certs(&mut ssl_builder)?; } if let Some(ref ciphers) = config.cipher_string { ssl_builder @@ -836,13 +1002,32 @@ pub(crate) async fn connect_stream( .set_hostname(&host) .map_err(|e| ClientError::tls(format!("SNI setup failed: {}", e)))?; } + // Hostname verification: when verify mode is PEER, pin the + // expected identity so OpenSSL checks the cert's SAN/CN. + if ssl_conf.verify_mode() != openssl::ssl::SslVerifyMode::NONE { + let param = ssl_conf.param_mut(); + match host.parse::() { + Ok(ip) => param + .set_ip(ip) + .map_err(|e| ClientError::tls(format!("hostname verify setup failed: {}", e)))?, + Err(_) => param + .set_host(&host) + .map_err(|e| ClientError::tls(format!("hostname verify setup failed: {}", e)))?, + } + } let mut tls_stream = tokio_openssl::SslStream::new(ssl_conf, tcp) .map_err(|e| ClientError::tls(format!("TLS stream setup failed: {}", e)))?; - Pin::new(&mut tls_stream) - .connect() + tokio::time::timeout(connect_timeout, Pin::new(&mut tls_stream).connect()) .await + .map_err(|_| { + ClientError::timeout(format!( + "TLS handshake with {} timed out after {}s", + host, + config.timeout() + )) + })? .map_err(|e| ClientError::tls(format!("TLS handshake failed: {}", e)))?; let cert_info = extract_cert_info(tls_stream.ssl()); diff --git a/src/python.rs b/src/python.rs index 2a95a0e..8d4168f 100644 --- a/src/python.rs +++ b/src/python.rs @@ -1171,6 +1171,7 @@ impl BlastHTTP { proxy=None, no_proxy=None, alpn_protocols=None, + timeout=None, ))] #[allow(clippy::too_many_arguments)] fn raw_connect<'py>( @@ -1185,6 +1186,7 @@ impl BlastHTTP { proxy: Option, no_proxy: Option>, alpn_protocols: Option>, + timeout: Option, ) -> PyResult> { let mut config = RequestConfig::new(url.clone()); config.verify_certs = verify_certs; @@ -1195,6 +1197,7 @@ impl BlastHTTP { config.proxy = proxy; config.no_proxy = no_proxy.unwrap_or_default(); config.alpn_protocols = alpn_protocols; + config.timeout_seconds = timeout; let limiter = self.rate_limiter.clone(); // Hand the limiter to the PyRawConnection so send_bytes / diff --git a/tests/python/test_ssl_verify.py b/tests/python/test_ssl_verify.py new file mode 100644 index 0000000..1ead508 --- /dev/null +++ b/tests/python/test_ssl_verify.py @@ -0,0 +1,387 @@ +"""Tests for SSL certificate verification across all connection types. + +Covers: request, request_batch, request_batch_stream, raw_connect, download. +Verifies that verify_certs=True rejects self-signed certs and verify_certs=False +accepts them. Also tests that the client cache correctly isolates connections +with different verify settings. +""" + +import asyncio +import os +import pathlib +import ssl +import subprocess +import tempfile + +import blasthttp +import pytest +import pytest_asyncio + + +@pytest.fixture(scope="module") +def selfsigned_cert(): + tmpdir = tempfile.mkdtemp() + cert = pathlib.Path(tmpdir) / "cert.pem" + key = pathlib.Path(tmpdir) / "key.pem" + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-nodes", + "-newkey", + "rsa:2048", + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost,IP:127.0.0.1", + "-keyout", + str(key), + "-out", + str(cert), + "-days", + "1", + ], + check=True, + capture_output=True, + ) + return str(cert), str(key) + + +def _make_server_ctx(cert, key): + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(cert, key) + return ctx + + +RESPONSE_BODY = "ssl-verify-test-ok" +HTTP_RESPONSE = ( + f"HTTP/1.1 200 OK\r\nContent-Length: {len(RESPONSE_BODY)}\r\nConnection: close\r\n\r\n{RESPONSE_BODY}" +).encode() + + +@pytest_asyncio.fixture +async def tls_server(selfsigned_cert): + """Single-connection TLS server on an ephemeral port.""" + cert, key = selfsigned_cert + ctx = _make_server_ctx(cert, key) + + async def handle(reader, writer): + try: + await reader.readuntil(b"\r\n\r\n") + except Exception: + pass + writer.write(HTTP_RESPONSE) + await writer.drain() + writer.close() + + server = await asyncio.start_server(handle, "127.0.0.1", 0, ssl=ctx) + port = server.sockets[0].getsockname()[1] + try: + yield port + finally: + server.close() + await server.wait_closed() + + +@pytest_asyncio.fixture +async def tls_multi_server(selfsigned_cert): + """Multi-connection TLS server for batch tests.""" + cert, key = selfsigned_cert + ctx = _make_server_ctx(cert, key) + + async def handle(reader, writer): + try: + await reader.readuntil(b"\r\n\r\n") + except Exception: + pass + writer.write(HTTP_RESPONSE) + await writer.drain() + writer.close() + + server = await asyncio.start_server(handle, "127.0.0.1", 0, ssl=ctx) + port = server.sockets[0].getsockname()[1] + try: + yield port + finally: + server.close() + await server.wait_closed() + + +@pytest.fixture(scope="module") +def wronghost_ca_signed(): + """CA + server cert valid only for DNS:wronghost.test. + + The server cert is signed by the CA so the chain is valid when SSL_CERT_FILE + points to the CA cert. This isolates hostname verification: the ONLY reason + verify_certs=True should fail is the hostname mismatch. + """ + tmpdir = tempfile.mkdtemp() + ca_key = pathlib.Path(tmpdir) / "ca.key" + ca_cert = pathlib.Path(tmpdir) / "ca.pem" + srv_key = pathlib.Path(tmpdir) / "srv.key" + srv_csr = pathlib.Path(tmpdir) / "srv.csr" + srv_cert = pathlib.Path(tmpdir) / "srv.pem" + ext_cnf = pathlib.Path(tmpdir) / "ext.cnf" + + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-nodes", + "-newkey", + "rsa:2048", + "-subj", + "/CN=Test CA", + "-keyout", + str(ca_key), + "-out", + str(ca_cert), + "-days", + "1", + ], + check=True, + capture_output=True, + ) + subprocess.run( + [ + "openssl", + "req", + "-nodes", + "-newkey", + "rsa:2048", + "-subj", + "/CN=wronghost.test", + "-keyout", + str(srv_key), + "-out", + str(srv_csr), + ], + check=True, + capture_output=True, + ) + ext_cnf.write_text("subjectAltName=DNS:wronghost.test\n") + subprocess.run( + [ + "openssl", + "x509", + "-req", + "-in", + str(srv_csr), + "-CA", + str(ca_cert), + "-CAkey", + str(ca_key), + "-CAcreateserial", + "-out", + str(srv_cert), + "-days", + "1", + "-extfile", + str(ext_cnf), + ], + check=True, + capture_output=True, + ) + return str(ca_cert), str(srv_cert), str(srv_key) + + +@pytest_asyncio.fixture +async def wronghost_tls_server(wronghost_ca_signed): + """TLS server with a CA-signed cert valid only for wronghost.test.""" + _ca_cert, srv_cert, srv_key = wronghost_ca_signed + ctx = _make_server_ctx(srv_cert, srv_key) + + async def handle(reader, writer): + try: + await reader.readuntil(b"\r\n\r\n") + except Exception: + pass + writer.write(HTTP_RESPONSE) + await writer.drain() + writer.close() + + server = await asyncio.start_server(handle, "127.0.0.1", 0, ssl=ctx) + port = server.sockets[0].getsockname()[1] + try: + yield port + finally: + server.close() + await server.wait_closed() + + +@pytest.fixture +def client(): + return blasthttp.BlastHTTP() + + +# ── request() ───────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_request_verify_true_rejects_self_signed(client, tls_server): + url = f"https://127.0.0.1:{tls_server}/" + with pytest.raises(RuntimeError): + await client.request(url, verify_certs=True, timeout=5) + + +@pytest.mark.asyncio +async def test_request_verify_false_accepts_self_signed(client, tls_server): + url = f"https://127.0.0.1:{tls_server}/" + r = await client.request(url, verify_certs=False, timeout=5) + assert r.status_code == 200 + assert r.text == RESPONSE_BODY + + +# ── request_batch() ─────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_request_batch_verify_true_rejects_self_signed(client, tls_multi_server): + url = f"https://127.0.0.1:{tls_multi_server}/" + configs = [blasthttp.BatchConfig(url, verify_certs=True, timeout=5)] + results = await client.request_batch(configs) + assert len(results) == 1 + assert results[0].success is False + assert results[0].error is not None + + +@pytest.mark.asyncio +async def test_request_batch_verify_false_accepts_self_signed(client, tls_multi_server): + url = f"https://127.0.0.1:{tls_multi_server}/" + configs = [blasthttp.BatchConfig(url, verify_certs=False, timeout=5)] + results = await client.request_batch(configs) + assert len(results) == 1 + assert results[0].success is True + assert results[0].response.status_code == 200 + + +# ── request_batch_stream() ──────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_request_batch_stream_verify_true_rejects_self_signed(client, tls_multi_server): + url = f"https://127.0.0.1:{tls_multi_server}/" + configs = [blasthttp.BatchConfig(url, verify_certs=True, timeout=5)] + results = [] + async for batch in client.request_batch_stream(configs): + results.extend(batch) + assert len(results) == 1 + assert results[0].success is False + + +@pytest.mark.asyncio +async def test_request_batch_stream_verify_false_accepts_self_signed(client, tls_multi_server): + url = f"https://127.0.0.1:{tls_multi_server}/" + configs = [blasthttp.BatchConfig(url, verify_certs=False, timeout=5)] + results = [] + async for batch in client.request_batch_stream(configs): + results.extend(batch) + assert len(results) == 1 + assert results[0].success is True + assert results[0].response.status_code == 200 + + +# ── raw_connect() ───────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_raw_connect_verify_true_rejects_self_signed(client, tls_server): + url = f"https://127.0.0.1:{tls_server}/" + with pytest.raises(RuntimeError): + await client.raw_connect(url, verify_certs=True, timeout=5) + + +@pytest.mark.asyncio +async def test_raw_connect_verify_false_accepts_self_signed(client, tls_server): + url = f"https://127.0.0.1:{tls_server}/" + conn = await client.raw_connect(url, verify_certs=False, timeout=5) + assert isinstance(conn, blasthttp.RawConnection) + await conn.close() + + +# ── download() ──────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_download_verify_true_rejects_self_signed(client, tls_server, tmp_path): + url = f"https://127.0.0.1:{tls_server}/file.txt" + dest = str(tmp_path / "out.txt") + with pytest.raises(RuntimeError): + await client.download(url, dest, verify_certs=True, timeout=5) + + +@pytest.mark.asyncio +async def test_download_verify_false_accepts_self_signed(client, tls_server, tmp_path): + url = f"https://127.0.0.1:{tls_server}/file.txt" + dest = str(tmp_path / "out.txt") + await client.download(url, dest, verify_certs=False, timeout=5) + assert pathlib.Path(dest).read_text() == RESPONSE_BODY + + +# ── raw_connect timeout ─────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_raw_connect_timeout_fires(): + """Connect to a non-routable IP with a short timeout to verify the + timeout parameter works.""" + client = blasthttp.BlastHTTP() + import time + + t0 = time.monotonic() + with pytest.raises(RuntimeError): + await client.raw_connect("https://192.0.2.1:443/", timeout=2) + elapsed = time.monotonic() - t0 + assert elapsed < 5, f"timeout took {elapsed:.1f}s, expected ~2s" + + +# ── Client cache isolation ──────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_cache_isolation_verify_false_then_true(tls_multi_server): + """After a verify=False request succeeds, a verify=True request to the + same host must still fail. This catches the bug where ConnMode didn't + include verify_certs in the cache key.""" + client = blasthttp.BlastHTTP() + url = f"https://127.0.0.1:{tls_multi_server}/" + + r = await client.request(url, verify_certs=False, timeout=5) + assert r.status_code == 200 + + with pytest.raises(RuntimeError): + await client.request(url, verify_certs=True, timeout=5) + + +# ── Hostname verification ──────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_request_verify_true_rejects_hostname_mismatch(wronghost_tls_server, wronghost_ca_signed): + """The cert chain is trusted (CA is in SSL_CERT_FILE) but the cert's SAN + (DNS:wronghost.test) doesn't match the connection target (127.0.0.1). + verify_certs=True must reject this.""" + ca_cert = wronghost_ca_signed[0] + old_env = os.environ.get("SSL_CERT_FILE") + os.environ["SSL_CERT_FILE"] = ca_cert + try: + fresh = blasthttp.BlastHTTP() + url = f"https://127.0.0.1:{wronghost_tls_server}/" + with pytest.raises(RuntimeError): + await fresh.request(url, verify_certs=True, timeout=5) + finally: + if old_env is None: + os.environ.pop("SSL_CERT_FILE", None) + else: + os.environ["SSL_CERT_FILE"] = old_env + + +@pytest.mark.asyncio +async def test_request_verify_false_ignores_hostname_mismatch(wronghost_tls_server): + """verify_certs=False should accept any cert regardless of hostname.""" + url = f"https://127.0.0.1:{wronghost_tls_server}/" + fresh = blasthttp.BlastHTTP() + r = await fresh.request(url, verify_certs=False, timeout=5) + assert r.status_code == 200 diff --git a/tests/tls_integration.rs b/tests/tls_integration.rs index d7ccb46..e4b39a7 100644 --- a/tests/tls_integration.rs +++ b/tests/tls_integration.rs @@ -231,6 +231,139 @@ async fn test_invalid_cipher_string_returns_error() { ); } +// ── SSL certificate verification ────────────────────────────────── + +#[tokio::test] +async fn test_verify_certs_true_rejects_self_signed() { + let server = TlsTestServer::start(TlsServerConfig::default()).await; + + let mut config = RequestConfig::new(server.url()); + config.verify_certs = Some(true); + config.timeout_seconds = Some(5); + + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_err(), + "verify_certs=true should reject self-signed cert" + ); + + server.shutdown().await; +} + +#[tokio::test] +async fn test_verify_certs_false_accepts_self_signed() { + let server = TlsTestServer::start(TlsServerConfig::default()).await; + + let mut config = RequestConfig::new(server.url()); + config.verify_certs = Some(false); + config.timeout_seconds = Some(5); + + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_ok(), + "verify_certs=false should accept self-signed cert: {:?}", + result.err() + ); + assert_eq!(result.unwrap().status, 200); + + server.shutdown().await; +} + +#[tokio::test] +async fn test_verify_certs_cache_isolation() { + // First request with verify=false should succeed against self-signed + let server1 = TlsTestServer::start(TlsServerConfig::default()).await; + + let mut config1 = RequestConfig::new(server1.url()); + config1.verify_certs = Some(false); + config1.timeout_seconds = Some(5); + + let client = HyperClient::new(); + let result1 = client.send(&config1).await; + assert!( + result1.is_ok(), + "verify=false should succeed: {:?}", + result1.err() + ); + + server1.shutdown().await; + + // Second request with verify=true to a new server on a different port + // must NOT reuse the cached non-verifying client + let server2 = TlsTestServer::start(TlsServerConfig::default()).await; + + let mut config2 = RequestConfig::new(server2.url()); + config2.verify_certs = Some(true); + config2.timeout_seconds = Some(5); + + let result2 = client.send(&config2).await; + assert!( + result2.is_err(), + "verify=true should reject self-signed even after verify=false succeeded" + ); + + server2.shutdown().await; +} + +// ── Hostname verification ───────────────────────────────────────── + +#[tokio::test] +async fn test_verify_certs_rejects_hostname_mismatch() { + // Cert is valid for "wronghost.test" only — no SAN for localhost or 127.0.0.1. + // verify_certs=true must reject this connection. + let server = TlsTestServer::start(TlsServerConfig { + san_dns: vec!["wronghost.test".to_string()], + san_ip: vec![], + ..Default::default() + }) + .await; + + let mut config = RequestConfig::new(server.url()); + config.verify_certs = Some(true); + config.timeout_seconds = Some(5); + + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_err(), + "verify_certs=true must reject cert with hostname mismatch" + ); + + server.shutdown().await; +} + +#[tokio::test] +async fn test_verify_false_ignores_hostname_mismatch() { + // Same wrong-host cert, but verify_certs=false bypasses all checks. + let server = TlsTestServer::start(TlsServerConfig { + san_dns: vec!["wronghost.test".to_string()], + san_ip: vec![], + ..Default::default() + }) + .await; + + let mut config = RequestConfig::new(server.url()); + config.verify_certs = Some(false); + config.timeout_seconds = Some(5); + + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_ok(), + "verify_certs=false should accept any cert: {:?}", + result.err() + ); + assert_eq!(result.unwrap().status, 200); + + server.shutdown().await; +} + // ── Invalid TLS version ─────────────────────────────────────────── #[tokio::test] diff --git a/tests/tls_server.rs b/tests/tls_server.rs index b00141c..5988139 100644 --- a/tests/tls_server.rs +++ b/tests/tls_server.rs @@ -26,6 +26,10 @@ pub struct TlsServerConfig { pub max_tls_version: Option, pub response_body: String, pub response_status: u16, + /// DNS SANs for the self-signed cert. Default: ["localhost"]. + pub san_dns: Vec, + /// IP SANs for the self-signed cert. Default: ["127.0.0.1"]. + pub san_ip: Vec, } impl Default for TlsServerConfig { @@ -36,12 +40,14 @@ impl Default for TlsServerConfig { max_tls_version: None, response_body: "OK".to_string(), response_status: 200, + san_dns: vec!["localhost".to_string()], + san_ip: vec!["127.0.0.1".to_string()], } } } // Generate a self-signed cert + key pair in memory (no files needed) -fn generate_self_signed() -> (PKey, X509) { +fn generate_self_signed(config: &TlsServerConfig) -> (PKey, X509) { let rsa = Rsa::generate(2048).unwrap(); let pkey = PKey::from_rsa(rsa).unwrap(); @@ -66,12 +72,14 @@ fn generate_self_signed() -> (PKey, X509) { builder.set_pubkey(&pkey).unwrap(); - // Add SAN for localhost + 127.0.0.1 - let san = SubjectAlternativeName::new() - .dns("localhost") - .ip("127.0.0.1") - .build(&builder.x509v3_context(None, None)) - .unwrap(); + let mut san = SubjectAlternativeName::new(); + for dns in &config.san_dns { + san.dns(dns); + } + for ip in &config.san_ip { + san.ip(ip); + } + let san = san.build(&builder.x509v3_context(None, None)).unwrap(); builder.append_extension(san).unwrap(); builder.sign(&pkey, MessageDigest::sha256()).unwrap(); @@ -109,7 +117,7 @@ fn build_acceptor(config: &TlsServerConfig) -> SslAcceptor { ) .unwrap(); - let (pkey, cert) = generate_self_signed(); + let (pkey, cert) = generate_self_signed(config); builder.set_private_key(&pkey).unwrap(); builder.set_certificate(&cert).unwrap();