Skip to content

Commit 92e5424

Browse files
committed
fix: resolve flaky tests and add library unit test coverage
- fix(hpx/tests): fix with_concurrency_limit_layer_success by increasing concurrency limit from 1 to 2 and inner timeout from 200ms to 300ms, preventing third concurrent request from exceeding the timeout bound - fix(hpx/tests): replace external httpbin.org dependency in http1_send_case_sensitive_headers with a local low_level_with_response server that inspects raw HTTP/1.1 bytes for case-sensitive headers - test(hpx): add unit tests for cookie::Jar (set/retrieve, domain scoping) - test(hpx): add unit tests for redirect (sensitive header stripping, same-origin preservation, referer policy for https-to-http) - test(hpx): add unit tests for retry::Policy (default, never, scoped, classify_fn, budget, max_retries, first_retry_delay_clamping) - test(hpx): add unit tests for proxy (no_proxy exclusion, basic auth, custom auth, http-only interception, socks default ports) - test(hpx): add NSS keylog unit tests for TLS debugging - test(hpx): add proxy pool failure classifier and proxy auth tests - feat(hpx-browser): extend SSRF protection with private/loopback/link-local address checks, IPv6 mapped IPv4 detection, and additional block ranges - feat(hpx-dl): add checksum verification variants (Sha256Truncated, LowerHexCaseInsensitive) and engine concurrency controls - chore: add specs context and README for SSRF documentation
1 parent a29a9ae commit 92e5424

38 files changed

Lines changed: 2201 additions & 153 deletions

AGENTS.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,24 +30,24 @@
3030

3131
When introducing new dependencies, prefer these versions unless compatibility requires an upgrade:
3232

33-
- `clap = "4.5.60"`
33+
- `clap = "4.6.1"`
3434
- `config = "0.15.19"`
3535
- `eyre = "0.6.12"`
3636
- `serde = "1.0.228"`
3737
- `thiserror = "2.0.18"`
38-
- `tokio = "1.49.0"`
38+
- `tokio = "1.52.1"`
3939
- `tracing = "0.1.44"`
40-
- `tracing-subscriber = "0.3.22"`
40+
- `tracing-subscriber = "0.3.23"`
4141
- `tracing-opentelemetry = "0.32.1"`
4242
- `opentelemetry = "0.31.0"`
4343
- `opentelemetry-otlp = "0.31.0"`
44-
- `sqlx = "=0.9.0-alpha.1"`
44+
- `sqlx = "0.9.0"`
4545
- `utoipa = "5.4.0"`
4646
- `utoipa-swagger-ui = "9.0.2"`
47-
- `arc-swap = "1.8.2"`
48-
- `hpx = "2.3.1"`
49-
- `scc = "3.6.5"`
50-
- `winnow = "0.7.14"`
47+
- `arc-swap = "1.9.2"`
48+
- `hpx = "2.5.4"`
49+
- `scc = "3.8.4"`
50+
- `winnow = "1.0.3"`
5151
- `shadow-rs = "1.7.0"`
5252
- `ecdysis = "1.0.1"`
5353

Cargo.toml

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ members = ["bin/*", "crates/*"]
33
resolver = "3"
44

55
[workspace.package]
6-
version = "2.5.3"
6+
version = "2.5.4"
77
edition = "2024"
88
authors = ["Akagi201 <akagi201@gmail.com>"]
99
license = "Apache-2.0"
@@ -184,11 +184,11 @@ all = "warn"
184184

185185
[workspace.dependencies]
186186
# local crates
187-
hpx = { path = "crates/hpx", version = "2.5.3" }
188-
hpx-browser = { path = "crates/hpx-browser", version = "2.5.3" }
189-
hpx-cli = { path = "crates/hpx-cli", version = "2.5.3" }
190-
hpx-dl = { path = "crates/hpx-dl", version = "2.5.3" }
191-
hpx-yawc = { path = "crates/yawc", version = "2.5.3" }
187+
hpx = { path = "crates/hpx", version = "2.5.4" }
188+
hpx-browser = { path = "crates/hpx-browser", version = "2.5.4" }
189+
hpx-cli = { path = "crates/hpx-cli", version = "2.5.4" }
190+
hpx-dl = { path = "crates/hpx-dl", version = "2.5.4" }
191+
hpx-yawc = { path = "crates/yawc", version = "2.5.4" }
192192

