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 =