Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## Unreleased

- 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.1. 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
- README no longer claims export ciphers work. OpenSSL removed them outright in 1.1.0, so no build flag brings them back

## 0.10.1

- Python 3.15 support: PyO3 0.30, and 3.15 wheels are published
Expand Down
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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)
Expand Down Expand Up @@ -115,9 +115,9 @@ 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 |
| `--min-tls` | Minimum TLS version (1.0–1.3) | |
| `--max-tls` | Maximum TLS version (1.0–1.3) | |
| `--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) | |

## Python API
Expand Down Expand Up @@ -239,9 +239,9 @@ 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 |
| `min_tls_version` | `str` | Minimum TLS version (`"1.0"`–`"1.3"`) |
| `max_tls_version` | `str` | Maximum TLS version (`"1.0"`–`"1.3"`) |
| `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 |
| `retry_wait_min_ms` | `int` | Minimum backoff between retries (ms) |
| `retry_wait_max_ms` | `int` | Maximum backoff between retries (ms) |
Expand Down Expand Up @@ -574,7 +574,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 `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.

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
Expand Down
48 changes: 36 additions & 12 deletions scripts/build-openssl.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}"

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ==="
Expand Down
140 changes: 129 additions & 11 deletions src/client/hyper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,6 +59,60 @@ 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. 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.
Expand Down Expand Up @@ -275,12 +330,13 @@ fn encode_alpn_protocols(protos: &[String]) -> Result<Vec<u8>, ClientError> {

fn parse_tls_version(s: &str) -> Result<openssl::ssl::SslVersion, ClientError> {
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
))),
}
Expand Down Expand Up @@ -378,17 +434,23 @@ 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 {
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 = verified_cipher_list(config);
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)?;
Expand Down Expand Up @@ -1048,17 +1110,19 @@ 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);
} 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 = verified_cipher_list(config);
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
Expand Down Expand Up @@ -3110,6 +3174,60 @@ mod tests {
assert!(!cut);
}

// ── verified_cipher_list ──────────────────────────────────────

fn cfg_with(verify: Option<bool>, 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(_)
));
}
/// The pooled path and `raw_connect` have to land on the same proxy, so
/// the endpoint the connector dials must match what `parse_proxy_url`
/// resolved. Portless URLs are where these used to disagree: handing the
Expand Down
Loading
Loading