193193
# external crates
194194
ahash = "0.8.12"
@@ -199,9 +199,9 @@ async-trait = "0.1.80"
199199
axum = "0.8.9"
200200
axum-core = "0.5.6"
201201
base64 = "0.22.1"
202-
blitz-dom = { git = "https://github.com/DioxusLabs/blitz.git", rev = "77435aadb92c6bf47e50d74810c52120f3aa3f37", version = "=0.3.0-alpha.6", features = ["floats"] }
203-
blitz-html = { git = "https://github.com/DioxusLabs/blitz.git", rev = "77435aadb92c6bf47e50d74810c52120f3aa3f37", version = "=0.3.0-alpha.6" }
204-
blitz-traits = { git = "https://github.com/DioxusLabs/blitz.git", rev = "77435aadb92c6bf47e50d74810c52120f3aa3f37", version = "=0.3.0-alpha.6" }
202+
blitz-dom = { git = "https://github.com/DioxusLabs/blitz.git", rev = "406a571491523d69d52c396f240b14edd1a1974f", version = "=0.3.0-alpha.6", features = ["floats"] }
203+
blitz-html = { git = "https://github.com/DioxusLabs/blitz.git", rev = "406a571491523d69d52c396f240b14edd1a1974f", version = "=0.3.0-alpha.6" }
204+
blitz-traits = { git = "https://github.com/DioxusLabs/blitz.git", rev = "406a571491523d69d52c396f240b14edd1a1974f", version = "=0.3.0-alpha.6" }
205205
boring = "5.1.0"
206206
brotli = "8.0.4"
207207
bytes = "1.12.0"
@@ -213,7 +213,7 @@ criterion = "0.8.2"
213213
crossbeam-channel = "0.5.16"
214214
csv = "1.4.0"
215215
cucumber = "0.23.0"
216-
deno_core = "0.406.0"
216+
deno_core = "0.407.0"
217217
der = "0.8.0"
218218
digest = "0.11.3"
219219
encoding_rs = "0.8.35"

Justfile

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ lint:
1010
cargo +nightly fmt --all -- --check
1111
cargo +nightly clippy --all -- -D warnings
1212
cargo shear
13+
just check-agents-md
1314
test:
1415
#!/usr/bin/env bash
1516
set -euo pipefail
@@ -51,6 +52,30 @@ test-coverage:
5152
else
5253
cargo tarpaulin --all-features --workspace --timeout 300
5354
fi
55+
# Check that AGENTS.md dependency versions match Cargo.toml
56+
check-agents-md:
57+
#!/usr/bin/env bash
58+
errors=0
59+
while IFS= read -r line; do
60+
crate=$(echo "$line" | sed -n 's/.*`\([^ ]*\) = ".*/\1/p')
61+
agents_ver=$(echo "$line" | sed -n 's/.*"\([^"]*\)".*/\1/p')
62+
if [ -z "$crate" ] || [ -z "$agents_ver" ]; then
63+
continue
64+
fi
65+
cargo_ver=$(grep -E "^${crate} = " Cargo.toml 2>/dev/null | sed -n 's/.*"\([^"]*\)".*/\1/p' | head -1)
66+
if [ -z "$cargo_ver" ]; then
67+
continue
68+
fi
69+
if [ "$agents_ver" != "$cargo_ver" ]; then
70+
echo "MISMATCH: ${crate}: AGENTS.md=${agents_ver} Cargo.toml=${cargo_ver}"
71+
errors=$((errors + 1))
72+
fi
73+
done < <(sed -n '/^## Preferred Dependencies/,/^##/p' AGENTS.md | grep '^\-.*\`.*=.*"')
74+
if [ $errors -gt 0 ]; then
75+
echo "FAIL: ${errors} version mismatch(es) between AGENTS.md and Cargo.toml"
76+
exit 1
77+
fi
78+
echo "OK: AGENTS.md versions match Cargo.toml"
5479
check-feature:
5580
#!/usr/bin/env bash
5681
set -euo pipefail

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ Default: `boring-tls`, `http1`, `http2`, `stream`, `tracing`.
291291
| `rustls-tls` | No | Rustls TLS backend (pure Rust) |
292292
| `openssl-tls` | No | OpenSSL TLS backend |
293293
| `openssl-vendored` | No | OpenSSL with vendored static linking |
294+
| `keylog` | No | TLS key logging to file (for debugging) — NOT for production |
294295
| `webpki-roots` | No | WebPKI root certificates |
295296
| **HTTP** | | |
296297
| `http1` | **Yes** | HTTP/1.1 support |

