Skip to content

Commit 0d70f4f

Browse files
authored
Trust the OS certificate store on ureq (native-certs), release 1.2.0 (#209)
* feat: add `native-certs` feature for the OS trust store on ureq The crate-built ureq client verifies against `RootCerts::WebPki` (Mozilla's bundled roots), which ignores the machine's trust store. Behind a TLS-intercepting corporate proxy the company CA is installed on the machine and absent from the bundled set, so every request fails to verify while `curl` and the browsers succeed. `native-certs` moves the per-call agent to `RootCerts::PlatformVerifier` and turns on `ureq/platform-verifier`, which the rustls lane panics without. Off by default: widening a self-updater's trust store is opt-in. No effect on reqwest, whose rustls setup already uses `rustls-platform-verifier`, or on an injected `ureq::Agent`, which owns its own TLS config. * docs: note that `native-certs` is a no-op without the ureq client * release 1.2.0
1 parent 1775cc2 commit 0d70f4f

8 files changed

Lines changed: 176 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@
88

99
### Removed
1010

11+
## [1.2.0]
12+
Additive over `1.1.0`: one opt-in feature for the ureq client's trust store. No API breaks, no
13+
migration needed.
14+
15+
### Added
16+
- `native-certs` feature: the crate-built ureq client verifies against the OS trust store
17+
(`RootCerts::PlatformVerifier`) instead of Mozilla's bundled roots (`RootCerts::WebPki`). Needed
18+
behind a TLS-intercepting corporate proxy, whose CA is installed on the machine and is absent from
19+
the bundled set, so every request otherwise fails to verify. Off by default, so the ureq client's
20+
trust store is unchanged unless asked for. No effect on reqwest (its rustls setup already uses
21+
`rustls-platform-verifier`) or on an injected `ureq::Agent`, which owns its own TLS config.
22+
23+
### Changed
24+
25+
### Removed
26+
1127
## [1.1.0]
1228
Additive over `1.0.0`: two verification entry points for releases the built-in gates do not cover.
1329
No API breaks, no migration needed.

Cargo.toml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "self_update"
3-
version = "1.1.0"
3+
version = "1.2.0"
44
description = "Self updates for standalone executables"
55
repository = "https://github.com/jaemk/self_update"
66
keywords = ["update", "upgrade", "download", "release"]
@@ -110,6 +110,17 @@ native-tls = ["reqwest?/native-tls", "ureq?/native-tls"]
110110
# the reqwest client; a ureq-only build gets `native-tls` (system OpenSSL) without vendoring.
111111
native-tls-vendored = ["native-tls", "reqwest?/native-tls-vendored"]
112112
rustls = ["reqwest?/rustls", "ureq?/rustls"]
113+
# Trust the OS certificate store instead of Mozilla's bundled roots, for the ureq client.
114+
# Turn it on when your users sit behind a TLS-intercepting corporate proxy whose CA is installed
115+
# in the machine's trust store: without it, every request fails to verify. It moves the ureq
116+
# per-call agent from `RootCerts::WebPki` to `RootCerts::PlatformVerifier`; no effect on an
117+
# injected `ureq::Agent`, which owns its own TLS config.
118+
#
119+
# A reqwest build needs nothing and is unaffected: reqwest 0.13's rustls setup already verifies
120+
# through `rustls-platform-verifier`, and its native-tls setup uses the system store by
121+
# definition. The `ureq?/` prefix means this feature is a no-op there, pulling in nothing.
122+
# See CORP-2 in specs/corporate-network-config.md.
123+
native-certs = ["ureq?/platform-verifier"]
113124

114125
reqwest = ["dep:reqwest"]
115126
ureq = ["dep:ureq"]

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,5 +855,27 @@ export SSL_CERT_DIR=/etc/ssl/certs
855855
Alternatively build with the `rustls` feature, which uses a bundled root store and does not depend
856856
on the system OpenSSL cert layout.
857857

858+
**TLS certificate errors behind a corporate proxy (`ureq` + `rustls`).** Many company networks
859+
terminate outbound HTTPS at an intercepting proxy that re-signs traffic with an internal CA. That CA
860+
is installed in the machine's trust store, so `curl` and the system browsers accept it, but the
861+
ureq client's default root store is `RootCerts::WebPki` (Mozilla's bundled roots), which ignores the
862+
machine entirely, so every request fails to verify. Enable the `native-certs` feature to move the
863+
ureq client onto the OS trust store instead:
864+
865+
```toml
866+
self_update = { version = "1.2", features = ["ureq", "rustls", "native-certs"] }
867+
```
868+
869+
The reqwest client needs nothing and is not affected by the feature: its rustls setup already
870+
verifies through `rustls-platform-verifier`, and its native-tls setup uses the system store by
871+
definition. On a reqwest-only build `native-certs` is a no-op that pulls in no extra dependency, so
872+
it is safe to enable unconditionally in a crate that offers both clients.
873+
`native-certs` has no effect on an injected `ureq::Agent` either, since that agent owns its own TLS
874+
config, so set `RootCerts::PlatformVerifier` on it yourself. On Linux the OS trust store honors
875+
`SSL_CERT_FILE` / `SSL_CERT_DIR`, so those env vars work as an escape hatch once the feature is on.
876+
To trust exactly one internal CA and nothing else, skip the feature and pass the certificate to
877+
[`add_root_certificate`](crate::backends::github::UpdateBuilder::add_root_certificate). Note that on
878+
a ureq build that *replaces* the trust store rather than adding to it.
879+
858880

859881
License: MIT

specs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ design before it can be built). Keep each row's status current with `spec.py set
4848
| Releases Test Constructor | done | [releases-test-constructor.md](releases-test-constructor.md) |
4949
| Choose Latest Release Sort | done | [choose-latest-release-sort.md](choose-latest-release-sort.md) |
5050
| Embedded Key Verification | done | [embedded-key-verification.md](embedded-key-verification.md) |
51-
| Corporate Network Config | pending | [corporate-network-config.md](corporate-network-config.md) |
51+
| Corporate Network Config | partial | [corporate-network-config.md](corporate-network-config.md) |
5252
| Restart After Update | done | [ref-restart.md](ref-restart.md) |
5353
| Update-check Interval Guard | done | [ref-check-interval.md](ref-check-interval.md) |
5454
| Manifest Backend | done | [ref-manifest-backend.md](ref-manifest-backend.md) |

