mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 09:27:29 +02:00
fix: block private image proxy requests
This commit is contained in:
@@ -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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Url, ProxyError> {
|
||||||
|
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::<IpAddr>() {
|
||||||
|
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<SocketAddr>), 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<Client, ProxyError> {
|
||||||
|
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<ImageResponse, ProxyError> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-62
@@ -3,6 +3,7 @@ mod asset_scope;
|
|||||||
mod backup;
|
mod backup;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod history;
|
mod history;
|
||||||
|
mod image_proxy;
|
||||||
mod search;
|
mod search;
|
||||||
mod state;
|
mod state;
|
||||||
mod sync;
|
mod sync;
|
||||||
@@ -252,72 +253,21 @@ pub fn run() {
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let encoded = path.strip_prefix('/').unwrap_or(&path);
|
let encoded = path.strip_prefix('/').unwrap_or(&path);
|
||||||
let external_url = percent_decode(encoded);
|
let external_url = percent_decode(encoded);
|
||||||
|
let response = match image_proxy::fetch(&external_url) {
|
||||||
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
|
Ok(image) => tauri::http::Response::builder()
|
||||||
responder.respond(
|
.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()
|
tauri::http::Response::builder()
|
||||||
.status(400)
|
.status(error.status())
|
||||||
.body(Vec::new())
|
.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;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
responder.respond(response.expect("valid image proxy response"));
|
||||||
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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user