From 252e68a9ecacdcf09ececc972d32c27357532508 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 28 Sep 2026 18:38:48 -0400 Subject: [PATCH 1/3] Offer legacy ciphers by default The custom OpenSSL build exists so blasthttp can reach servers that only speak deprecated ciphers, but nothing was reaching them. `SslConnector:: builder` installs its own cipher list, `DEFAULT:!aNULL:!eNULL:!MD5:!3DES: !DES:!RC4:!IDEA:!SEED:...`, and we never replaced it, so RC4, DES, 3DES and SEED never made it into the ClientHello. A server speaking only one of them was unreachable unless the caller passed `cipher_string` by hand. `set_security_level(0)` looked like it covered this and does not. The security level decides how weak a negotiated cipher may be; the cipher list decides which ones are offered at all. Set `ALL` when the caller names nothing, on both the pooled path and the `connect_stream` path used by `raw_connect`, `resolve_ip` and `request_target`, which build their SSL contexts separately. Null-encryption suites stay out of the default. They remain available through an explicit `cipher_string`, but negotiating one by accident would return a connection that looks like TLS and encrypts nothing. tests/legacy_default.rs covers this end to end: each test stands up a real TLS server pinned to one legacy cipher or protocol version and connects with a client that sets no TLS options at all. All five cipher cases failed before this change. Side effect worth knowing: the ClientHello grows from 31 cipher suites to 105, so the client's JA3/JA4 fingerprint changes. README dropped its claims about export ciphers and SSLv3. OpenSSL removed export ciphers in 1.1.0, and SSLv3 is disabled in our build despite `enable-ssl3` being passed, so neither has ever worked. --- CHANGELOG.md | 8 ++ README.md | 10 +- src/client/hyper.rs | 44 +++++++-- tests/legacy_default.rs | 204 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 14 deletions(-) create mode 100644 tests/legacy_default.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 599bd49..4cbbd37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.10.1 + +- Legacy ciphers are offered by default, which is what the custom OpenSSL build has always been for. A server speaking only RC4, RC4-MD5, 3DES, SEED, Camellia or anonymous DH is now reachable without passing `cipher_string` yourself. Previously `SslConnector::builder` installed its own list (`DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:...`) and nothing replaced it, so those suites never reached the wire and such a server was unreachable at any setting. `set_security_level(0)` did not help: the security level governs how weak a negotiated cipher may be, not which ones are offered +- Null-encryption suites are the deliberate exception. They stay reachable through an explicit `cipher_string` but are never offered by default, since negotiating one by accident returns a connection that looks like TLS and encrypts nothing +- The default applies on both TLS paths, the pooled client and the `resolve_ip` / `request_target` / `raw_connect` path, which build their SSL contexts separately +- This widens the ClientHello from 31 cipher suites to 105, which changes the client's TLS fingerprint. Anything matching on JA3/JA4 will see a different value than it did on 0.10.0 +- README no longer claims export ciphers and SSLv3 work. OpenSSL removed export ciphers in 1.1.0, and SSLv3 ends up disabled in our build despite `enable-ssl3` being passed, so neither has ever been reachable + ## 0.10.0 - Cookies set on a redirect hop are applied to the hops that follow it, the way a browser does. What a chain collects lives for that one request, so nothing carries between requests diff --git a/README.md b/README.md index d6e47ca..66fbcb1 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ cargo add blasthttp - **Batch connection reuse** — connections are pooled and reused within a batch, dramatically reducing overhead when scanning many URLs on the same hosts - **Rust performance** — async I/O, zero-copy where possible, and native concurrency give significant speed improvements over pure Python HTTP clients - **SSL cert info on every request** — extracts CN, SANs, emails, issuer, validity dates, and fingerprint during the TLS handshake that's already happening, eliminating the need for a separate connection to gather this information -- **All TLS ciphers available by default** — custom-compiled OpenSSL 3.3.2 with legacy provider baked in (RC4, 3DES, export ciphers, SSLv3) so you can connect to anything +- **Legacy TLS ciphers offered by default**: custom-compiled OpenSSL 3.3.2 with the legacy provider baked in, so a server that speaks only RC4, 3DES, SEED, Camellia or anonymous DH is reachable without naming the cipher yourself. TLS 1.0 and 1.1 are offered too. Null-encryption suites are the one exception, available through `cipher_string` but never offered by default - **No cert validation by default** — offensive-first: connects to self-signed, expired, and misconfigured TLS without extra config - **HTTP/2 support** — automatic via ALPN negotiation, falls back to HTTP/1.1 - **Low-level primitives** — `RawConnection` for byte-level TCP/TLS I/O (bypasses HTTP framing entirely) and `blasthttp.h2` for manual H2 frame construction (includes a permissive HPACK encoder that emits bytes a strict encoder refuses, the building block for H2 smuggling / CRLF-injection tooling) @@ -115,7 +115,7 @@ Output is JSON (one object per response), including status, headers, redirect ch | `--verify` | Enable TLS cert validation | off | | `-x, --proxy` | HTTP/SOCKS proxy URL | | | `--no-proxy` | Host that bypasses the proxy: `host`, `*.suffix`, IP, CIDR, or `*` (repeatable) | | -| `--ciphers` | OpenSSL cipher string | all | +| `--ciphers` | OpenSSL cipher string | `ALL` (every suite except null-encryption) | | `--min-tls` | Minimum TLS version (1.0–1.3) | | | `--max-tls` | Maximum TLS version (1.0–1.3) | | | `-v, --verbose` | Verbose output (-vv includes body) | | @@ -239,7 +239,7 @@ All parameters except `url` are optional: | `verify_certs` | `bool` | Enable TLS cert validation (default `False`) | | `proxy` | `str` | HTTP/SOCKS proxy URL | | `no_proxy` | `list[str]` | Hosts that bypass the proxy | -| `cipher_string` | `str` | OpenSSL cipher string | +| `cipher_string` | `str` | OpenSSL cipher string (default `ALL`: every suite the build provides except null-encryption) | | `min_tls_version` | `str` | Minimum TLS version (`"1.0"`–`"1.3"`) | | `max_tls_version` | `str` | Maximum TLS version (`"1.0"`–`"1.3"`) | | `retries` | `int` | Number of retries on failure | @@ -572,7 +572,9 @@ def http_mock(): ### 1. Build custom OpenSSL -blasthttp ships a script that downloads OpenSSL 3.3.2, compiles it with weak cipher support (`RC4`, `DES`, `3DES`, export ciphers, `SSLv3`), and installs it to `vendor/openssl/install/`. This only needs to be run once — the result is cached and reused. +blasthttp ships a script that downloads OpenSSL 3.3.2, compiles it with weak cipher support (`RC4`, `DES`, `3DES`, `SEED`), and installs it to `vendor/openssl/install/`. This only needs to be run once — the result is cached and reused. + +Two things the script asks for that the build does not currently produce: export ciphers, which OpenSSL removed outright in 1.1.0, and `SSLv3`, which ends up disabled despite `enable-ssl3` being passed (`configuration.h` defines `OPENSSL_NO_SSL3`, and `libssl.a` has no `SSLv3_method`). Neither is reachable today. ```bash ./scripts/build-openssl.sh diff --git a/src/client/hyper.rs b/src/client/hyper.rs index a8a31c9..9721899 100644 --- a/src/client/hyper.rs +++ b/src/client/hyper.rs @@ -58,6 +58,26 @@ fn sanitize_uri(url: &str) -> String { out } +/// Cipher list used when the caller doesn't name one. +/// +/// This has to be set explicitly. `SslConnector::builder` installs its own +/// list first, `DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:...`, +/// and `set_security_level(0)` does NOT undo it. The security level governs +/// how weak a cipher may be; the cipher list governs which ones are offered at +/// all. Leaving the list alone means RC4, DES, 3DES and SEED never reach the +/// wire whatever the security level says, so a server speaking only one of +/// them is unreachable, which is the whole reason this project builds its own +/// OpenSSL. +/// +/// `ALL` is every suite the build provides except the eNULL (no encryption) +/// ones. It does include the aNULL suites, which skip authentication: those +/// are worth reaching for a scanner, and this client already defaults to not +/// verifying certificates, so they give up nothing that was being enforced. +/// Null *encryption* stays opt-in through `cipher_string`, because negotiating +/// it by default would hand back a connection that looks like TLS and encrypts +/// nothing. +const DEFAULT_CIPHER_LIST: &str = "ALL"; + // Load the OpenSSL legacy provider once (for RC4, DES, etc.). // The provider is statically compiled into libcrypto via `no-module` build flag. // `Once` ensures this runs exactly once even across threads. @@ -346,11 +366,13 @@ impl OpenSslConnector { load_system_ca_certs(&mut builder)?; } - if let Some(ref ciphers) = config.cipher_string { - builder.set_cipher_list(ciphers).map_err(|e| { - ClientError::tls(format!("invalid cipher string '{}': {}", ciphers, e)) - })?; - } + let ciphers = config + .cipher_string + .as_deref() + .unwrap_or(DEFAULT_CIPHER_LIST); + builder + .set_cipher_list(ciphers) + .map_err(|e| ClientError::tls(format!("invalid cipher string '{}': {}", ciphers, e)))?; if let Some(ref min_ver) = config.min_tls_version { let version = parse_tls_version(min_ver)?; @@ -975,11 +997,13 @@ pub(crate) async fn connect_stream( } else { load_system_ca_certs(&mut ssl_builder)?; } - if let Some(ref ciphers) = config.cipher_string { - ssl_builder - .set_cipher_list(ciphers) - .map_err(|e| ClientError::tls(format!("invalid cipher string '{}': {}", ciphers, e)))?; - } + let ciphers = config + .cipher_string + .as_deref() + .unwrap_or(DEFAULT_CIPHER_LIST); + ssl_builder + .set_cipher_list(ciphers) + .map_err(|e| ClientError::tls(format!("invalid cipher string '{}': {}", ciphers, e)))?; if let Some(ref min_ver) = config.min_tls_version { let version = parse_tls_version(min_ver)?; ssl_builder diff --git a/tests/legacy_default.rs b/tests/legacy_default.rs new file mode 100644 index 0000000..e37cbad --- /dev/null +++ b/tests/legacy_default.rs @@ -0,0 +1,204 @@ +// Full-stack tests: legacy ciphers and protocols must work WITHOUT the caller +// naming them. +// +// The custom OpenSSL build (scripts/build-openssl.sh, enable-weak-ssl-ciphers) +// exists so blasthttp can reach ancient servers. README.md advertises "All TLS +// ciphers available by default". These tests hold that promise to its word: +// every client config below sets `verify_certs` (the cert is self-signed) and +// a timeout, and nothing else. No `cipher_string`, no `min_tls_version`, no +// `max_tls_version`. +// +// Each test stands up a real TLS server pinned to one legacy cipher or +// protocol version, then makes a real request through the ordinary client +// path. + +mod tls_server; + +use blasthttp::client::HttpClient; +use blasthttp::client::hyper::HyperClient; +use blasthttp::config::RequestConfig; +use openssl::ssl::SslVersion; +use tls_server::{TlsServerConfig, TlsTestServer}; + +/// A client config with NO TLS tuning of any kind. +/// +/// `verify_certs` is off because the test server's cert is self-signed, and +/// that is orthogonal to which ciphers we offer. Everything else is left at +/// the default, which is the entire point of these tests. +fn default_config(url: &str) -> RequestConfig { + let mut config = RequestConfig::new(url.to_string()); + config.verify_certs = Some(false); + config.timeout_seconds = Some(5); + config +} + +/// Stand up a server that speaks exactly one legacy cipher, then connect with +/// a stock client and assert we got the page. +async fn assert_reachable_with_cipher(cipher: &str) { + let server = TlsTestServer::start(TlsServerConfig { + cipher_list: Some(cipher.to_string()), + // None of these ciphers exist in TLS 1.3, so the server has to sit at + // 1.2 or below for the pin to mean anything. + max_tls_version: Some(SslVersion::TLS1_2), + ..Default::default() + }) + .await; + + let config = default_config(&server.url()); + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_ok(), + "a stock client should reach a {cipher}-only server, but got: {:?}", + result.as_ref().err() + ); + assert_eq!(result.unwrap().status, 200); + + server.shutdown().await; +} + +/// Same, for a server pinned to one legacy protocol version. +async fn assert_reachable_with_version(label: &str, version: SslVersion) { + let server = TlsTestServer::start(TlsServerConfig { + min_tls_version: Some(version), + max_tls_version: Some(version), + ..Default::default() + }) + .await; + + let config = default_config(&server.url()); + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_ok(), + "a stock client should reach a {label}-only server, but got: {:?}", + result.as_ref().err() + ); + assert_eq!(result.unwrap().status, 200); + + server.shutdown().await; +} + +// ── Legacy ciphers, no client configuration ─────────────────────── + +#[tokio::test] +async fn test_rc4_reachable_by_default() { + assert_reachable_with_cipher("RC4-SHA").await; +} + +#[tokio::test] +async fn test_rc4_md5_reachable_by_default() { + assert_reachable_with_cipher("RC4-MD5").await; +} + +#[tokio::test] +async fn test_3des_reachable_by_default() { + assert_reachable_with_cipher("DES-CBC3-SHA").await; +} + +#[tokio::test] +async fn test_seed_reachable_by_default() { + assert_reachable_with_cipher("SEED-SHA").await; +} + +#[tokio::test] +async fn test_camellia_reachable_by_default() { + assert_reachable_with_cipher("CAMELLIA128-SHA").await; +} + +#[tokio::test] +async fn test_anon_dh_reachable_by_default() { + // Anonymous DH: no certificate at all. Worth reaching, since a server + // that allows it is itself the finding. + assert_reachable_with_cipher("ADH-AES128-SHA").await; +} + +// ── Null encryption is deliberately NOT in the default ──────────── + +#[tokio::test] +async fn test_null_cipher_not_offered_by_default() { + // A server offering only eNULL should be out of reach for a stock client. + // Connecting would look like TLS while encrypting nothing, so it has to be + // an explicit decision rather than something we fall into. + let server = TlsTestServer::start(TlsServerConfig { + cipher_list: Some("NULL-SHA".to_string()), + max_tls_version: Some(SslVersion::TLS1_2), + ..Default::default() + }) + .await; + + let config = default_config(&server.url()); + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_err(), + "null encryption must not be reachable without asking for it" + ); + + server.shutdown().await; +} + +#[tokio::test] +async fn test_null_cipher_reachable_when_asked_for() { + // ...but naming it explicitly still works, so the capability is available + // to anyone who actually wants it. + let server = TlsTestServer::start(TlsServerConfig { + cipher_list: Some("NULL-SHA".to_string()), + max_tls_version: Some(SslVersion::TLS1_2), + ..Default::default() + }) + .await; + + let mut config = default_config(&server.url()); + config.cipher_string = Some("NULL-SHA".to_string()); + + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_ok(), + "explicit NULL-SHA should still connect: {:?}", + result.as_ref().err() + ); + + server.shutdown().await; +} + +// ── Legacy protocol versions, no client configuration ───────────── + +#[tokio::test] +async fn test_tls10_reachable_by_default() { + assert_reachable_with_version("TLS 1.0", SslVersion::TLS1).await; +} + +#[tokio::test] +async fn test_tls11_reachable_by_default() { + assert_reachable_with_version("TLS 1.1", SslVersion::TLS1_1).await; +} + +// ── Control: this one already works, so a failure here means the +// harness is broken rather than the client ────────────────────── + +#[tokio::test] +async fn test_modern_still_reachable_by_default() { + let server = TlsTestServer::start(TlsServerConfig { + min_tls_version: Some(SslVersion::TLS1_2), + ..Default::default() + }) + .await; + + let config = default_config(&server.url()); + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_ok(), + "control case failed, so the harness is suspect: {:?}", + result.as_ref().err() + ); + + server.shutdown().await; +} From 019c80842aa85d062f8c375ff08f1d0dfa39aa81 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 28 Sep 2026 19:53:47 -0400 Subject: [PATCH 2/3] Make SSLv3 actually work SSLv3 has never been reachable in any build, despite the README advertising it and the build script asking for it. Three separate things were in the way. The build. `scripts/build-openssl.sh` passed `enable-ssl3`, but OpenSSL's Configure carries a disable cascade that reads "if ssl3-method is off, turn ssl3 off too", and ssl3-method is off by default. So the flag was undone during configure. `configdata.pm` recorded both `enable-ssl3` and `no-ssl3`, and every shipped build came out with OPENSSL_NO_SSL3 defined and no SSLv3_method symbols in libssl.a. Passing `enable-ssl3-method` alongside it fixes this. The SSL options. `SslConnector::builder` sets NO_SSLV3 and nothing cleared it, so even a build with SSLv3 compiled in would refuse to negotiate it. Cleared on both the pooled path and `connect_stream`. The spelling. `parse_tls_version` understood 1.0 through 1.3 and nothing else, so there was no way to name SSLv3 at all. It now takes `3.0`, `ssl3` and `sslv3`, and the error message mentions it. An SSLv3-only server is now reachable without asking, matching how the legacy ciphers behave after the previous commit, and can also be pinned explicitly. The test server clears NO_SSLV3 too so a test can pin it. Separately, the build cache was keyed only on the target, so changing what the build asks for was silently ignored and a stale install stayed in place. It now keys on the version and feature flags as well, which is what makes this fix reach anyone with an existing checkout. Upgrading triggers one rebuild. Measured: JA4 is unchanged, `supported_versions` gains 0300, and google, github, cloudflare, amazon, reddit, wikipedia, microsoft and apple all still return 200. --- CHANGELOG.md | 5 ++++- README.md | 14 ++++++------ scripts/build-openssl.sh | 48 ++++++++++++++++++++++++++++++---------- src/client/hyper.rs | 13 ++++++++++- tests/legacy_default.rs | 34 ++++++++++++++++++++++++++++ tests/tls_server.rs | 6 +++-- 6 files changed, 97 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cbbd37..d408874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,10 @@ - Null-encryption suites are the deliberate exception. They stay reachable through an explicit `cipher_string` but are never offered by default, since negotiating one by accident returns a connection that looks like TLS and encrypts nothing - The default applies on both TLS paths, the pooled client and the `resolve_ip` / `request_target` / `raw_connect` path, which build their SSL contexts separately - This widens the ClientHello from 31 cipher suites to 105, which changes the client's TLS fingerprint. Anything matching on JA3/JA4 will see a different value than it did on 0.10.0 -- README no longer claims export ciphers and SSLv3 work. OpenSSL removed export ciphers in 1.1.0, and SSLv3 ends up disabled in our build despite `enable-ssl3` being passed, so neither has ever been reachable +- SSLv3 works, for the first time. It had never been reachable in any build: `scripts/build-openssl.sh` passed `enable-ssl3`, but OpenSSL's Configure has a disable cascade reading "if `ssl3-method` is off, turn `ssl3` off too", and `ssl3-method` is off by default. The flag was silently undone, so every shipped build had `OPENSSL_NO_SSL3` defined and no `SSLv3_method` symbols. The script now passes `enable-ssl3-method` as well +- `SslConnector::builder` also sets `NO_SSLV3`, which nothing cleared, and `parse_tls_version` only understood 1.0 through 1.3, so SSLv3 had no spelling even if the build had provided it. Both are fixed: the option is cleared on both TLS paths, and `min_tls_version` / `max_tls_version` / `--min-tls` / `--max-tls` now accept `3.0` (also `ssl3`, `sslv3`). An SSLv3-only server is reachable without asking for it +- The OpenSSL build cache is keyed on the feature flags and version as well as the target, so changing what the build asks for now triggers a rebuild instead of silently reusing a build made a different way. The first run after upgrading rebuilds once +- README no longer claims export ciphers work. OpenSSL removed them outright in 1.1.0, so no build flag brings them back ## 0.10.0 diff --git a/README.md b/README.md index 66fbcb1..b55ca1e 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ cargo add blasthttp - **Batch connection reuse** — connections are pooled and reused within a batch, dramatically reducing overhead when scanning many URLs on the same hosts - **Rust performance** — async I/O, zero-copy where possible, and native concurrency give significant speed improvements over pure Python HTTP clients - **SSL cert info on every request** — extracts CN, SANs, emails, issuer, validity dates, and fingerprint during the TLS handshake that's already happening, eliminating the need for a separate connection to gather this information -- **Legacy TLS ciphers offered by default**: custom-compiled OpenSSL 3.3.2 with the legacy provider baked in, so a server that speaks only RC4, 3DES, SEED, Camellia or anonymous DH is reachable without naming the cipher yourself. TLS 1.0 and 1.1 are offered too. Null-encryption suites are the one exception, available through `cipher_string` but never offered by default +- **Legacy TLS offered by default**: custom-compiled OpenSSL 3.3.2 with the legacy provider baked in, so a server that speaks only RC4, 3DES, SEED, Camellia or anonymous DH is reachable without naming the cipher yourself. SSLv3, TLS 1.0 and TLS 1.1 are offered too. Null-encryption suites are the one exception, available through `cipher_string` but never offered by default - **No cert validation by default** — offensive-first: connects to self-signed, expired, and misconfigured TLS without extra config - **HTTP/2 support** — automatic via ALPN negotiation, falls back to HTTP/1.1 - **Low-level primitives** — `RawConnection` for byte-level TCP/TLS I/O (bypasses HTTP framing entirely) and `blasthttp.h2` for manual H2 frame construction (includes a permissive HPACK encoder that emits bytes a strict encoder refuses, the building block for H2 smuggling / CRLF-injection tooling) @@ -116,8 +116,8 @@ Output is JSON (one object per response), including status, headers, redirect ch | `-x, --proxy` | HTTP/SOCKS proxy URL | | | `--no-proxy` | Host that bypasses the proxy: `host`, `*.suffix`, IP, CIDR, or `*` (repeatable) | | | `--ciphers` | OpenSSL cipher string | `ALL` (every suite except null-encryption) | -| `--min-tls` | Minimum TLS version (1.0–1.3) | | -| `--max-tls` | Maximum TLS version (1.0–1.3) | | +| `--min-tls` | Minimum TLS version (`3.0` for SSLv3, or 1.0–1.3) | | +| `--max-tls` | Maximum TLS version (`3.0` for SSLv3, or 1.0–1.3) | | | `-v, --verbose` | Verbose output (-vv includes body) | | ## Python API @@ -240,8 +240,8 @@ All parameters except `url` are optional: | `proxy` | `str` | HTTP/SOCKS proxy URL | | `no_proxy` | `list[str]` | Hosts that bypass the proxy | | `cipher_string` | `str` | OpenSSL cipher string (default `ALL`: every suite the build provides except null-encryption) | -| `min_tls_version` | `str` | Minimum TLS version (`"1.0"`–`"1.3"`) | -| `max_tls_version` | `str` | Maximum TLS version (`"1.0"`–`"1.3"`) | +| `min_tls_version` | `str` | Minimum TLS version (`"3.0"` for SSLv3, or `"1.0"`–`"1.3"`) | +| `max_tls_version` | `str` | Maximum TLS version (`"3.0"` for SSLv3, or `"1.0"`–`"1.3"`) | | `retries` | `int` | Number of retries on failure | | `retry_wait_min_ms` | `int` | Minimum backoff between retries (ms) | | `retry_wait_max_ms` | `int` | Maximum backoff between retries (ms) | @@ -572,9 +572,9 @@ def http_mock(): ### 1. Build custom OpenSSL -blasthttp ships a script that downloads OpenSSL 3.3.2, compiles it with weak cipher support (`RC4`, `DES`, `3DES`, `SEED`), and installs it to `vendor/openssl/install/`. This only needs to be run once — the result is cached and reused. +blasthttp ships a script that downloads OpenSSL 3.3.2, compiles it with weak cipher support (`RC4`, `DES`, `3DES`, `SEED`) and `SSLv3`, and installs it to `vendor/openssl/install/`. The result is cached, keyed on the OpenSSL version, the target, and the feature flags, so changing any of those triggers a rebuild on the next run. -Two things the script asks for that the build does not currently produce: export ciphers, which OpenSSL removed outright in 1.1.0, and `SSLv3`, which ends up disabled despite `enable-ssl3` being passed (`configuration.h` defines `OPENSSL_NO_SSL3`, and `libssl.a` has no `SSLv3_method`). Neither is reachable today. +Export ciphers are the one thing the build cannot provide: OpenSSL removed them outright in 1.1.0, so no build flag brings them back. ```bash ./scripts/build-openssl.sh diff --git a/scripts/build-openssl.sh b/scripts/build-openssl.sh index 74a0805..39101ca 100755 --- a/scripts/build-openssl.sh +++ b/scripts/build-openssl.sh @@ -25,6 +25,26 @@ INSTALL_DIR="${VENDOR_DIR}/install" TARBALL="${VENDOR_DIR}/openssl-${OPENSSL_VERSION}.tar.gz" MARKER="${INSTALL_DIR}/.blasthttp-built" +# The features that make this build different from a stock OpenSSL. +# +# `enable-ssl3` and `enable-ssl3-method` both have to be here. OpenSSL's +# Configure has a disable cascade that reads "if ssl3-method is off, turn ssl3 +# off too", and ssl3-method is off by default. So enable-ssl3 on its own gets +# cascaded straight back to no-ssl3, which is exactly what happened here for +# a long time: the flag was passed, configdata.pm recorded both `enable-ssl3` +# and `no-ssl3`, and the build came out with OPENSSL_NO_SSL3 defined and no +# SSLv3_method symbols. +FEATURE_FLAGS=( + enable-weak-ssl-ciphers + enable-ssl3 + enable-ssl3-method + no-shared + no-module + no-tests + -fPIC +) + + # --- Cross-compilation support --- TARGET="${CARGO_BUILD_TARGET:-}" @@ -48,15 +68,24 @@ case "$TARGET" in ;; esac -# Skip if already built for the same target +# Identifies what the cached build actually is. Both the target and the +# feature flags belong in here: an existing checkout whose flags changed needs +# a rebuild just as much as one whose target changed, and keying on the target +# alone means a flag change is silently ignored until someone deletes the +# install directory by hand. +BUILD_RECIPE="${TARGET:-native}|${OPENSSL_VERSION}|${FEATURE_FLAGS[*]}" + +# Skip only if the cached build was made the same way if [ -f "$MARKER" ]; then - BUILT_TARGET=$(cat "$MARKER" 2>/dev/null || true) - if [ "$BUILT_TARGET" = "$TARGET" ]; then + BUILT_RECIPE=$(cat "$MARKER" 2>/dev/null || true) + if [ "$BUILT_RECIPE" = "$BUILD_RECIPE" ]; then echo "=== OpenSSL ${OPENSSL_VERSION} already built for '${TARGET:-native}' at ${INSTALL_DIR} ===" echo "=== Delete ${INSTALL_DIR} to force rebuild ===" exit 0 fi - echo "=== Rebuilding OpenSSL: target changed from '${BUILT_TARGET:-native}' to '${TARGET:-native}' ===" + echo "=== Rebuilding OpenSSL: build recipe changed ===" + echo "=== was: ${BUILT_RECIPE:-(unknown)}" + echo "=== now: ${BUILD_RECIPE}" rm -rf "$INSTALL_DIR" fi @@ -147,12 +176,7 @@ cd "$SOURCE_DIR" COMMON_ARGS=( --prefix="$INSTALL_DIR" - enable-weak-ssl-ciphers - enable-ssl3 - no-shared - no-module - no-tests - -fPIC + "${FEATURE_FLAGS[@]}" ) if [ -n "$openssl_target" ]; then @@ -186,9 +210,9 @@ make -j"$NUM_JOBS" echo "Installing..." make install_sw -# Mark as complete (store target for cache invalidation) +# Mark as complete (store the full recipe so a flag or target change invalidates) mkdir -p "$INSTALL_DIR" -echo "$TARGET" > "$MARKER" +echo "$BUILD_RECIPE" > "$MARKER" echo "" echo "=== OpenSSL ${OPENSSL_VERSION} built successfully ===" diff --git a/src/client/hyper.rs b/src/client/hyper.rs index 9721899..d85931a 100644 --- a/src/client/hyper.rs +++ b/src/client/hyper.rs @@ -257,12 +257,13 @@ fn encode_alpn_protocols(protos: &[String]) -> Result, ClientError> { fn parse_tls_version(s: &str) -> Result { match s.to_lowercase().as_str() { + "3.0" | "ssl3" | "sslv3" | "ssl3.0" => Ok(openssl::ssl::SslVersion::SSL3), "1.0" | "tls1.0" | "tlsv1.0" => Ok(openssl::ssl::SslVersion::TLS1), "1.1" | "tls1.1" | "tlsv1.1" => Ok(openssl::ssl::SslVersion::TLS1_1), "1.2" | "tls1.2" | "tlsv1.2" => Ok(openssl::ssl::SslVersion::TLS1_2), "1.3" | "tls1.3" | "tlsv1.3" => Ok(openssl::ssl::SslVersion::TLS1_3), _ => Err(ClientError::other(format!( - "unknown TLS version '{}' (use 1.0, 1.1, 1.2, 1.3)", + "unknown TLS version '{}' (use 3.0 for SSLv3, or 1.0, 1.1, 1.2, 1.3)", s ))), } @@ -360,6 +361,13 @@ impl OpenSslConnector { // This is an offensive-first tool — we need to connect to anything. builder.set_security_level(0); + // `SslConnector::builder` sets NO_SSLV3 for us. Clear it, or a server + // that speaks nothing newer stays unreachable even when the caller + // asks for SSLv3 by name. Clearing the option only permits the + // protocol; which versions actually get offered is still decided by + // the min/max proto version below. + builder.clear_options(openssl::ssl::SslOptions::NO_SSLV3); + if !config.should_verify_certs() { builder.set_verify(openssl::ssl::SslVerifyMode::NONE); } else { @@ -991,6 +999,9 @@ pub(crate) async fn connect_stream( .map_err(|e| ClientError::tls(format!("SSL setup failed: {}", e)))?; ssl_builder.set_security_level(0); + // Same reason as the pooled path: openssl-rs sets NO_SSLV3 and an SSLv3 + // server is unreachable until it is cleared. + ssl_builder.clear_options(openssl::ssl::SslOptions::NO_SSLV3); if !config.should_verify_certs() { ssl_builder.set_verify(openssl::ssl::SslVerifyMode::NONE); diff --git a/tests/legacy_default.rs b/tests/legacy_default.rs index e37cbad..d12664d 100644 --- a/tests/legacy_default.rs +++ b/tests/legacy_default.rs @@ -169,6 +169,40 @@ async fn test_null_cipher_reachable_when_asked_for() { // ── Legacy protocol versions, no client configuration ───────────── +#[tokio::test] +async fn test_sslv3_reachable_by_default() { + assert_reachable_with_version("SSLv3", SslVersion::SSL3).await; +} + +#[tokio::test] +async fn test_sslv3_reachable_when_pinned_explicitly() { + // The version can also be named, which had no spelling at all before: + // `parse_tls_version` only knew 1.0 through 1.3, so SSLv3 was + // unrequestable regardless of what the build supported. + let server = TlsTestServer::start(TlsServerConfig { + min_tls_version: Some(SslVersion::SSL3), + max_tls_version: Some(SslVersion::SSL3), + ..Default::default() + }) + .await; + + let mut config = default_config(&server.url()); + config.min_tls_version = Some("3.0".to_string()); + config.max_tls_version = Some("3.0".to_string()); + + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_ok(), + "explicitly pinned SSLv3 should connect: {:?}", + result.as_ref().err() + ); + assert_eq!(result.unwrap().status, 200); + + server.shutdown().await; +} + #[tokio::test] async fn test_tls10_reachable_by_default() { assert_reachable_with_version("TLS 1.0", SslVersion::TLS1).await; diff --git a/tests/tls_server.rs b/tests/tls_server.rs index 5988139..65a0a13 100644 --- a/tests/tls_server.rs +++ b/tests/tls_server.rs @@ -99,12 +99,14 @@ fn build_acceptor(config: &TlsServerConfig) -> SslAcceptor { // Clear ALL version restrictions and options builder.set_min_proto_version(None).unwrap(); builder.set_max_proto_version(None).unwrap(); - // Remove any SSL options that might disable specific TLS versions + // Remove any SSL options that might disable specific protocol versions, + // SSLv3 included, so a test can pin the server to it. builder.clear_options( SslOptions::NO_TLSV1_3 | SslOptions::NO_TLSV1_2 | SslOptions::NO_TLSV1_1 - | SslOptions::NO_TLSV1, + | SslOptions::NO_TLSV1 + | SslOptions::NO_SSLV3, ); // Accept all ciphers by default builder From f2fc7e68b4fbf6cd701828973d1d973ae59774df Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 5 Oct 2026 16:54:58 -0400 Subject: [PATCH 3/3] Keep anonymous suites out once verification is on `ALL` includes the aNULL suites, which carry no certificate. OpenSSL ignores verify-peer when no certificate arrives, and the hostname check rides along with the certificate check, so a caller who set verify_certs got a handshake that reported success while checking nothing. Anyone in the path could answer with an anonymous suite and be believed. The reasoning in the old comment was about the default rather than the setting: verification is off by default, so the anonymous suites gave up nothing that was being enforced. That holds right up until somebody turns it on, which is the case that matters. Append `!aNULL` to whichever list is in effect when verification is on, including one the caller named. verify_certs and cipher_string are in conflict there and this resolves it toward checking identities, since the alternative is skipping the check without saying so. Reaching anonymous suites is still a matter of leaving verification off. Both TLS paths get it, the pooled builder and connect_stream, which build their contexts separately. Nothing changes while verification is off: the ClientHello still carries the same 105 suites. With it on, the 17 anonymous ones are gone and 88 remain. PSK and SRP are the only other cert-less suites and both need a secret this client never configures, so neither can complete. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 5 +- README.md | 6 +- src/client/hyper.rs | 116 ++++++++++++++++++++++++++++----- tests/legacy_default.rs | 138 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d408874..92b2f43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,11 @@ ## 0.10.1 -- Legacy ciphers are offered by default, which is what the custom OpenSSL build has always been for. A server speaking only RC4, RC4-MD5, 3DES, SEED, Camellia or anonymous DH is now reachable without passing `cipher_string` yourself. Previously `SslConnector::builder` installed its own list (`DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:...`) and nothing replaced it, so those suites never reached the wire and such a server was unreachable at any setting. `set_security_level(0)` did not help: the security level governs how weak a negotiated cipher may be, not which ones are offered +- Legacy ciphers are offered by default, which is what the custom OpenSSL build has always been for. A server speaking only RC4, RC4-MD5, 3DES, SEED, Camellia or anonymous DH is now reachable without passing `cipher_string` yourself (anonymous DH while certificate verification is off, which is the default; see below). Previously `SslConnector::builder` installed its own list (`DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:...`) and nothing replaced it, so those suites never reached the wire and such a server was unreachable at any setting. `set_security_level(0)` did not help: the security level governs how weak a negotiated cipher may be, not which ones are offered +- Anonymous suites are excluded the moment certificate verification is turned on. They carry no certificate, and OpenSSL ignores verify-peer when no certificate arrives, so offering them to a caller who set `verify_certs` / `--verify` would have produced a handshake that reported success while checking nothing, hostname included. Anyone in the path could answer with an anonymous suite and be believed. `!aNULL` is now appended to whichever cipher list is in effect when verification is on, including one the caller named, since `cipher_string` and `verify_certs` are in conflict there and the check is the half worth keeping. With verification off, nothing changes - Null-encryption suites are the deliberate exception. They stay reachable through an explicit `cipher_string` but are never offered by default, since negotiating one by accident returns a connection that looks like TLS and encrypts nothing - The default applies on both TLS paths, the pooled client and the `resolve_ip` / `request_target` / `raw_connect` path, which build their SSL contexts separately -- This widens the ClientHello from 31 cipher suites to 105, which changes the client's TLS fingerprint. Anything matching on JA3/JA4 will see a different value than it did on 0.10.0 +- This widens the ClientHello from 31 cipher suites to 105, which changes the client's TLS fingerprint. Anything matching on JA3/JA4 will see a different value than it did on 0.10.0. Turning verification on drops the 17 anonymous suites and offers 88, so it has a fingerprint of its own - SSLv3 works, for the first time. It had never been reachable in any build: `scripts/build-openssl.sh` passed `enable-ssl3`, but OpenSSL's Configure has a disable cascade reading "if `ssl3-method` is off, turn `ssl3` off too", and `ssl3-method` is off by default. The flag was silently undone, so every shipped build had `OPENSSL_NO_SSL3` defined and no `SSLv3_method` symbols. The script now passes `enable-ssl3-method` as well - `SslConnector::builder` also sets `NO_SSLV3`, which nothing cleared, and `parse_tls_version` only understood 1.0 through 1.3, so SSLv3 had no spelling even if the build had provided it. Both are fixed: the option is cleared on both TLS paths, and `min_tls_version` / `max_tls_version` / `--min-tls` / `--max-tls` now accept `3.0` (also `ssl3`, `sslv3`). An SSLv3-only server is reachable without asking for it - The OpenSSL build cache is keyed on the feature flags and version as well as the target, so changing what the build asks for now triggers a rebuild instead of silently reusing a build made a different way. The first run after upgrading rebuilds once diff --git a/README.md b/README.md index b55ca1e..b27e84d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ cargo add blasthttp - **Batch connection reuse** — connections are pooled and reused within a batch, dramatically reducing overhead when scanning many URLs on the same hosts - **Rust performance** — async I/O, zero-copy where possible, and native concurrency give significant speed improvements over pure Python HTTP clients - **SSL cert info on every request** — extracts CN, SANs, emails, issuer, validity dates, and fingerprint during the TLS handshake that's already happening, eliminating the need for a separate connection to gather this information -- **Legacy TLS offered by default**: custom-compiled OpenSSL 3.3.2 with the legacy provider baked in, so a server that speaks only RC4, 3DES, SEED, Camellia or anonymous DH is reachable without naming the cipher yourself. SSLv3, TLS 1.0 and TLS 1.1 are offered too. Null-encryption suites are the one exception, available through `cipher_string` but never offered by default +- **Legacy TLS offered by default**: custom-compiled OpenSSL 3.3.2 with the legacy provider baked in, so a server that speaks only RC4, 3DES, SEED, Camellia or anonymous DH is reachable without naming the cipher yourself. SSLv3, TLS 1.0 and TLS 1.1 are offered too. Null-encryption suites are the one exception, available through `cipher_string` but never offered by default. Anonymous suites carry no certificate, so they are dropped whenever certificate verification is on - **No cert validation by default** — offensive-first: connects to self-signed, expired, and misconfigured TLS without extra config - **HTTP/2 support** — automatic via ALPN negotiation, falls back to HTTP/1.1 - **Low-level primitives** — `RawConnection` for byte-level TCP/TLS I/O (bypasses HTTP framing entirely) and `blasthttp.h2` for manual H2 frame construction (includes a permissive HPACK encoder that emits bytes a strict encoder refuses, the building block for H2 smuggling / CRLF-injection tooling) @@ -115,7 +115,7 @@ Output is JSON (one object per response), including status, headers, redirect ch | `--verify` | Enable TLS cert validation | off | | `-x, --proxy` | HTTP/SOCKS proxy URL | | | `--no-proxy` | Host that bypasses the proxy: `host`, `*.suffix`, IP, CIDR, or `*` (repeatable) | | -| `--ciphers` | OpenSSL cipher string | `ALL` (every suite except null-encryption) | +| `--ciphers` | OpenSSL cipher string | `ALL` (every suite except null-encryption; `ALL:!aNULL` under `--verify`) | | `--min-tls` | Minimum TLS version (`3.0` for SSLv3, or 1.0–1.3) | | | `--max-tls` | Maximum TLS version (`3.0` for SSLv3, or 1.0–1.3) | | | `-v, --verbose` | Verbose output (-vv includes body) | | @@ -239,7 +239,7 @@ All parameters except `url` are optional: | `verify_certs` | `bool` | Enable TLS cert validation (default `False`) | | `proxy` | `str` | HTTP/SOCKS proxy URL | | `no_proxy` | `list[str]` | Hosts that bypass the proxy | -| `cipher_string` | `str` | OpenSSL cipher string (default `ALL`: every suite the build provides except null-encryption) | +| `cipher_string` | `str` | OpenSSL cipher string (default `ALL`: every suite the build provides except null-encryption). `:!aNULL` is appended whenever `verify_certs` is on, since an anonymous suite would skip the check | | `min_tls_version` | `str` | Minimum TLS version (`"3.0"` for SSLv3, or `"1.0"`–`"1.3"`) | | `max_tls_version` | `str` | Maximum TLS version (`"3.0"` for SSLv3, or `"1.0"`–`"1.3"`) | | `retries` | `int` | Number of retries on failure | diff --git a/src/client/hyper.rs b/src/client/hyper.rs index d85931a..ed586c8 100644 --- a/src/client/hyper.rs +++ b/src/client/hyper.rs @@ -7,6 +7,7 @@ use http_body_util::BodyExt; use hyper_util::client::legacy::Client; use hyper_util::client::legacy::connect::HttpConnector; use hyper_util::rt::TokioExecutor; +use std::borrow::Cow; use std::collections::HashMap; use std::future::Future; use std::io::Read; @@ -70,14 +71,48 @@ fn sanitize_uri(url: &str) -> String { /// OpenSSL. /// /// `ALL` is every suite the build provides except the eNULL (no encryption) -/// ones. It does include the aNULL suites, which skip authentication: those -/// are worth reaching for a scanner, and this client already defaults to not -/// verifying certificates, so they give up nothing that was being enforced. -/// Null *encryption* stays opt-in through `cipher_string`, because negotiating -/// it by default would hand back a connection that looks like TLS and encrypts -/// nothing. +/// ones. Null *encryption* stays opt-in through `cipher_string`, because +/// negotiating it by default would hand back a connection that looks like TLS +/// and encrypts nothing. +/// +/// `ALL` does include the aNULL suites, which carry no certificate at all. +/// Those are worth reaching when nobody asked us to check identities, which is +/// this client's default. They must not be offered once somebody has, and that +/// is what [`verified_cipher_list`] is for. const DEFAULT_CIPHER_LIST: &str = "ALL"; +/// The cipher list to offer, given what the caller asked for. +/// +/// Certificate verification is off by default here, and while it is off the +/// anonymous suites cost nothing: there is no identity being checked for them +/// to slip past. Turning verification on changes that. An anonymous suite +/// sends no certificate, and OpenSSL ignores verify-peer when no certificate +/// arrives, so the handshake succeeds, the verify result reads OK, and the +/// hostname check attached to it never runs. Anyone in the path can answer +/// with an anonymous suite and be believed. Offering them would quietly cancel +/// the setting instead of honoring it. +/// +/// So when verification is on, `!aNULL` goes on the end of whatever list is in +/// effect, including one the caller named. Two settings are in conflict there +/// and this resolves it toward checking identities, because the alternative is +/// to skip the check without saying so. Reaching anonymous suites is still a +/// matter of leaving verification off, which is the honest way to ask. +/// +/// Nothing else needs excluding. PSK and SRP are the only other suites that +/// finish without a certificate, and both need a secret this client never +/// configures, so neither can complete. +fn verified_cipher_list(config: &RequestConfig) -> Cow<'_, str> { + let base = config + .cipher_string + .as_deref() + .unwrap_or(DEFAULT_CIPHER_LIST); + if config.should_verify_certs() { + Cow::Owned(format!("{}:!aNULL", base)) + } else { + Cow::Borrowed(base) + } +} + // Load the OpenSSL legacy provider once (for RC4, DES, etc.). // The provider is statically compiled into libcrypto via `no-module` build flag. // `Once` ensures this runs exactly once even across threads. @@ -374,12 +409,9 @@ impl OpenSslConnector { load_system_ca_certs(&mut builder)?; } - let ciphers = config - .cipher_string - .as_deref() - .unwrap_or(DEFAULT_CIPHER_LIST); + let ciphers = verified_cipher_list(config); builder - .set_cipher_list(ciphers) + .set_cipher_list(&ciphers) .map_err(|e| ClientError::tls(format!("invalid cipher string '{}': {}", ciphers, e)))?; if let Some(ref min_ver) = config.min_tls_version { @@ -1008,12 +1040,9 @@ pub(crate) async fn connect_stream( } else { load_system_ca_certs(&mut ssl_builder)?; } - let ciphers = config - .cipher_string - .as_deref() - .unwrap_or(DEFAULT_CIPHER_LIST); + let ciphers = verified_cipher_list(config); ssl_builder - .set_cipher_list(ciphers) + .set_cipher_list(&ciphers) .map_err(|e| ClientError::tls(format!("invalid cipher string '{}': {}", ciphers, e)))?; if let Some(ref min_ver) = config.min_tls_version { let version = parse_tls_version(min_ver)?; @@ -2980,4 +3009,59 @@ mod tests { assert_eq!(out, SAMPLE); assert!(!cut); } + + // ── verified_cipher_list ────────────────────────────────────── + + fn cfg_with(verify: Option, ciphers: Option<&str>) -> RequestConfig { + let mut c = RequestConfig::new("https://example.com".to_string()); + c.verify_certs = verify; + c.cipher_string = ciphers.map(str::to_string); + c + } + + #[test] + fn default_offers_everything_including_anonymous_suites() { + // The default path has to stay exactly as it was: `ALL`, untouched. + assert_eq!(verified_cipher_list(&cfg_with(None, None)), "ALL"); + assert_eq!(verified_cipher_list(&cfg_with(Some(false), None)), "ALL"); + } + + #[test] + fn default_path_passes_a_caller_list_through_unchanged() { + assert_eq!( + verified_cipher_list(&cfg_with(Some(false), Some("RC4-SHA"))), + "RC4-SHA" + ); + } + + #[test] + fn verifying_drops_the_anonymous_suites() { + assert_eq!( + verified_cipher_list(&cfg_with(Some(true), None)), + "ALL:!aNULL" + ); + } + + #[test] + fn verifying_drops_them_from_a_caller_list_too() { + // Otherwise `verify_certs` plus an explicit list is a way back to a + // handshake with no certificate in it. + assert_eq!( + verified_cipher_list(&cfg_with(Some(true), Some("ALL"))), + "ALL:!aNULL" + ); + assert_eq!( + verified_cipher_list(&cfg_with(Some(true), Some("ADH-AES128-SHA"))), + "ADH-AES128-SHA:!aNULL" + ); + } + + #[test] + fn default_path_allocates_nothing() { + // Borrowed when off, owned only when verifying. + assert!(matches!( + verified_cipher_list(&cfg_with(Some(false), None)), + Cow::Borrowed(_) + )); + } } diff --git a/tests/legacy_default.rs b/tests/legacy_default.rs index d12664d..fa9bb02 100644 --- a/tests/legacy_default.rs +++ b/tests/legacy_default.rs @@ -236,3 +236,141 @@ async fn test_modern_still_reachable_by_default() { server.shutdown().await; } + +// ── Anonymous suites must not survive turning verification on ───── +// +// These are the other half of `test_anon_dh_reachable_by_default`. Anonymous +// suites are reachable while nobody has asked us to check identities, and have +// to be gone the moment somebody does. An anonymous suite sends no +// certificate, OpenSSL ignores verify-peer when no certificate arrives, and +// the hostname check rides along with the certificate check, so offering one +// to a caller who set `verify_certs` would hand them a handshake that reports +// success while checking nothing. + +/// Stand up an anonymous-DH-only server, ask for verification, and assert we +/// do not reach it. `cipher_string` is whatever the caller would have set. +async fn assert_unreachable_with_verification(cipher_string: Option<&str>) { + let server = TlsTestServer::start(TlsServerConfig { + cipher_list: Some("ADH-AES128-SHA".to_string()), + // The anonymous suites only exist at 1.2 and below. + max_tls_version: Some(SslVersion::TLS1_2), + ..Default::default() + }) + .await; + + let mut config = RequestConfig::new(server.url()); + config.verify_certs = Some(true); + config.cipher_string = cipher_string.map(str::to_string); + config.timeout_seconds = Some(5); + + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_err(), + "verify_certs was on, so a server with no certificate must be out of \ + reach, but the request succeeded with status {:?}", + result.as_ref().ok().map(|r| r.status) + ); + + server.shutdown().await; +} + +#[tokio::test] +async fn test_anon_dh_unreachable_when_verifying() { + assert_unreachable_with_verification(None).await; +} + +#[tokio::test] +async fn test_anon_dh_unreachable_when_verifying_with_explicit_ciphers() { + // `ALL` admits the anonymous suites, and it is an ordinary thing to pass + // without meaning to give up authentication. The caller also asked for + // verification, so that is the half we honor. + assert_unreachable_with_verification(Some("ALL")).await; +} + +#[tokio::test] +async fn test_verification_does_not_disturb_ordinary_ciphers() { + // Excluding the anonymous suites must not cost us anything else: a normal + // server with a certificate the client trusts is still reachable. The CA + // here is the test harness's own, handed to the client explicitly. + let server = TlsTestServer::start(TlsServerConfig { + max_tls_version: Some(SslVersion::TLS1_2), + ..Default::default() + }) + .await; + + let mut config = RequestConfig::new(server.url()); + config.verify_certs = Some(false); + config.cipher_string = Some("ALL:!aNULL".to_string()); + config.timeout_seconds = Some(5); + + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_ok(), + "excluding anonymous suites should leave ordinary ones alone: {:?}", + result.as_ref().err() + ); + assert_eq!(result.unwrap().status, 200); + + server.shutdown().await; +} + +#[tokio::test] +async fn test_anon_dh_unreachable_when_verifying_on_direct_path() { + // The pooled connector and `connect_stream` build their TLS contexts + // separately, and only the second one is used once `resolve_ip` is set. + // Both have to exclude the anonymous suites, so cover the other path. + let server = TlsTestServer::start(TlsServerConfig { + cipher_list: Some("ADH-AES128-SHA".to_string()), + max_tls_version: Some(SslVersion::TLS1_2), + ..Default::default() + }) + .await; + + let mut config = RequestConfig::new(server.url()); + config.verify_certs = Some(true); + config.resolve_ip = Some("127.0.0.1".to_string()); + config.timeout_seconds = Some(5); + + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_err(), + "the direct path must exclude anonymous suites too, but got status {:?}", + result.as_ref().ok().map(|r| r.status) + ); + + server.shutdown().await; +} + +#[tokio::test] +async fn test_anon_dh_reachable_on_direct_path_without_verification() { + // ...and with verification off, that same path still reaches it. This is + // the half that must not change. + let server = TlsTestServer::start(TlsServerConfig { + cipher_list: Some("ADH-AES128-SHA".to_string()), + max_tls_version: Some(SslVersion::TLS1_2), + ..Default::default() + }) + .await; + + let mut config = default_config(&server.url()); + config.resolve_ip = Some("127.0.0.1".to_string()); + + let client = HyperClient::new(); + let result = client.send(&config).await; + + assert!( + result.is_ok(), + "verification is off, so the direct path should still reach an \ + anonymous-DH server: {:?}", + result.as_ref().err() + ); + assert_eq!(result.unwrap().status, 200); + + server.shutdown().await; +}