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}