Merge pull request #526 from marcoripa96/native-ca-roots

feat(net): trust platform CA roots alongside bundled webpki roots
This commit is contained in:
SGavrl authored and GitHub committed 2026-07-31 17:44:01 +00:00
commit d66e5ec110
3 files changed
+244 -2

No files matched your search

+9
View File
@@ -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_*`
+163 -2
View File
@@ -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<std::path::PathBuf> {
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<Vec<reqwest::Certificate>> = 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");
}
}