specs/corporate-network-config.md

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Corporate Network Config
22

3-
Status: CORP-1 done; CORP-2 and CORP-3 pending
3+
Status: CORP-1 and CORP-2 done; CORP-3 pending
44

55
## Problem
66

@@ -10,24 +10,25 @@ its own root store and does not read the OS trust store, so any connection throu
1010
proxy fails with a certificate verification error unless the caller injects a
1111
pre-configured `reqwest` client with the custom CA added manually.
1212

13-
CORP-1 has shipped; CORP-2 and CORP-3 remain open:
13+
CORP-1 and CORP-2 have shipped; CORP-3 remains open:
1414

1515
- **Custom root CA (CORP-1)**: shipped. `self_update::Certificate` plus
1616
`add_root_certificate` on every builder and on `Download`; a malformed
1717
certificate surfaces as `Error::InvalidCertificate` from `build()` /
1818
`download_to`. See CORP-1 below and `ref-http-client.md`.
1919

20-
- **OS trust store (CORP-2)**: the default reqwest + rustls setup in 0.13.4 already uses
21-
the platform verifier (OS trust store) via `rustls-platform-verifier`. The gap is the
22-
ureq per-call path, which defaults to Mozilla's bundled roots (`WebPki`), not the OS.
20+
- **OS trust store (CORP-2)**: shipped in 1.2.0 as the opt-in `native-certs` feature. The
21+
default reqwest + rustls setup in 0.13.4 already used the platform verifier (OS trust
22+
store) via `rustls-platform-verifier`; the gap was the ureq per-call path, which defaults
23+
to Mozilla's bundled roots (`WebPki`) and so ignores the machine's trust store entirely.
2324

2425
- **Proxy with auth (CORP-3)**: env-var passthrough (`HTTP_PROXY` / `HTTPS_PROXY`) works
2526
for unauthenticated proxies, but any proxy requiring credentials must be configured on
2627
an injected client. There is no `.proxy(url)` setter on the builders.
2728

28-
The remaining gaps (CORP-2, CORP-3) force corporate users to add `reqwest` or `ureq` as
29-
a direct dependency solely to unlock transport config that belongs on the `self_update`
30-
builder surface.
29+
The remaining gap (CORP-3) forces corporate users to add `reqwest` or `ureq` as a direct
30+
dependency solely to unlock transport config that belongs on the `self_update` builder
31+
surface.
3132

3233
---
3334

@@ -128,7 +129,7 @@ ureq build-time client -- #[cfg(all(feature = "ureq", not(feature = "reqw
128129

129130
---
130131

131-
## CORP-2: OS trust store for ureq (designed)
132+
## CORP-2: OS trust store for ureq (done)
132133

133134
### reqwest: already solved
134135

@@ -178,6 +179,22 @@ CORP-2-7. When both `reqwest` and `ureq` features are on, the ureq per-call path
178179
reached; `native-certs` is effectively a no-op (no overhead). This is acceptable since
179180
the common default-feature path uses reqwest with the platform verifier already.
180181

