//! HTTPS allowlist for update and module catalogs (port of `security/urlPolicy.js`). use crate::core::error::{CoreError, CoreResult}; use url::Url; pub fn normalize_base_url(value: &str) -> CoreResult { let mut url = Url::parse(value).map_err(|_| CoreError::plain("Invalid update base URL."))?; if url.scheme() != "https" { return Err(CoreError::plain("Update URL must use HTTPS.")); } if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() || url.query().is_some() { return Err(CoreError::plain("Invalid update base URL.")); } let path = format!("{}/", url.path().trim_end_matches('/')); url.set_path(&path); Ok(url) } #[derive(Default)] pub struct AllowOptions<'a> { pub base_url: Option<&'a str>, pub path_segment: Option<&'a str>, } pub fn assert_allowed_url(candidate: &str, allowed: &[String], options: &AllowOptions<'_>) -> CoreResult { let raw = candidate.trim(); if raw.is_empty() { return Err(CoreError::plain("Missing URL.")); } let bases = allowed.iter().map(|b| normalize_base_url(b)).collect::>>()?; if raw.starts_with('/') { let base = normalize_base_url(options.base_url.ok_or_else(|| CoreError::plain("Relative URL requires a base URL."))?)?; let joined = base.join(raw.trim_start_matches('/')).map_err(|_| CoreError::plain("Invalid URL."))?; return assert_allowed_url(joined.as_str(), allowed, options); } let url = Url::parse(raw).map_err(|_| CoreError::plain("Invalid URL."))?; if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { return Err(CoreError::plain("URL credentials and fragments are not allowed.")); } let matched = bases .iter() .find(|base| url.scheme() == base.scheme() && url.host_str() == base.host_str() && url.port() == base.port() && url.path().starts_with(base.path())) .ok_or_else(|| CoreError::plain("URL is outside the update allowlist."))?; if let Some(segment) = options.path_segment { if !url.path().starts_with(&format!("{}{}", matched.path(), segment)) { return Err(CoreError::plain("URL path is outside the allowed update area.")); } } Ok(url) } /// External links the main window may open in the system browser (same rule as 0.1.0). pub fn is_allowed_external_url(value: &str) -> bool { Url::parse(value).map(|u| u.scheme() == "https" && u.host_str() == Some("ch-j.de")).unwrap_or(false) } #[cfg(test)] mod tests { use super::*; #[test] fn allowlist() { let allowed = vec!["https://sm.ch-j.de/".to_string()]; assert!(assert_allowed_url("https://sm.ch-j.de/files/a.dmg", &allowed, &AllowOptions { path_segment: Some("files/"), ..Default::default() }).is_ok()); assert!(assert_allowed_url("https://sm.ch-j.de/api/x", &allowed, &AllowOptions { path_segment: Some("files/"), ..Default::default() }).is_err()); assert!(assert_allowed_url("https://evil.example/files/a", &allowed, &AllowOptions::default()).is_err()); assert!(assert_allowed_url("https://u:p@sm.ch-j.de/x", &allowed, &AllowOptions::default()).is_err()); assert!(assert_allowed_url("http://sm.ch-j.de/x", &allowed, &AllowOptions::default()).is_err()); assert!(assert_allowed_url("https://sm.ch-j.de:444/x", &allowed, &AllowOptions::default()).is_err()); assert_eq!( assert_allowed_url("/files/b.deb", &allowed, &AllowOptions { base_url: Some("https://sm.ch-j.de"), ..Default::default() }).unwrap().as_str(), "https://sm.ch-j.de/files/b.deb" ); assert!(is_allowed_external_url("https://ch-j.de/x")); assert!(!is_allowed_external_url("http://ch-j.de/x")); } }