From 0522831dcc60faac8b044eedbab0b771c7cbbd45 Mon Sep 17 00:00:00 2001 From: Marco Ripamonti Date: Tue, 28 Jul 2026 15:43:12 +0200 Subject: [PATCH 1/2] feat(net): trust platform CA roots alongside bundled webpki roots reqwest was pinned to rustls-tls, so HTTPS only trusted the bundled webpki roots: SSL_CERT_FILE, SSL_CERT_DIR, and the system CA store were ignored, and obscura-net exposes no add-CA hook. Deployments behind a TLS-inspecting egress proxy or an internal CA cannot fetch at all, and a caching proxy in front of --proxy cannot terminate TLS. Enable rustls-tls-native-roots alongside rustls-tls. reqwest merges the two root stores into one, so the default trust surface only grows and certificate verification is otherwise unchanged: unknown CAs are still rejected. The stealth (wreq) client has its own TLS stack and is out of scope here. Tests: an in-process HTTPS fixture with an rcgen-minted private CA, served via tokio-rustls (pinned to the ring provider so the test binary has a single rustls CryptoProvider). With SSL_CERT_FILE pointing at the CA the fetch succeeds; without it the same fixture is rejected. Fixes #525. --- Cargo.lock | 140 +++++++++++++++++++++++++++++++ Cargo.toml | 6 +- crates/obscura-net/Cargo.toml | 9 ++ crates/obscura-net/src/client.rs | 92 +++++++++++++++++++- 4 files changed, 245 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc3c366..1af53b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -510,6 +510,22 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -697,6 +713,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_more" version = "0.99.20" @@ -1209,6 +1231,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1608,6 +1631,12 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1764,12 +1793,14 @@ version = "0.1.0" dependencies = [ "async-trait", "encoding_rs", + "rcgen", "reqwest", "serde", "serde_json", "tempfile", "thiserror 2.0.18", "tokio", + "tokio-rustls", "tracing", "url", "wreq", @@ -1805,6 +1836,12 @@ dependencies = [ "syn", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "outref" version = "0.5.2" @@ -1850,6 +1887,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1961,6 +2008,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2147,6 +2200,19 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2206,6 +2272,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -2275,6 +2342,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -2283,6 +2351,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -2316,12 +2396,44 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.26.0" @@ -2737,6 +2849,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.3" @@ -3682,6 +3813,15 @@ dependencies = [ "tap", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 201344d..68696da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,11 @@ tokio-tungstenite = "0.26" # (obscura-net/src/cookies.rs) and never uses reqwest's cookie store. Pulling # it in only added the cookie/cookie_store crates, and cookie 0.18.1's blanket # From impl fails to compile under coherence on some toolchains (issue #295). -reqwest = { version = "0.12", features = ["gzip", "brotli", "deflate", "rustls-tls", "socks"], default-features = false } +# "rustls-tls-native-roots" adds the platform trust store (and SSL_CERT_FILE / +# SSL_CERT_DIR) on top of the bundled webpki roots, so deployments behind a +# corporate CA or an inspecting egress proxy can trust their own roots. The +# two root sets merge, so the default trust surface only grows. +reqwest = { version = "0.12", features = ["gzip", "brotli", "deflate", "rustls-tls", "rustls-tls-native-roots", "socks"], default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" url = "2" diff --git a/crates/obscura-net/Cargo.toml b/crates/obscura-net/Cargo.toml index 5246806..be0598a 100644 --- a/crates/obscura-net/Cargo.toml +++ b/crates/obscura-net/Cargo.toml @@ -25,6 +25,15 @@ tempfile = "3" # Pin exact versions so the build always resolves to the API wreq_client.rs targets. wreq-util = { version = "=3.0.0-rc.12", optional = true } +[dev-dependencies] +# TLS fixtures for the native-roots tests: rcgen mints a throwaway CA + leaf, +# tokio-rustls serves one HTTPS response with them. Pinned to the ring +# provider so the test binary has a single rustls CryptoProvider (reqwest's +# rustls also uses ring; adding aws-lc-rs would make provider auto-selection +# panic). +rcgen = "0.13" +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } + # `prefix-symbols` renames BoringSSL exports to avoid clashes with system OpenSSL, # but the renaming is only correct on Linux and Android (per wreq's README). # Enabling it on other platforms produces unresolved `build_script_main_*` diff --git a/crates/obscura-net/src/client.rs b/crates/obscura-net/src/client.rs index 5aa450b..02d5eba 100644 --- a/crates/obscura-net/src/client.rs +++ b/crates/obscura-net/src/client.rs @@ -764,10 +764,12 @@ pub enum ObscuraNetError { #[cfg(test)] mod ssrf_tests { - use super::{is_forbidden_ip, validate_url, SsrfGuardResolver}; + use super::{is_forbidden_ip, validate_url, ObscuraHttpClient, SsrfGuardResolver}; + use crate::cookies::CookieJar; use reqwest::dns::{Name, Resolve}; use std::net::IpAddr; use std::str::FromStr; + use std::sync::Arc; use url::Url; fn ip(s: &str) -> IpAddr { @@ -852,4 +854,92 @@ mod ssrf_tests { ), } } + + /// Mint a throwaway CA plus a 127.0.0.1 leaf it signed, and serve one + /// canned HTTPS response with the leaf on an ephemeral port. Returns the + /// port and the CA certificate as PEM. + async fn https_fixture_with_private_ca() -> (u16, String) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let ca_key = rcgen::KeyPair::generate().unwrap(); + let mut ca_params = rcgen::CertificateParams::new(Vec::new()).unwrap(); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let ca_cert = ca_params.self_signed(&ca_key).unwrap(); + + let leaf_key = rcgen::KeyPair::generate().unwrap(); + let leaf_params = + rcgen::CertificateParams::new(vec!["127.0.0.1".to_string()]).unwrap(); + let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap(); + + let certs = vec![tokio_rustls::rustls::pki_types::CertificateDer::from( + leaf_cert.der().to_vec(), + )]; + let key = tokio_rustls::rustls::pki_types::PrivateKeyDer::Pkcs8( + tokio_rustls::rustls::pki_types::PrivatePkcs8KeyDer::from( + leaf_key.serialize_der(), + ), + ); + let config = tokio_rustls::rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .unwrap(); + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let Ok(mut tls) = acceptor.accept(stream).await else { + return; // Handshake rejection is the point of one test. + }; + let mut buf = [0u8; 1024]; + let _ = tls.read(&mut buf).await; + let body = "private ca ok"; + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = tls.write_all(resp.as_bytes()).await; + let _ = tls.shutdown().await; + }); + } + }); + + (port, ca_cert.pem()) + } + + // The two native-roots tests set/rely on SSL_CERT_FILE, which + // rustls-native-certs reads once per process at client build. They are + // only correct under `cargo nextest` (one process per test) — the same + // constraint the whole workspace already has. + + #[tokio::test] + async fn native_roots_trusts_a_private_ca_via_ssl_cert_file() { + let (port, ca_pem) = https_fixture_with_private_ca().await; + let ca_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(ca_file.path(), ca_pem).unwrap(); + std::env::set_var("SSL_CERT_FILE", ca_file.path()); + + let client = + ObscuraHttpClient::with_full_options(Arc::new(CookieJar::new()), None, true); + let url = Url::parse(&format!("https://127.0.0.1:{port}/")).unwrap(); + let resp = client.fetch(&url).await.expect("private CA in SSL_CERT_FILE must be trusted"); + assert_eq!(resp.status, 200); + assert_eq!(resp.text(), "private ca ok"); + } + + #[tokio::test] + async fn private_ca_is_still_rejected_without_ssl_cert_file() { + // Guards against the native-roots feature weakening verification: the + // same fixture that the SSL_CERT_FILE test trusts must fail here. The + // listener is reachable (same setup), so an Err can only be TLS. + let (port, _ca_pem) = https_fixture_with_private_ca().await; + let client = + ObscuraHttpClient::with_full_options(Arc::new(CookieJar::new()), None, true); + let url = Url::parse(&format!("https://127.0.0.1:{port}/")).unwrap(); + assert!(client.fetch(&url).await.is_err(), "unknown CA must be rejected"); + } } From f46f741e8c7c458f0153083e852bbf0bc02d2620 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:57:18 +0000 Subject: [PATCH 2/2] fix(net): load configured CA roots without default cost --- Cargo.lock | 68 ------------------------- Cargo.toml | 6 +-- crates/obscura-net/Cargo.toml | 2 +- crates/obscura-net/src/client.rs | 87 +++++++++++++++++++++++++++++--- 4 files changed, 81 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1af53b6..85f8d82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -510,22 +510,6 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f" -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -1231,7 +1215,6 @@ dependencies = [ "hyper", "hyper-util", "rustls", - "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1836,12 +1819,6 @@ dependencies = [ "syn", ] -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "outref" version = "0.5.2" @@ -2272,7 +2249,6 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", - "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -2351,18 +2327,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -2396,44 +2360,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "selectors" version = "0.26.0" diff --git a/Cargo.toml b/Cargo.toml index 68696da..201344d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,11 +36,7 @@ tokio-tungstenite = "0.26" # (obscura-net/src/cookies.rs) and never uses reqwest's cookie store. Pulling # it in only added the cookie/cookie_store crates, and cookie 0.18.1's blanket # From impl fails to compile under coherence on some toolchains (issue #295). -# "rustls-tls-native-roots" adds the platform trust store (and SSL_CERT_FILE / -# SSL_CERT_DIR) on top of the bundled webpki roots, so deployments behind a -# corporate CA or an inspecting egress proxy can trust their own roots. The -# two root sets merge, so the default trust surface only grows. -reqwest = { version = "0.12", features = ["gzip", "brotli", "deflate", "rustls-tls", "rustls-tls-native-roots", "socks"], default-features = false } +reqwest = { version = "0.12", features = ["gzip", "brotli", "deflate", "rustls-tls", "socks"], default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" url = "2" diff --git a/crates/obscura-net/Cargo.toml b/crates/obscura-net/Cargo.toml index be0598a..308129b 100644 --- a/crates/obscura-net/Cargo.toml +++ b/crates/obscura-net/Cargo.toml @@ -26,7 +26,7 @@ tempfile = "3" wreq-util = { version = "=3.0.0-rc.12", optional = true } [dev-dependencies] -# TLS fixtures for the native-roots tests: rcgen mints a throwaway CA + leaf, +# TLS fixtures for the configured-roots tests: rcgen mints a throwaway CA + leaf, # tokio-rustls serves one HTTPS response with them. Pinned to the ring # provider so the test binary has a single rustls CryptoProvider (reqwest's # rustls also uses ring; adding aws-lc-rs would make provider auto-selection diff --git a/crates/obscura-net/src/client.rs b/crates/obscura-net/src/client.rs index 02d5eba..c86c1f2 100644 --- a/crates/obscura-net/src/client.rs +++ b/crates/obscura-net/src/client.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use std::net::{IpAddr, SocketAddr}; @@ -14,6 +14,52 @@ use url::Url; use crate::cookies::CookieJar; use crate::interceptor::{InterceptAction, RequestInterceptor}; +fn configured_root_paths() -> Vec { + let mut paths = Vec::new(); + if let Some(path) = std::env::var_os("SSL_CERT_FILE").filter(|path| !path.is_empty()) { + paths.push(path.into()); + } + if let Some(directory) = std::env::var_os("SSL_CERT_DIR").filter(|path| !path.is_empty()) { + match std::fs::read_dir(directory) { + Ok(entries) => { + paths.extend(entries.filter_map(|entry| entry.ok().map(|entry| entry.path()))); + } + Err(error) => { + tracing::warn!(%error, "failed to read SSL_CERT_DIR"); + } + } + } + paths.sort(); + paths +} + +fn configured_root_certificates() -> &'static [reqwest::Certificate] { + static ROOTS: OnceLock> = OnceLock::new(); + + ROOTS.get_or_init(|| { + let mut certificates = Vec::new(); + for path in configured_root_paths() { + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) => { + tracing::warn!(%error, path = %path.display(), "failed to read CA certificate file"); + continue; + } + }; + match reqwest::Certificate::from_pem_bundle(&bytes) { + Ok(mut bundle) if !bundle.is_empty() => certificates.append(&mut bundle), + _ => match reqwest::Certificate::from_der(&bytes) { + Ok(certificate) => certificates.push(certificate), + Err(error) => { + tracing::warn!(%error, path = %path.display(), "failed to parse CA certificate file"); + } + }, + } + } + certificates + }) +} + #[derive(Debug, Clone)] pub struct Response { pub url: Url, @@ -456,6 +502,14 @@ impl ObscuraHttpClient { .dns_resolver(Arc::new(SsrfGuardResolver::new(self.allow_private_network))) ; + if std::env::var_os("SSL_CERT_FILE").is_some() + || std::env::var_os("SSL_CERT_DIR").is_some() + { + for certificate in configured_root_certificates() { + builder = builder.add_root_certificate(certificate.clone()); + } + } + if let Some(ref proxy) = self.proxy_url { if let Ok(p) = reqwest::Proxy::all(proxy.as_str()) { builder = builder.proxy(p); @@ -911,13 +965,13 @@ mod ssrf_tests { (port, ca_cert.pem()) } - // The two native-roots tests set/rely on SSL_CERT_FILE, which - // rustls-native-certs reads once per process at client build. They are - // only correct under `cargo nextest` (one process per test) — the same - // constraint the whole workspace already has. + // The two configured-roots tests set/rely on SSL_CERT_FILE, which is + // cached once per process at client build. They are only correct under + // `cargo nextest` (one process per test), the same constraint the whole + // workspace already has. #[tokio::test] - async fn native_roots_trusts_a_private_ca_via_ssl_cert_file() { + async fn configured_roots_trust_a_private_ca_via_ssl_cert_file() { let (port, ca_pem) = https_fixture_with_private_ca().await; let ca_file = tempfile::NamedTempFile::new().unwrap(); std::fs::write(ca_file.path(), ca_pem).unwrap(); @@ -931,10 +985,27 @@ mod ssrf_tests { assert_eq!(resp.text(), "private ca ok"); } + #[tokio::test] + async fn configured_roots_trust_a_private_ca_via_ssl_cert_dir() { + let (port, ca_pem) = https_fixture_with_private_ca().await; + let ca_dir = tempfile::tempdir().unwrap(); + std::fs::write(ca_dir.path().join("private-ca.pem"), ca_pem).unwrap(); + std::env::set_var("SSL_CERT_DIR", ca_dir.path()); + + let client = + ObscuraHttpClient::with_full_options(Arc::new(CookieJar::new()), None, true); + let url = Url::parse(&format!("https://127.0.0.1:{port}/")).unwrap(); + let resp = client + .fetch(&url) + .await + .expect("private CA in SSL_CERT_DIR must be trusted"); + assert_eq!(resp.status, 200); + assert_eq!(resp.text(), "private ca ok"); + } + #[tokio::test] async fn private_ca_is_still_rejected_without_ssl_cert_file() { - // Guards against the native-roots feature weakening verification: the - // same fixture that the SSL_CERT_FILE test trusts must fail here. The + // The same fixture that the SSL_CERT_FILE test trusts must fail here. The // listener is reachable (same setup), so an Err can only be TLS. let (port, _ca_pem) = https_fixture_with_private_ca().await; let client =