182+
CORP-2-8. `native-certs` is a no-op on a build with no `ureq` feature, and pulls in no
183+
dependency there: the mapping is `ureq?/platform-verifier`, and the `?` prefix activates
184+
`platform-verifier` only when `ureq` is itself enabled. Enabling `native-certs`
185+
unconditionally is therefore safe for a downstream crate that offers both clients. Nothing
186+
is needed on the reqwest side for either TLS backend: rustls verifies through
187+
`rustls-platform-verifier` (CORP-2-1), and native-tls uses the system store by definition.
188+
189+
CORP-2-9. The reqwest rustls lane reaches the platform verifier only while `root_certs` is
190+
empty and `tls_certs_only` was never set (reqwest 0.13.4 `async_impl/client.rs`; the
191+
blocking client wraps the same builder, so both APIs behave alike). Neither condition is
192+
reachable by accident from this crate: `tls_certs_only` is never called, and
193+
`tls_certs_merge` is invoked with the collected `add_root_certificate` certs, which extends
194+
an empty vec with nothing when the caller supplied none. A caller who DOES supply
195+
certificates moves to `Verifier::new_with_extra_roots`, which keeps the OS trust store and
196+
adds theirs (CORP-1-12), rather than the ureq lane's replace semantics (CORP-1-15).
197+
181198
---
182199

183200
## CORP-3: proxy with auth (designed)

src/http_client/ureq.rs

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,34 @@ impl From<Agent> for UreqClient {
4242
}
4343
}
4444

