diff --git a/Cargo.lock b/Cargo.lock index fc3c366..85f8d82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -697,6 +697,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" @@ -1608,6 +1614,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 +1776,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", @@ -1850,6 +1864,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 +1985,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 +2177,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" @@ -2275,6 +2318,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", @@ -2737,6 +2781,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 +3745,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/crates/obscura-net/Cargo.toml b/crates/obscura-net/Cargo.toml index 5246806..308129b 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 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 +# 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..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); @@ -764,10 +818,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 +908,109 @@ 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 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 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(); + 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 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() { + // 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"); + } }