//! HTTPS transport for update and module catalogs (port of `security/testHttpsFetch.js`). //! //! * only explicitly allowed HTTPS origins, TLS 1.2–1.3, no automatic redirects; //! * the owner-approved certificate exception applies to exactly one legacy origin //! (`https://sm.ch-j.de/`), unchanged from 0.1.0. Integrity of Core updates never relies //! on transport: payloads are verified by SHA-512 and an OpenPGP signature. use crate::core::error::{CoreError, CoreResult}; use std::collections::HashSet; use std::time::Duration; use url::Url; pub const INSECURE_LEGACY_ORIGINS: [&str; 1] = ["https://sm.ch-j.de/"]; fn origin_of(value: &str) -> CoreResult { let url = Url::parse(value).map_err(|_| CoreError::plain("Invalid HTTPS origin."))?; if url.scheme() != "https" { return Err(CoreError::plain("Test update transport accepts HTTPS origins only.")); } Ok(url.origin().ascii_serialization()) } pub struct HttpsClient { allowed: HashSet, insecure: HashSet, strict: reqwest::Client, legacy: reqwest::Client, } fn builder() -> reqwest::ClientBuilder { reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .min_tls_version(reqwest::tls::Version::TLS_1_2) .use_rustls_tls() .no_proxy() .connect_timeout(Duration::from_secs(10)) .user_agent(concat!("CH-J-Server-Manager/", env!("CARGO_PKG_VERSION"))) } impl HttpsClient { pub fn new(allowed_base_urls: &[String], insecure_base_urls: &[&str]) -> CoreResult { let allowed = allowed_base_urls.iter().map(|u| origin_of(u)).collect::>>()?; if allowed.is_empty() { return Err(CoreError::plain("At least one test HTTPS origin is required.")); } let insecure = insecure_base_urls.iter().map(|u| origin_of(u)).collect::>>()?; if insecure.iter().any(|o| !allowed.contains(o)) { return Err(CoreError::plain("An insecure test origin must also be explicitly allowed.")); } let strict = builder().build().map_err(|e| CoreError::plain(e.to_string()))?; let legacy = builder().danger_accept_invalid_certs(true).build().map_err(|e| CoreError::plain(e.to_string()))?; Ok(Self { allowed, insecure, strict, legacy }) } pub fn client_for(&self, url: &Url) -> CoreResult<&reqwest::Client> { let origin = url.origin().ascii_serialization(); if url.scheme() != "https" || !self.allowed.contains(&origin) { return Err(CoreError::plain("Test HTTPS request is outside the allowed origins.")); } Ok(if self.insecure.contains(&origin) { &self.legacy } else { &self.strict }) } pub async fn get(&self, url: &Url, accept: &str, timeout: Duration) -> CoreResult { self.client_for(url)? .get(url.clone()) .header(reqwest::header::ACCEPT, accept) .timeout(timeout) .send() .await .map_err(|e| CoreError::plain(if e.is_timeout() { "Request timeout.".to_string() } else { e.to_string() })) } /// GET with a hard size limit on the decoded body. pub async fn get_text(&self, url: &Url, accept: &str, timeout: Duration, max_bytes: usize) -> CoreResult<(u16, String)> { let response = self.get(url, accept, timeout).await?; let status = response.status().as_u16(); if response.content_length().map(|l| l as usize > max_bytes).unwrap_or(false) { return Err(CoreError::plain("Response is too large.")); } let mut body = Vec::new(); let mut response = response; while let Some(chunk) = response.chunk().await.map_err(|e| CoreError::plain(e.to_string()))? { body.extend_from_slice(&chunk); if body.len() > max_bytes { return Err(CoreError::plain("Response is too large.")); } } Ok((status, String::from_utf8_lossy(&body).to_string())) } }