From 0391a60c1b8b9abb36365cfef4958aa4d9f62503 Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Mon, 17 Aug 2026 21:06:50 +0200 Subject: [PATCH] fix: block private image proxy requests --- src-tauri/src/image_proxy.rs | 244 +++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 74 ++--------- 2 files changed, 256 insertions(+), 62 deletions(-) create mode 100644 src-tauri/src/image_proxy.rs diff --git a/src-tauri/src/image_proxy.rs b/src-tauri/src/image_proxy.rs new file mode 100644 index 0000000..0f2cef3 --- /dev/null +++ b/src-tauri/src/image_proxy.rs @@ -0,0 +1,244 @@ +use reqwest::{blocking::Client, redirect::Policy, Url}; +use std::io::Read; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs}; +use std::time::Duration; + +const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024; + +pub struct ImageResponse { + pub content_type: String, + pub body: Vec, +} + +pub enum ProxyError { + Invalid(String), + Blocked(String), + Fetch(String), +} + +impl ProxyError { + pub fn status(&self) -> u16 { + match self { + Self::Invalid(_) => 400, + Self::Blocked(_) => 403, + Self::Fetch(_) => 502, + } + } + + pub fn message(&self) -> &str { + match self { + Self::Invalid(message) | Self::Blocked(message) | Self::Fetch(message) => message, + } + } +} + +fn is_public_ipv4(ip: Ipv4Addr) -> bool { + let [a, b, c, _] = ip.octets(); + !(a == 0 + || a == 10 + || a == 127 + || a >= 224 + || (a == 100 && (64..=127).contains(&b)) + || (a == 169 && b == 254) + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 168) + || (a == 192 && b == 0 && c == 0) + || (a == 192 && b == 0 && c == 2) + || (a == 198 && (b == 18 || b == 19)) + || (a == 198 && b == 51 && c == 100) + || (a == 203 && b == 0 && c == 113)) +} + +fn is_public_ipv6(ip: Ipv6Addr) -> bool { + if ip.is_unspecified() || ip.is_loopback() || ip.to_ipv4().is_some() { + return false; + } + let first = ip.segments()[0]; + (0x2000..=0x3fff).contains(&first) && ip.segments()[..2] != [0x2001, 0x0db8] +} + +fn is_public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_public_ipv4(ip), + IpAddr::V6(ip) => is_public_ipv6(ip), + } +} + +fn normalized_host(host: &str) -> String { + host.trim_start_matches('[') + .trim_end_matches(']') + .trim_end_matches('.') + .to_ascii_lowercase() +} + +fn validate_url(raw_url: &str) -> Result { + let url = Url::parse(raw_url).map_err(|_| ProxyError::Invalid("Invalid image URL".into()))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(ProxyError::Invalid( + "Only HTTP and HTTPS images are supported".into(), + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(ProxyError::Invalid( + "Image URLs must not contain credentials".into(), + )); + } + let expected_port = if url.scheme() == "https" { 443 } else { 80 }; + if url.port_or_known_default() != Some(expected_port) { + return Err(ProxyError::Blocked("Non-standard image URL port".into())); + } + let host = url + .host_str() + .ok_or_else(|| ProxyError::Invalid("Image URL has no host".into()))?; + if host.ends_with('.') { + return Err(ProxyError::Invalid( + "Image URL host must not have a trailing dot".into(), + )); + } + let normalized = normalized_host(host); + if normalized == "localhost" || normalized.ends_with(".localhost") { + return Err(ProxyError::Blocked( + "Local image hosts are not allowed".into(), + )); + } + if let Ok(ip) = normalized.parse::() { + if !is_public_ip(ip) { + return Err(ProxyError::Blocked( + "Private image addresses are not allowed".into(), + )); + } + } + Ok(url) +} + +fn resolve_public_host(url: &Url) -> Result<(String, Vec), ProxyError> { + let host = url + .host_str() + .ok_or_else(|| ProxyError::Invalid("Image URL has no host".into()))?; + let host = normalized_host(host); + let port = url + .port_or_known_default() + .ok_or_else(|| ProxyError::Invalid("Image URL has no port".into()))?; + let addresses: Vec<_> = (host.as_str(), port) + .to_socket_addrs() + .map_err(|error| ProxyError::Fetch(format!("Could not resolve image host: {error}")))? + .collect(); + if addresses.is_empty() { + return Err(ProxyError::Fetch( + "Image host resolved to no addresses".into(), + )); + } + if addresses.iter().any(|address| !is_public_ip(address.ip())) { + return Err(ProxyError::Blocked( + "Image host resolves to a private address".into(), + )); + } + Ok((host, addresses)) +} + +fn image_client(host: &str, addresses: &[SocketAddr]) -> Result { + let redirect_host = host.to_string(); + Client::builder() + .timeout(Duration::from_secs(30)) + .redirect(Policy::custom(move |attempt| { + if attempt.previous().len() >= 5 + || attempt + .url() + .host_str() + .map_or(true, |host| normalized_host(host) != redirect_host) + || validate_url(attempt.url().as_str()).is_err() + { + attempt.stop() + } else { + attempt.follow() + } + })) + .resolve_to_addrs(host, addresses) + .build() + .map_err(|error| ProxyError::Fetch(format!("Could not create image client: {error}"))) +} + +pub fn fetch(raw_url: &str) -> Result { + let url = validate_url(raw_url)?; + let (host, addresses) = resolve_public_host(&url)?; + let client = image_client(&host, &addresses)?; + let mut response = client + .get(url) + .send() + .map_err(|error| ProxyError::Fetch(format!("Image request failed: {error}")))?; + if !response.status().is_success() { + return Err(ProxyError::Fetch(format!( + "Image server returned {}", + response.status() + ))); + } + if response + .content_length() + .is_some_and(|length| length > MAX_IMAGE_BYTES) + { + return Err(ProxyError::Blocked("Image exceeds the 20 MiB limit".into())); + } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string(); + if !content_type.to_ascii_lowercase().starts_with("image/") { + return Err(ProxyError::Blocked( + "Remote resource is not an image".into(), + )); + } + let mut body = Vec::new(); + response + .by_ref() + .take(MAX_IMAGE_BYTES + 1) + .read_to_end(&mut body) + .map_err(|error| ProxyError::Fetch(format!("Could not read image: {error}")))?; + if body.len() as u64 > MAX_IMAGE_BYTES { + return Err(ProxyError::Blocked("Image exceeds the 20 MiB limit".into())); + } + Ok(ImageResponse { content_type, body }) +} + +#[cfg(test)] +mod tests { + use super::{is_public_ip, validate_url}; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + #[test] + fn blocks_local_and_private_networks() { + for ip in [ + IpAddr::V4(Ipv4Addr::LOCALHOST), + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)), + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), + IpAddr::V6(Ipv6Addr::LOCALHOST), + "fc00::1".parse().unwrap(), + "fe80::1".parse().unwrap(), + ] { + assert!(!is_public_ip(ip), "{ip}"); + } + assert!(is_public_ip("93.184.216.34".parse().unwrap())); + assert!(is_public_ip( + "2606:2800:220:1:248:1893:25c8:1946".parse().unwrap() + )); + } + + #[test] + fn validates_only_public_web_image_urls() { + for url in [ + "file:///etc/passwd", + "http://localhost/image.png", + "http://127.0.0.1/image.png", + "http://[::1]/image.png", + "http://example.com:8080/image.png", + "https://example.com./image.png", + "https://user:pass@example.com/image.png", + ] { + assert!(validate_url(url).is_err(), "{url}"); + } + assert!(validate_url("https://example.com/image.png").is_ok()); + assert!(validate_url("http://93.184.216.34/image.png").is_ok()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ccbc38c..e2388fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod asset_scope; mod backup; mod commands; mod history; +mod image_proxy; mod search; mod state; mod sync; @@ -252,72 +253,21 @@ pub fn run() { std::thread::spawn(move || { let encoded = path.strip_prefix('/').unwrap_or(&path); let external_url = percent_decode(encoded); - - if !external_url.starts_with("http://") && !external_url.starts_with("https://") { - responder.respond( + let response = match image_proxy::fetch(&external_url) { + Ok(image) => tauri::http::Response::builder() + .status(200) + .header("Content-Type", image.content_type) + .header("Access-Control-Allow-Origin", "*") + .header("X-Content-Type-Options", "nosniff") + .body(image.body), + Err(error) => { + log::warn!("Blocked external image request: {}", error.message()); tauri::http::Response::builder() - .status(400) + .status(error.status()) .body(Vec::new()) - .unwrap(), - ); - return; - } - - let client = match reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - { - Ok(c) => c, - Err(_) => { - responder.respond( - tauri::http::Response::builder() - .status(502) - .body(Vec::new()) - .unwrap(), - ); - return; } }; - - match client.get(&external_url).send() { - Ok(resp) => { - let content_type = resp - .headers() - .get("content-type") - .and_then(|h| h.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_string(); - let status = resp.status().as_u16(); - match resp.bytes() { - Ok(bytes) => { - responder.respond( - tauri::http::Response::builder() - .status(status) - .header("Content-Type", &content_type) - .header("Access-Control-Allow-Origin", "*") - .body(bytes.to_vec()) - .unwrap(), - ); - } - Err(_) => { - responder.respond( - tauri::http::Response::builder() - .status(502) - .body(Vec::new()) - .unwrap(), - ); - } - } - } - Err(_) => { - responder.respond( - tauri::http::Response::builder() - .status(502) - .body(Vec::new()) - .unwrap(), - ); - } - } + responder.respond(response.expect("valid image proxy response")); }); });