Skip to main content

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}