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"); + } }