45+
/// The trust store a crate-built ureq agent starts from, before any caller-supplied certificates
46+
/// replace it (CORP-2-4).
47+
///
48+
/// ureq's own default is [`RootCerts::WebPki`](ureq::tls::RootCerts::WebPki) -- Mozilla's bundled
49+
/// roots, which ignore the machine's trust store entirely. That is the safer default for a
50+
/// self-updater (it cannot be widened by anything installed on the host), so it stays the default
51+
/// here. The `native-certs` feature opts into
52+
/// [`PlatformVerifier`](ureq::tls::RootCerts::PlatformVerifier) instead, which is what a caller
53+
/// behind a TLS-intercepting corporate proxy needs: the company CA lives in the OS store, not in
54+
/// Mozilla's list.
55+
///
56+
/// Note the rustls lane *panics* on `PlatformVerifier` without ureq's `platform-verifier` feature
57+
/// (`ureq::tls::rustls`), which is exactly what `native-certs = ["ureq?/platform-verifier"]`
58+
/// guarantees -- so the two must stay wired together.
59+
fn default_root_certs() -> ureq::tls::RootCerts {
60+
#[cfg(feature = "native-certs")]
61+
{
62+
ureq::tls::RootCerts::PlatformVerifier
63+
}
64+
#[cfg(not(feature = "native-certs"))]
65+
{
66+
ureq::tls::RootCerts::WebPki
67+
}
68+
}
69+
4570
/// Build a per-call ureq agent honoring the per-request `timeout`, the TLS feature, and proxy-env.
46-
/// `root_certs`, when `Some`, replaces the default trust store with the supplied certificates.
71+
/// `root_certs`, when `Some`, replaces the default trust store (see [`default_root_certs`]) with
72+
/// the supplied certificates.
4773
fn build_call_agent(
4874
timeout: Option<Duration>,
4975
#[cfg(any(not(feature = "reqwest"), test))] root_certs: Option<UreqRootCerts>,
@@ -57,9 +83,13 @@ fn build_call_agent(
5783
let provider = TlsProvider::NativeTls;
5884

5985
#[cfg(any(not(feature = "reqwest"), test))]
60-
let mut tls = TlsConfig::builder().provider(provider);
86+
let mut tls = TlsConfig::builder()
87+
.provider(provider)
88+
.root_certs(default_root_certs());
6189
#[cfg(all(feature = "reqwest", not(test)))]
62-
let tls = TlsConfig::builder().provider(provider);
90+
let tls = TlsConfig::builder()
91+
.provider(provider)
92+
.root_certs(default_root_certs());
6393
#[cfg(any(not(feature = "reqwest"), test))]
6494
if let Some(certs) = root_certs {
6595
tls = tls.root_certs(ureq::tls::RootCerts::Specific(certs));
@@ -560,6 +590,44 @@ mod tests {
560590
);
561591
}
562592

593+
// spec: CORP-2-4
594+
#[test]
595+
fn default_trust_store_follows_the_native_certs_feature() {
596+
// The whole point of `native-certs`: a crate-built agent must consult the OS trust store
597+
// rather than Mozilla's bundled roots, because a corporate MITM proxy's CA is only ever in
598+
// the former. Without the feature the WebPki default must be preserved exactly -- widening
599+
// the trust store of a self-updater is not something a minor release gets to do silently.
600+
let agent = build_call_agent(None, None);
601+
let roots = agent.config().tls_config().root_certs();
602+
#[cfg(feature = "native-certs")]
603+
assert!(
604+
matches!(roots, ureq::tls::RootCerts::PlatformVerifier),
605+
"with `native-certs` the per-call agent must use the OS trust store, got {roots:?}"
606+
);
607+
#[cfg(not(feature = "native-certs"))]
608+
assert!(
609+
matches!(roots, ureq::tls::RootCerts::WebPki),
610+
"without `native-certs` the per-call agent must keep ureq's WebPki roots, got {roots:?}"
611+
);
612+
}
613+
614+
// spec: CORP-1-14, CORP-1-15, CORP-2-4
615+
#[test]
616+
fn caller_supplied_certs_replace_the_default_trust_store() {
617+
// CORP-1-15's documented limitation, pinned against the CORP-2 change: adding the default
618+
// `root_certs` call must not turn `Specific` into a merge, and must not let the default
619+
// win over an explicit cert. `Specific` still fully replaces whatever the default was.
620+
let certs = std::sync::Arc::new(vec![
621+
ureq::tls::Certificate::from_der(b"der-bytes").to_owned(),
622+
]);
623+
let agent = build_call_agent(None, Some(certs));
624+
let roots = agent.config().tls_config().root_certs();
625+
assert!(
626+
matches!(roots, ureq::tls::RootCerts::Specific(_)),
627+
"caller-supplied roots must replace the default trust store, got {roots:?}"
628+
);
629+
}
630+
563631
#[test]
564632
fn injected_agent_no_status_error_falls_through_to_is_success_check() {
565633
// 404 must still map to NotFound via the bottom-of-`get` is_success() path (the

src/lib.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,28 @@ export SSL_CERT_DIR=/etc/ssl/certs
873873
Alternatively build with the `rustls` feature, which uses a bundled root store and does not depend
874874
on the system OpenSSL cert layout.
875875
876+
**TLS certificate errors behind a corporate proxy (`ureq` + `rustls`).** Many company networks
877+
terminate outbound HTTPS at an intercepting proxy that re-signs traffic with an internal CA. That CA
878+
is installed in the machine's trust store, so `curl` and the system browsers accept it, but the
879+
ureq client's default root store is `RootCerts::WebPki` (Mozilla's bundled roots), which ignores the
880+
machine entirely, so every request fails to verify. Enable the `native-certs` feature to move the
881+
ureq client onto the OS trust store instead:
882+
883+
```toml
884+
self_update = { version = "1.2", features = ["ureq", "rustls", "native-certs"] }
885+
```
886+
887+
The reqwest client needs nothing and is not affected by the feature: its rustls setup already
888+
verifies through `rustls-platform-verifier`, and its native-tls setup uses the system store by
889+
definition. On a reqwest-only build `native-certs` is a no-op that pulls in no extra dependency, so
890+
it is safe to enable unconditionally in a crate that offers both clients.
891+
`native-certs` has no effect on an injected `ureq::Agent` either, since that agent owns its own TLS
892+
config, so set `RootCerts::PlatformVerifier` on it yourself. On Linux the OS trust store honors
893+
`SSL_CERT_FILE` / `SSL_CERT_DIR`, so those env vars work as an escape hatch once the feature is on.
894+
To trust exactly one internal CA and nothing else, skip the feature and pass the certificate to
895+
[`add_root_certificate`](crate::backends::github::UpdateBuilder::add_root_certificate). Note that on
896+
a ureq build that *replaces* the trust store rather than adding to it.
897+
876898
*/
877899

878900
// Enable the `doc_cfg` feature on docs.rs (nightly-only, guarded by the `docsrs` cfg set via

src/macros.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,12 @@ macro_rules! request_config_setters {
158158
/// certificate is surfaced at connection time instead.
159159
///
160160
/// **ureq-only builds**: when the `reqwest` feature is disabled, the crate-built ureq client
161-
/// trusts *only* the supplied certificates (replacing the default Mozilla root set). Supply
162-
/// all CA certificates you need, including any public roots. If you need the Mozilla set plus
163-
/// a custom CA, inject a pre-built `ureq::Agent` via [`ureq_agent`](Self::ureq_agent)
164-
/// configured with `RootCerts::PlatformVerifier` or a merged root set instead.
161+
/// trusts *only* the supplied certificates (replacing whatever the default root set was).
162+
/// Supply all CA certificates you need, including any public roots. If what you actually
163+
/// want is the machine's own trust store -- the usual case behind an intercepting corporate
164+
/// proxy, whose CA is already installed there -- enable the `native-certs` feature and do
165+
/// not call this setter at all. For anything finer, inject a pre-built `ureq::Agent` via
166+
/// [`ureq_agent`](Self::ureq_agent) carrying its own merged root set.
165167
pub fn add_root_certificate(&mut self, cert: crate::Certificate) -> &mut Self {
166168
self.$($path).+.root_certificates.push(cert);
167169
self

0 commit comments

Comments
 (0)