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, but only as a fallback when the OS store yields
14//!    nothing usable;
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};
23
24use anyhow::Context;
25use reqwest::{Certificate, ClientBuilder};
26use serde::Deserialize;
27
28/// How an HTTP client should establish TLS trust.
29///
30/// The OS trust store and `SSL_CERT_FILE` are always honoured; the fields here
31/// carry the explicit, opt-in CLI overrides.
32#[derive(Debug, Clone, Default, Deserialize)]
33pub struct TlsConfig {
34    /// Extra PEM trust anchors supplied via `--ca-cert` (each file may hold one
35    /// or more certificates).
36    #[serde(default)]
37    pub ca_certs: Vec<PathBuf>,
38    /// Disable certificate verification entirely (`--insecure`). Last resort.
39    #[serde(default)]
40    pub insecure: bool,
41}
42
43impl TlsConfig {
44    /// Apply the trust configuration to a `reqwest` client builder.
45    ///
46    /// When `insecure` is set, verification is turned off and trust-anchor
47    /// assembly is skipped. Otherwise the OS store is loaded (falling back to
48    /// webpki only when it is empty), then `SSL_CERT_FILE` and `--ca-cert`
49    /// certificates are layered on as additional roots.
50    pub fn apply(&self, builder: ClientBuilder) -> anyhow::Result<ClientBuilder> {
51        if self.insecure {
52            warn_insecure_once();
53            // Nothing is verified, so assembling trust anchors is pointless.
54            return Ok(builder.danger_accept_invalid_certs(true));
55        }
56
57        let mut builder = builder;
58
59        // 1. OS / system trust store. This is what lets an org root CA — or one
60        //    injected by a TLS-intercepting proxy — be trusted.
61        let native = rustls_native_certs::load_native_certs();
62        for err in &native.errors {
63            eprintln!("webtools: warning: reading a system certificate failed: {err}");
64        }
65        let mut native_roots = 0usize;
66        for cert in native.certs {
67            if let Ok(c) = Certificate::from_der(&cert) {
68                builder = builder.add_root_certificate(c);
69                native_roots += 1;
70            }
71        }
72
73        // 2. Keep the bundled webpki roots only as a fallback when the OS store
74        //    yielded nothing usable; otherwise prefer the system store.
75        builder = builder.tls_built_in_root_certs(native_roots == 0);
76
77        // 3. SSL_CERT_FILE — a common override in corp/proxy environments.
78        //    `load_native_certs` already consults it, but we read it explicitly
79        //    too so the certs are guaranteed to load as roots and an unreadable
80        //    value surfaces a clear, dedicated warning rather than failing
81        //    silently. (Per OpenSSL conventions, setting it points the default
82        //    file at this bundle, so prefer --ca-cert to layer onto the OS store.)
83        if let Some(path) = std::env::var_os("SSL_CERT_FILE") {
84            let path = PathBuf::from(path);
85            match std::fs::read(&path) {
86                Ok(pem) => builder = add_pem_bundle(builder, &pem, &path)?,
87                Err(e) => eprintln!(
88                    "webtools: warning: SSL_CERT_FILE ({}) is set but unreadable: {e}",
89                    path.display()
90                ),
91            }
92        }
93
94        // 4. Explicit --ca-cert PEM bundles (extra roots).
95        for path in &self.ca_certs {
96            let pem = std::fs::read(path)
97                .with_context(|| format!("reading --ca-cert {}", path.display()))?;
98            builder = add_pem_bundle(builder, &pem, path)?;
99        }
100
101        Ok(builder)
102    }
103}
104
105/// Parse every certificate in a PEM bundle and add each as a trust anchor.
106fn add_pem_bundle(
107    mut builder: ClientBuilder,
108    pem: &[u8],
109    path: &Path,
110) -> anyhow::Result<ClientBuilder> {
111    let certs = Certificate::from_pem_bundle(pem)
112        .with_context(|| format!("parsing PEM certificates from {}", path.display()))?;
113    if certs.is_empty() {
114        eprintln!(
115            "webtools: warning: no certificates found in {}",
116            path.display()
117        );
118    }
119    for cert in certs {
120        builder = builder.add_root_certificate(cert);
121    }
122    Ok(builder)
123}
124
125/// Print the `--insecure` warning at most once per process.
126fn warn_insecure_once() {
127    static WARNED: AtomicBool = AtomicBool::new(false);
128    if !WARNED.swap(true, Ordering::Relaxed) {
129        eprintln!(
130            "webtools: WARNING: --insecure disables TLS certificate verification; \
131             the connection can be intercepted. Use only as a last resort — \
132             prefer the OS trust store, SSL_CERT_FILE, or --ca-cert."
133        );
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn default_is_secure_with_no_extra_roots() {
143        let cfg = TlsConfig::default();
144        assert!(!cfg.insecure);
145        assert!(cfg.ca_certs.is_empty());
146        // Applying the default config must succeed (loads the OS store).
147        assert!(cfg.apply(reqwest::Client::builder()).is_ok());
148    }
149
150    #[test]
151    fn insecure_config_applies() {
152        let cfg = TlsConfig {
153            insecure: true,
154            ..Default::default()
155        };
156        assert!(cfg.apply(reqwest::Client::builder()).is_ok());
157    }
158
159    #[test]
160    fn missing_ca_cert_is_an_error() {
161        let cfg = TlsConfig {
162            ca_certs: vec![PathBuf::from("/no/such/ca-cert.pem")],
163            ..Default::default()
164        };
165        assert!(cfg.apply(reqwest::Client::builder()).is_err());
166    }
167}