diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 3fadea2..ecb38fc 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Generate token from GitHub App id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index b89c7fe..9404b22 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -53,7 +53,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Cache OpenSSL build - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: vendor/openssl/install key: openssl-3.3.2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/build-openssl.sh') }} diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 44ba000..3e7c658 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -23,7 +23,7 @@ jobs: run: cargo fmt --all -- --check - name: Cache OpenSSL build - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: vendor/openssl/install key: openssl-3.3.2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/build-openssl.sh') }} @@ -54,7 +54,7 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Cache OpenSSL build - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: vendor/openssl/install key: openssl-3.3.2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/build-openssl.sh') }} diff --git a/Cargo.lock b/Cargo.lock index 852f26e..ee28f52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,7 +99,7 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "blasthttp" -version = "0.6.1" +version = "0.7.0" dependencies = [ "brotli", "bytes", @@ -125,9 +125,9 @@ dependencies = [ [[package]] name = "brotli" -version = "7.0.0" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -136,9 +136,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "4.0.3" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -565,9 +565,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags", "cfg-if", @@ -590,9 +590,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -856,9 +856,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", diff --git a/Cargo.toml b/Cargo.toml index 4eec481..3a7df73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "blasthttp" -version = "0.6.1" +version = "0.7.0" edition = "2024" description = "Offensive-first HTTP library with Python bindings" license = "GPL-3.0" @@ -42,7 +42,7 @@ http = "1" # Decompression flate2 = "1" -brotli = "7" +brotli = "8" # Hashing โ€” response fingerprinting (matches BBOT's hash format) murmur3 = "0.5" diff --git a/pyproject.toml b/pyproject.toml index df1e0c2..2002f3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "blasthttp" -version = "0.6.1" +version = "0.7.0" description = "Offensive-first HTTP library" license = "GPL-3.0" requires-python = ">=3.10" @@ -20,7 +20,7 @@ managed = false dev = [ "pytest>=8", "pytest-asyncio>=0.23", - "ruff==0.15.10", + "ruff==0.15.13", ] [tool.pytest.ini_options] diff --git a/src/client/hyper.rs b/src/client/hyper.rs index 8154054..43815ce 100644 --- a/src/client/hyper.rs +++ b/src/client/hyper.rs @@ -626,7 +626,10 @@ async fn dispatch_request( config: &RequestConfig, log: &DebugLog, ) -> Result { - let request = build_request(uri, config, false)?; + // The pooled high-level client populates Host / :authority from the URI + // itself, so we don't add a Host header here. Adding it would cause + // duplicate :authority + host in the HTTP/2 HPACK block. + let request = build_request(uri, config, false, false)?; let v = config.verbosity; debug_record(log, v, 1, " Request headers:"); @@ -882,8 +885,11 @@ async fn dispatch_direct( // Use origin-form unless request_target was explicitly set (caller wants // exact control over the request-line, e.g. absolute-form for SSRF testing). + // The low-level http1 sender below doesn't auto-populate Host from the URI + // the way the pooled high-level client does, so we ask build_request to do + // it manually. let use_origin_form = config.request_target.is_none(); - let request = build_request(&request_uri, config, use_origin_form)?; + let request = build_request(&request_uri, config, use_origin_form, true)?; debug_record(log, v, 1, " Request headers:"); for (name, value) in request.headers() { @@ -962,8 +968,9 @@ async fn dispatch_forward_proxy( let _ = conn.await; }); - // Build request with absolute-form URI (SendRequest does NOT normalize it) - let request = build_request(target_uri, config, false)?; + // Build request with absolute-form URI (SendRequest does NOT normalize it). + // The low-level http1 sender doesn't auto-populate Host, so add it manually. + let request = build_request(target_uri, config, false, true)?; let v = config.verbosity; debug_record(log, v, 1, " Request headers:"); @@ -989,6 +996,7 @@ fn build_request( uri: &http::Uri, config: &RequestConfig, origin_form: bool, + manual_host_header: bool, ) -> Result, ClientError> { // For direct connections (dispatch_direct), use origin-form (path + query only) // in the request-line per RFC 7230 ยง5.3.1. For pooled/client connections, @@ -1026,8 +1034,16 @@ fn build_request( // Auto-set Host from URI (HTTP/1.1 requirement) unless the caller supplies // their own. hyper's low-level handshake API (used by dispatch_direct for - // resolve_ip / request_target) does not auto-set Host, so we must do it. - if !has_custom_host && let Some(authority) = uri.authority() { + // resolve_ip / request_target, and by the forward-proxy path) does not + // auto-set Host, so we must do it. For the pooled high-level client we + // skip this โ€” hyper populates Host (HTTP/1.1) or :authority (HTTP/2) from + // the URI itself, and adding our own Host on top would land as a duplicate + // header in the HTTP/2 HPACK block alongside :authority, which some + // origin servers/WAFs reject as a protocol violation. + if manual_host_header + && !has_custom_host + && let Some(authority) = uri.authority() + { builder = builder.header("Host", authority.as_str()); } @@ -1530,22 +1546,48 @@ mod tests { fn test_build_request_auto_host_from_uri() { let uri: http::Uri = "http://example.com:8080/path".parse().unwrap(); let config = RequestConfig::new("http://example.com:8080/path".to_string()); - let req = build_request(&uri, &config, true).unwrap(); + let req = build_request(&uri, &config, true, true).unwrap(); assert_eq!(req.headers().get("host").unwrap(), "example.com:8080"); } + #[test] + fn test_build_request_no_manual_host_for_pooled_path() { + // The pooled high-level client populates Host / :authority from the + // URI itself, so build_request must not add a Host header on top. + // Sending both would land as a duplicate :authority + host in the + // HTTP/2 HPACK block, which some origin servers reject. + let uri: http::Uri = "http://example.com:8080/path".parse().unwrap(); + let config = RequestConfig::new("http://example.com:8080/path".to_string()); + let req = build_request(&uri, &config, false, false).unwrap(); + assert!(req.headers().get("host").is_none()); + } + #[test] fn test_build_request_custom_host_overrides_auto() { let uri: http::Uri = "http://example.com:8080/path".parse().unwrap(); let mut config = RequestConfig::new("http://example.com:8080/path".to_string()); config.headers = Some(vec![("Host".to_string(), "custom.host".to_string())]); - let req = build_request(&uri, &config, true).unwrap(); + let req = build_request(&uri, &config, true, true).unwrap(); // Should only have the custom Host, not auto-derived let hosts: Vec<_> = req.headers().get_all("host").iter().collect(); assert_eq!(hosts.len(), 1); assert_eq!(hosts[0], "custom.host"); } + #[test] + fn test_build_request_custom_host_passes_through_pooled_path() { + // Even on the pooled path (manual_host_header=false), a caller-supplied + // Host must be preserved โ€” that's how virtualhost / host-header probes + // override the auto-derived value. + let uri: http::Uri = "http://example.com:8080/path".parse().unwrap(); + let mut config = RequestConfig::new("http://example.com:8080/path".to_string()); + config.headers = Some(vec![("Host".to_string(), "custom.host".to_string())]); + let req = build_request(&uri, &config, false, false).unwrap(); + let hosts: Vec<_> = req.headers().get_all("host").iter().collect(); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0], "custom.host"); + } + #[test] fn test_build_request_multiple_host_headers() { let uri: http::Uri = "http://example.com/".parse().unwrap(); @@ -1554,7 +1596,7 @@ mod tests { ("Host".to_string(), "first.host".to_string()), ("Host".to_string(), "second.host".to_string()), ]); - let req = build_request(&uri, &config, true).unwrap(); + let req = build_request(&uri, &config, true, true).unwrap(); let hosts: Vec<_> = req.headers().get_all("host").iter().collect(); assert_eq!(hosts.len(), 2); assert_eq!(hosts[0], "first.host"); @@ -1565,7 +1607,7 @@ mod tests { fn test_build_request_origin_form_strips_authority() { let uri: http::Uri = "http://example.com:8080/path?q=1".parse().unwrap(); let config = RequestConfig::new("http://example.com:8080/path?q=1".to_string()); - let req = build_request(&uri, &config, true).unwrap(); + let req = build_request(&uri, &config, true, true).unwrap(); assert_eq!(req.uri(), "/path?q=1"); } @@ -1573,7 +1615,7 @@ mod tests { fn test_build_request_absolute_form_preserves_uri() { let uri: http::Uri = "http://example.com:8080/path?q=1".parse().unwrap(); let config = RequestConfig::new("http://example.com:8080/path?q=1".to_string()); - let req = build_request(&uri, &config, false).unwrap(); + let req = build_request(&uri, &config, false, false).unwrap(); assert_eq!(req.uri().to_string(), "http://example.com:8080/path?q=1"); } @@ -1583,7 +1625,7 @@ mod tests { // Simulate: origin_form=false (as dispatch_direct does when request_target is Some) let uri: http::Uri = "http://evil.com/admin".parse().unwrap(); let config = RequestConfig::new("http://example.com/".to_string()); - let req = build_request(&uri, &config, false).unwrap(); + let req = build_request(&uri, &config, false, true).unwrap(); assert_eq!(req.uri().to_string(), "http://evil.com/admin"); } }