bin/hpx-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ tokio = { workspace = true, features = [
4545
"process",
4646
] }
4747
tracing-subscriber = { workspace = true }
48+
url = { workspace = true }
4849
uuid = { workspace = true }
4950

5051
[dev-dependencies]

bin/hpx-cli/src/browser.rs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ pub(crate) async fn handle_fetch(
2323
output: Option<PathBuf>,
2424
quiet: bool,
2525
_obey_robots: bool,
26-
_allow_private_network: bool,
26+
allow_private_network: bool,
2727
_v8_flags: Option<String>,
2828
_storage_dir: Option<PathBuf>,
2929
) -> eyre::Result<()> {
@@ -42,6 +42,18 @@ pub(crate) async fn handle_fetch(
4242

4343
// Original format: short-circuit browser, use raw HTTP client.
4444
if dump == DumpFormat::Original {
45+
// SSRF check — reject private/special-use IPs unless explicitly allowed.
46+
// Uses DNS resolution to prevent DNS rebinding attacks.
47+
let parsed = url::Url::parse(&url)?;
48+
if !allow_private_network
49+
&& let Some(host) = parsed.host_str()
50+
&& hpx_browser::net::ssrf::is_forbidden_resolved(host).await
51+
{
52+
eyre::bail!(
53+
"SSRF protection: request to {host} is forbidden (use --allow-private-network to bypass)"
54+
);
55+
}
56+
4557
let client = hpx::Client::new();
4658
let resp = client.get(&url).send().await.wrap_err("fetch failed")?;
4759
let bytes = resp.bytes().await.wrap_err("reading response body")?;
@@ -260,10 +272,6 @@ pub(crate) async fn handle_scrape(
260272
}
261273
}
262274

263-
let _ = (obey_robots, allow_private_network, v8_flags, storage_dir);
264-
// ponytail: obey_robots / allow_private_network / v8_flags / storage_dir
265-
// not yet wired to in-process worker. Ignored for now.
266-
267275
let sem = std::sync::Arc::new(Semaphore::new(concurrency));
268276
let eval = std::sync::Arc::new(eval);
269277

@@ -396,10 +404,11 @@ pub(crate) async fn handle_serve(
396404
_storage_dir: Option<PathBuf>,
397405
quiet: bool,
398406
_obey_robots: bool,
399-
_allow_private_network: bool,
407+
allow_private_network: bool,
400408
_v8_flags: Option<String>,
401409
) -> eyre::Result<()> {
402-
// ponytail: allow_file_access, storage_dir, obey_robots, allow_private_network, v8_flags not wired yet.
410+
// ponytail: allow_file_access, storage_dir, obey_robots, v8_flags not wired yet.
411+
let _ = allow_private_network; // CDP server uses Page which has SSRF via HttpClient
403412

404413
let html = "<html><body></body></html>";
405414

bin/hpx-cli/src/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ pub(crate) struct Cli {
357357
#[arg(long, global = true, env = "HPX_OBEY_ROBOTS")]
358358
pub obey_robots: bool,
359359

360-
/// Allow fetching from private/local network IPs.
360+
/// Allow requests to private/internal IP addresses (disabled by default for security).
361361
#[arg(long, global = true, env = "HPX_ALLOW_PRIVATE_NETWORK")]
362362
pub allow_private_network: bool,
363363

crates/hpx-browser/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ which = { workspace = true, optional = true }
6666
winnow = { workspace = true }
6767

6868
[dev-dependencies]
69+
axum = { workspace = true }
6970
criterion = { workspace = true, features = ["html_reports"] }
71+
hpx = { workspace = true }
7072
proptest = { workspace = true }
7173
sha1 = { workspace = true }
7274

crates/hpx-browser/src/net/mod.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ pub enum NetError {
5959

6060
#[error("hpx client error: {0}")]
6161
Client(#[from] hpx::Error),
62+
63+
#[error("SSRF protection: request to {0} is forbidden (use --allow-private-network to bypass)")]
64+
Ssrf(String),
6265
}
6366

6467
// ---------------------------------------------------------------------------
@@ -145,6 +148,7 @@ pub struct HttpClient {
145148
accept_ch_origins: scc::HashSet<String>,
146149
h1_only_hosts: scc::HashSet<String>,
147150
browser_profile: hpx::BrowserProfile,
151+
allow_private_network: bool,
148152
}
149153

150154
impl HttpClient {
@@ -169,6 +173,7 @@ impl HttpClient {
169173
accept_ch_origins: session.accept_ch.clone(),
170174
h1_only_hosts: session.h1_only_hosts.clone(),
171175
browser_profile,
176+
allow_private_network: false,
172177
})
173178
}
174179

@@ -180,6 +185,12 @@ impl HttpClient {
180185
&self.browser_profile
181186
}
182187

188+
/// Allow requests to private/internal IP addresses (disabled by default).
189+
pub fn allow_private_network(mut self, allow: bool) -> Self {
190+
self.allow_private_network = allow;
191+
self
192+
}
193+
183194
/// Whether `host` has previously sent `Accept-CH`.
184195
pub fn has_accept_ch(&self, host: &str) -> bool {
185196
self.accept_ch_origins.contains_sync(host)
@@ -476,6 +487,17 @@ impl HttpClient {
476487
extra_headers: &[(String, String)],
477488
) -> Result<hpx::Response, NetError> {
478489
let parsed = Url::parse(url)?;
490+
491+
// SSRF check — reject private/special-use IPs unless explicitly allowed.
492+
// Uses DNS resolution to prevent DNS rebinding attacks.
493+
if !self.allow_private_network {
494+
if let Some(host) = parsed.host_str() {
495+
if crate::net::ssrf::is_forbidden_resolved(host).await {
496+
return Err(NetError::Ssrf(host.to_string()));
497+
}
498+
}
499+
}
500+
479501
let builder = match method {
480502
"GET" | "HEAD" => self.inner.get(url),
481503
"POST" => self.inner.post(url),
@@ -823,4 +845,65 @@ mod tests {
823845
let q = p;
824846
assert_eq!(p, q); // Copy semantics — both still usable
825847
}
848+
849+
// --- SSRF integration tests ---
850+
851+
#[tokio::test]
852+
async fn ssrf_blocks_loopback_by_default() {
853+
let client = HttpClient::new(hpx::BrowserProfile::Chrome).unwrap();
854+
let result = client
855+
.execute_single_request("GET", "http://127.0.0.1", None, &[])
856+
.await;
857+
assert!(result.is_err());
858+
let err = result.unwrap_err().to_string();
859+
assert!(
860+
err.contains("127.0.0.1"),
861+
"error should mention the blocked host, got: {err}"
862+
);
863+
assert!(
864+
err.contains("--allow-private-network"),
865+
"error should mention --allow-private-network bypass, got: {err}"
866+
);
867+
}
868+
869+
#[tokio::test]
870+
async fn ssrf_allows_private_network_when_enabled() {
871+
let client = HttpClient::new(hpx::BrowserProfile::Chrome)
872+
.unwrap()
873+
.allow_private_network(true);
874+
// This will fail with a connection error (no server listening), NOT an SSRF error
875+
let result = client
876+
.execute_single_request("GET", "http://127.0.0.1", None, &[])
877+
.await;
878+
match result {
879+
Ok(_) => {}
880+
Err(e) => {
881+
let msg = e.to_string();
882+
assert!(
883+
!msg.contains("SSRF"),
884+
"should not be SSRF rejection, got: {msg}"
885+
);
886+
}
887+
}
888+
}
889+
890+
#[tokio::test]
891+
async fn ssrf_allows_public_ip() {
892+
let client = HttpClient::new(hpx::BrowserProfile::Chrome).unwrap();
893+
// 93.184.216.34 is example.com — real public IP; may fail with
894+
// connection error in CI but must NOT be SSRF rejection.
895+
let result = client
896+
.execute_single_request("GET", "http://93.184.216.34", None, &[])
897+
.await;
898+
match result {
899+
Ok(_) => {}
900+
Err(e) => {
901+
let msg = e.to_string();
902+
assert!(
903+
!msg.contains("SSRF"),
904+
"public IP should not trigger SSRF, got: {msg}"
905+
);
906+
}
907+
}
908+
}
826909
}

0 commit comments

Comments
 (0)