webfetch_core/tls.rs
1//! Shared TLS trust configuration for the HTTP clients.
2//!
3//! By default `reqwest`'s rustls backend trusts only the bundled webpki root
4//! set, which ignores any CA the host operating system trusts. Behind a
5//! TLS-intercepting proxy (common in corporate networks) the proxy presents a
6//! certificate signed by an org root CA that lives in the OS trust store but
7//! not in webpki, so the handshake fails with `UnknownIssuer`.
8//!
9//! [`TlsConfig::apply`] fixes this by assembling the trust anchors explicitly:
10//!
11//! 1. the OS / system trust store (via `rustls-native-certs`), so org root CAs
12//! — including proxy-injected ones — are trusted;
13//! 2. the bundled webpki roots, always, so a thin or partial system store
14//! cannot leave the client unable to verify ordinary public sites;
15//! 3. any certs in `SSL_CERT_FILE`, if it is set and readable;
16//! 4. any explicit `--ca-cert` PEM bundles.
17//!
18//! `--insecure` (`danger_accept_invalid_certs`) is a strictly opt-in last
19//! resort: it disables verification entirely and prints a loud warning.
20
21use std::path::{Path, PathBuf};
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::OnceLock;
24
25use anyhow::Context;
26use reqwest::{Certificate, ClientBuilder};
27use serde::Deserialize;
28
29/// The system trust anchors, in DER, loaded at most once per process.
30///
31/// [`TlsConfig::apply`] runs once per client, and the fetch path builds a fresh
32/// client for every redirect hop (to keep per-URL IP pinning), so without this
33/// cache a single redirected fetch would read and PEM-parse the whole OS
34/// certificate bundle several times over.
35fn native_root_ders() -> &'static [Vec<u8>] {
36 static ROOTS: OnceLock<Vec<Vec<u8>>> = OnceLock::new();
37 ROOTS.get_or_init(|| {
38 let native = rustls_native_certs::load_native_certs();
39 for err in &native.errors {
40 eprintln!("webtools: warning: reading a system certificate failed: {err}");
41 }
42 native.certs.into_iter().map(|c| c.to_vec()).collect()
43 })
44}
45
46/// How an HTTP client should establish TLS trust.
47///
48/// The OS trust store and `SSL_CERT_FILE` are always honoured; the fields here
49/// carry the explicit, opt-in CLI overrides.
50#[derive(Debug, Clone, Default, Deserialize)]
51pub struct TlsConfig {
52 /// Extra PEM trust anchors supplied via `--ca-cert` (each file may hold one
53 /// or more certificates).
54 #[serde(default)]
55 pub ca_certs: Vec<PathBuf>,
56 /// Disable certificate verification entirely (`--insecure`). Last resort.
57 #[serde(default)]
58 pub insecure: bool,
59}
60
61impl TlsConfig {
62 /// Apply the trust configuration to a `reqwest` client builder.
63 ///
64 /// When `insecure` is set, verification is turned off and trust-anchor
65 /// assembly is skipped. Otherwise the OS store, the bundled webpki roots,
66 /// `SSL_CERT_FILE` and `--ca-cert` are all layered together as roots.
67 pub fn apply(&self, builder: ClientBuilder) -> anyhow::Result<ClientBuilder> {
68 if self.insecure {
69 warn_insecure_once();
70 // Nothing is verified, so assembling trust anchors is pointless.
71 return Ok(builder.danger_accept_invalid_certs(true));
72 }
73
74 let mut builder = builder;
75
76 // 1. OS / system trust store. This is what lets an org root CA — or one
77 // injected by a TLS-intercepting proxy — be trusted.
78 for cert in native_root_ders() {
79 if let Ok(c) = Certificate::from_der(cert) {
80 builder = builder.add_root_certificate(c);
81 }
82 }
83
84 // 2. The bundled webpki roots, always — not only when the OS store came
85 // back empty.
86 //
87 // Treating them as a fallback meant a *partial* OS store silently
88 // replaced a complete root set with an incomplete one: a container
89 // with three certificates in /etc/ssl would lose webpki entirely and
90 // fail to verify most of the web, with no signal beyond a handshake
91 // error. Only a fully empty store triggered the fallback, which is
92 // the one case that failure mode does not cover.
93 //
94 // The cost is that removing a CA from the OS store does not distrust
95 // it here, since webpki may still carry it. That is a real trade, but
96 // a tool that stops reaching the web on a thin base image is the
97 // worse outcome — and it is the behaviour the changelog described.
98 builder = builder.tls_built_in_root_certs(true);
99
100 // 3. SSL_CERT_FILE — a common override in corp/proxy environments.
101 // `load_native_certs` already consults it, but we read it explicitly
102 // too so the certs are guaranteed to load as roots and an unreadable
103 // value surfaces a clear, dedicated warning rather than failing
104 // silently. (Per OpenSSL conventions, setting it points the default
105 // file at this bundle, so prefer --ca-cert to layer onto the OS store.)
106 if let Some(path) = std::env::var_os("SSL_CERT_FILE") {
107 let path = PathBuf::from(path);
108 match std::fs::read(&path) {
109 Ok(pem) => builder = add_pem_bundle(builder, &pem, &path)?,
110 Err(e) => eprintln!(
111 "webtools: warning: SSL_CERT_FILE ({}) is set but unreadable: {e}",
112 path.display()
113 ),
114 }
115 }
116
117 // 4. Explicit --ca-cert PEM bundles (extra roots).
118 for path in &self.ca_certs {
119 let pem = std::fs::read(path)
120 .with_context(|| format!("reading --ca-cert {}", path.display()))?;
121 builder = add_pem_bundle(builder, &pem, path)?;
122 }
123
124 Ok(builder)
125 }
126}
127
128/// Parse every certificate in a PEM bundle and add each as a trust anchor.
129fn add_pem_bundle(
130 mut builder: ClientBuilder,
131 pem: &[u8],
132 path: &Path,
133) -> anyhow::Result<ClientBuilder> {
134 let certs = Certificate::from_pem_bundle(pem)
135 .with_context(|| format!("parsing PEM certificates from {}", path.display()))?;
136 if certs.is_empty() {
137 eprintln!(
138 "webtools: warning: no certificates found in {}",
139 path.display()
140 );
141 }
142 for cert in certs {
143 builder = builder.add_root_certificate(cert);
144 }
145 Ok(builder)
146}
147
148/// Print the `--insecure` warning at most once per process.
149fn warn_insecure_once() {
150 static WARNED: AtomicBool = AtomicBool::new(false);
151 if !WARNED.swap(true, Ordering::Relaxed) {
152 eprintln!(
153 "webtools: WARNING: --insecure disables TLS certificate verification; \
154 the connection can be intercepted. Use only as a last resort — \
155 prefer the OS trust store, SSL_CERT_FILE, or --ca-cert."
156 );
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn default_is_secure_with_no_extra_roots() {
166 let cfg = TlsConfig::default();
167 assert!(!cfg.insecure);
168 assert!(cfg.ca_certs.is_empty());
169 // Applying the default config must succeed (loads the OS store).
170 assert!(cfg.apply(reqwest::Client::builder()).is_ok());
171 }
172
173 #[test]
174 fn insecure_config_applies() {
175 let cfg = TlsConfig {
176 insecure: true,
177 ..Default::default()
178 };
179 assert!(cfg.apply(reqwest::Client::builder()).is_ok());
180 }
181
182 #[test]
183 fn missing_ca_cert_is_an_error() {
184 let cfg = TlsConfig {
185 ca_certs: vec![PathBuf::from("/no/such/ca-cert.pem")],
186 ..Default::default()
187 };
188 assert!(cfg.apply(reqwest::Client::builder()).is_err());
189 }
190}