This commit is contained in:
Mateusz Gruszczyński committed 2026-10-03 18:10:20 +02:00
1 parent 1297ea2920
commit dc0fcabf51
49 files changed
+2632 -252

No files matched your search

+149 -28
View File
@@ -17,6 +17,9 @@ use hyper_util::{
use rustls::{ClientConfig, RootCertStore};
use serde::Serialize;
use std::{
env,
io::{BufReader, Cursor},
path::{Path, PathBuf},
sync::{Arc, RwLock},
time::Duration,
};
@@ -26,6 +29,19 @@ use url::Url;
const EMBEDDED_DEFAULTS: &str = include_str!("../UPDATE_CHECK.toml");
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_VERSION_DOCUMENT_BYTES: usize = 256 * 1024;
const MAX_REDIRECTS: usize = 5;
const SYSTEM_CA_BUNDLES: &[&str] = &[
// FreeBSD / OPNsense and common ca_root_nss locations.
"/etc/ssl/cert.pem",
"/usr/local/share/certs/ca-root-nss.crt",
"/usr/local/etc/ssl/cert.pem",
// Debian/Ubuntu, RHEL/Fedora and Alpine families.
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
"/etc/openssl/cert.pem",
"/etc/openssl/certs/ca-certificates.crt",
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Config {
@@ -188,7 +204,11 @@ pub async fn poll_loop(status: Arc<SharedStatus>, config: Config, shutdown: Canc
loop {
match check_once(&config).await {
Ok(version) => status.record_success(version),
Err(error) => tracing::debug!(%error, "update check failed; retrying on the next interval"),
Err(error) => tracing::error!(
error = %format!("{error:#}"),
version_url = %config.version_url,
"update check failed; retrying on the next interval"
),
}
tokio::select! {
_ = shutdown.cancelled() => return,
@@ -205,8 +225,7 @@ async fn check_once(config: &Config) -> Result<String> {
}
async fn fetch(url: &str) -> Result<String> {
let mut roots = RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let roots = load_root_store();
let tls = ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
@@ -223,32 +242,117 @@ async fn fetch(url: &str) -> Result<String> {
.enable_http1()
.wrap_connector(http);
let client: Client<_, Body> = Client::builder(TokioExecutor::new()).build(connector);
let uri: Uri = url.parse().context("invalid update version URI")?;
let request = Request::builder()
.method(http::Method::GET)
.uri(uri)
.header(header::ACCEPT, "text/plain, text/x-toml;q=0.9")
.header(
header::USER_AGENT,
concat!("RustProxy/", env!("CARGO_PKG_VERSION"), " update-check"),
)
.body(Body::empty())?;
let response = client.request(request).await.context("update version request failed")?;
ensure!(
response.status().is_success(),
"update version request returned HTTP {}",
response.status()
);
let body = Limited::new(response.into_body(), MAX_VERSION_DOCUMENT_BYTES)
.collect()
.await
.map_err(|error| {
anyhow::anyhow!(
"update version document is too large or incomplete: {error}"
let mut current = Url::parse(url).context("invalid update version URL")?;
for redirects in 0..=MAX_REDIRECTS {
let uri: Uri = current
.as_str()
.parse()
.context("invalid update version URI")?;
let request = Request::builder()
.method(http::Method::GET)
.uri(uri)
.header(header::ACCEPT, "text/plain, text/x-toml;q=0.9")
.header(
header::USER_AGENT,
concat!("RustProxy/", env!("CARGO_PKG_VERSION"), " update-check"),
)
})?
.to_bytes();
String::from_utf8(body.to_vec()).context("update version document is not UTF-8")
.body(Body::empty())?;
let response = client
.request(request)
.await
.with_context(|| format!("update version request failed for {}", current))?;
if response.status().is_redirection() {
ensure!(
redirects < MAX_REDIRECTS,
"update version request exceeded redirect limit"
);
let location = response
.headers()
.get(header::LOCATION)
.context("update version redirect is missing Location")?
.to_str()
.context("update version redirect Location is not valid UTF-8")?;
current = redirect_target(&current, location)?;
continue;
}
ensure!(
response.status().is_success(),
"update version request returned HTTP {}",
response.status()
);
let body = Limited::new(response.into_body(), MAX_VERSION_DOCUMENT_BYTES)
.collect()
.await
.map_err(|error| {
anyhow::anyhow!(
"update version document is too large or incomplete: {error}"
)
})?
.to_bytes();
return String::from_utf8(body.to_vec())
.context("update version document is not UTF-8");
}
unreachable!("redirect loop always returns or fails at the configured limit")
}
fn load_root_store() -> RootCertStore {
let mut roots = RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut bundles = Vec::<PathBuf>::new();
for variable in ["SSL_CERT_FILE", "CURL_CA_BUNDLE"] {
if let Some(path) = env::var_os(variable).filter(|value| !value.is_empty()) {
bundles.push(PathBuf::from(path));
}
}
bundles.extend(SYSTEM_CA_BUNDLES.iter().map(PathBuf::from));
bundles.sort();
bundles.dedup();
for path in bundles {
add_system_ca_bundle(&mut roots, &path);
}
roots
}
fn add_system_ca_bundle(roots: &mut RootCertStore, path: &Path) {
let Ok(bytes) = std::fs::read(path) else {
return;
};
let mut reader = BufReader::new(Cursor::new(bytes));
let certificates =
rustls_pemfile::certs(&mut reader).filter_map(|certificate| certificate.ok());
let (added, _ignored) = roots.add_parsable_certificates(certificates);
if added > 0 {
tracing::debug!(path=%path.display(), added, "loaded system CA certificates for update checker");
}
}
fn redirect_target(base: &Url, location: &str) -> Result<Url> {
let target = base
.join(location)
.context("invalid update version redirect URL")?;
ensure!(
matches!(target.scheme(), "http" | "https"),
"update version redirect must use http:// or https://"
);
ensure!(
target.host_str().is_some(),
"update version redirect must contain a host"
);
ensure!(
base.scheme() != "https" || target.scheme() == "https",
"update version redirect must not downgrade HTTPS to HTTP"
);
ensure!(
target.username().is_empty() && target.password().is_none(),
"update version redirect must not contain credentials"
);
Ok(target)
}
fn package_version(document: &str) -> Result<String> {
@@ -345,6 +449,23 @@ foo = { version = "100.0.0" }
assert_eq!(package_version(document).unwrap(), "0.19.4");
}
#[test]
fn update_redirects_accept_relative_https_and_reject_unsafe_targets() {
let base =
Url::parse("https://updates.example.invalid/releases/current/Cargo.toml").unwrap();
assert_eq!(
redirect_target(&base, "../next/Cargo.toml").unwrap().as_str(),
"https://updates.example.invalid/releases/next/Cargo.toml"
);
assert!(redirect_target(&base, "ftp://updates.example.invalid/Cargo.toml").is_err());
assert!(redirect_target(&base, "http://updates.example.invalid/Cargo.toml").is_err());
assert!(redirect_target(
&base,
"https://user:secret@updates.example.invalid/Cargo.toml"
)
.is_err());
}
#[test]
fn compares_release_versions() {
assert!(is_newer_version("0.19.5", "0.19.4"));