Fix (security): SSRF via DNS rebinding and redirects in extension downloads - Check resolved addresses inside the DNS resolver so every connection is covered, including redirect hops, retries and fallback URLs - Re-validate every redirect target - Block more non-public ranges (CGNAT, 0.0.0.0/8, multicast, reserved, IPv4-mapped/compatible IPv6, NAT64, 6to4) and bracketed IPv6 literals - Fixes #552

Co-authored-by: carfeii <carfeii@users.noreply.github.com>
This commit is contained in:
aleksey-hoffmanandcarfeii committed 2026-10-18 13:15:54 +03:00
1 parent c2295cecd5
commit 45cedf892f
6 files changed
+467 -40

No files matched your search

+1 -1
View File
@@ -48,7 +48,7 @@ dirs = "6"
tantivy = "0.26"
walkdir = "2.5"
rayon = "1.12"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync"] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "net", "time"] }
notify = "8"
tauri-plugin-drag = "2"
tauri-plugin-window-state = "2"
+7 -5
View File
@@ -9,7 +9,9 @@ use tauri::Emitter;
use super::archives::extract_archive;
use super::fs_ops::{next_sibling_temp_dir, remove_dir_force};
use super::http::{build_http_client, download_to_file_across_urls_with_retry, UrlCandidates};
use super::http::{
build_remote_http_client, download_to_file_across_urls_with_retry, UrlCandidates,
};
use super::paths::{ensure_app_data_subdir, get_extension_dir};
use super::security::{
authorize_extension_caller, require_integrity, validate_binary_path_component,
@@ -171,7 +173,7 @@ pub async fn download_extension_binary(
}
}
let client = build_http_client()?;
let client = build_remote_http_client()?;
let cancel_flag = get_extension_install_cancellation_flag(request.cancellation_id.as_ref());
let candidates = UrlCandidates::new(validated_url);
let mut on_progress = |_downloaded: u64, _total: Option<u64>| {};
@@ -241,7 +243,7 @@ pub async fn download_and_extract_extension_binary(
let archive_path = staging_root_dir.join("download_archive");
let client = build_http_client()?;
let client = build_remote_http_client()?;
let cancel_flag = get_extension_install_cancellation_flag(request.cancellation_id.as_ref());
let candidates = UrlCandidates::new(validated_url);
let mut on_progress = |_downloaded: u64, _total: Option<u64>| {};
@@ -518,7 +520,7 @@ async fn run_download_shared_binary(
}
}
let client = build_http_client()?;
let client = build_remote_http_client()?;
let cancel_flag = get_extension_install_cancellation_flag(cancellation_id.as_ref());
let candidates = UrlCandidates::new(validated_url);
let mut on_progress =
@@ -663,7 +665,7 @@ async fn run_download_and_extract_shared_binary(
let archive_path = staging_root_dir.join("download_archive");
let client = build_http_client()?;
let client = build_remote_http_client()?;
let cancel_flag = get_extension_install_cancellation_flag(cancellation_id.as_ref());
let candidates = UrlCandidates::new(validated_url);
let mut on_progress =
+4 -4
View File
@@ -3,9 +3,9 @@
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
use super::http::{
build_http_client, build_jsdelivr_fallback_url, check_response_status, classify_reqwest_error,
fetch_text_across_urls_with_retry, read_response_bytes_with_limit, run_with_retry,
HttpRetryError, RetryConfig, UrlCandidates,
build_http_client, build_jsdelivr_fallback_url, build_remote_http_client,
check_response_status, classify_reqwest_error, fetch_text_across_urls_with_retry,
read_response_bytes_with_limit, run_with_retry, HttpRetryError, RetryConfig, UrlCandidates,
};
use super::security::validate_remote_url;
use super::types::{FetchUrlResult, MAX_TEXT_FETCH_BYTES};
@@ -114,6 +114,6 @@ pub async fn fetch_url_text(url: String) -> Result<FetchUrlResult, String> {
let candidates = UrlCandidates::new(validated_url.clone())
.with_fallback(build_jsdelivr_fallback_url(&validated_url));
let client = build_http_client()?;
let client = build_remote_http_client()?;
fetch_text_across_urls_with_retry(&client, candidates, MAX_TEXT_FETCH_BYTES, "fetch URL").await
}
+244 -3
View File
@@ -12,7 +12,10 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use super::security::verify_integrity_sha256_digest;
use super::security::{
is_private_ip_address, validate_remote_url, verify_integrity_sha256_digest,
BLOCKED_TARGET_MESSAGE,
};
use super::types::{FetchUrlResult, MAX_TEXT_FETCH_BYTES};
const HTTP_TIMEOUT_SECS: u64 = 900;
@@ -21,6 +24,8 @@ const HTTP_POOL_IDLE_TIMEOUT_SECS: u64 = 30;
const HTTP_TCP_KEEPALIVE_SECS: u64 = 30;
const PROGRESS_EMIT_INTERVAL_BYTES: u64 = 256 * 1024;
const MAX_RETRY_AFTER_SECS: u64 = 60;
const DNS_RESOLVE_TIMEOUT_SECS: u64 = 10;
const MAX_REMOTE_REDIRECTS: usize = 10;
pub const HTTP_USER_AGENT: &str = concat!(
"sigma-file-manager/",
@@ -28,17 +33,124 @@ pub const HTTP_USER_AGENT: &str = concat!(
" (+https://github.com/aleksey-hoffman/sigma-file-manager)"
);
pub fn build_http_client() -> Result<reqwest::Client, String> {
fn base_http_client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder()
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
.connect_timeout(Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECS))
.pool_idle_timeout(Duration::from_secs(HTTP_POOL_IDLE_TIMEOUT_SECS))
.tcp_keepalive(Duration::from_secs(HTTP_TCP_KEEPALIVE_SECS))
.user_agent(HTTP_USER_AGENT)
}
/// Client for fixed, app-chosen hosts only. Use `build_remote_http_client`
/// for any URL that comes from an extension, manifest, or registry entry.
pub fn build_http_client() -> Result<reqwest::Client, String> {
base_http_client_builder()
.build()
.map_err(|error| format!("Failed to create HTTP client: {}", error))
}
#[derive(Debug)]
pub(crate) struct BlockedTargetError;
impl std::fmt::Display for BlockedTargetError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(BLOCKED_TARGET_MESSAGE)
}
}
impl std::error::Error for BlockedTargetError {}
fn ensure_public_socket_addrs(addrs: &[std::net::SocketAddr]) -> Result<(), BlockedTargetError> {
if addrs.iter().any(|addr| is_private_ip_address(&addr.ip())) {
return Err(BlockedTargetError);
}
Ok(())
}
/// Resolves hostnames for remote download clients and refuses to hand any
/// address to the connector unless every resolved address is public. Since
/// the check runs inside the resolver, the address that gets connected to is
/// always the address that was checked: on the first request, on every
/// redirect hop, on retries, and on fallback URLs.
///
/// IP-literal hosts never reach a resolver; those are covered by
/// `validate_remote_url` and by `remote_redirect_policy`.
#[derive(Debug, Default, Clone, Copy)]
struct PublicOnlyResolver;
impl reqwest::dns::Resolve for PublicOnlyResolver {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
let host = name.as_str().to_string();
Box::pin(async move {
let lookup = tokio::time::timeout(
Duration::from_secs(DNS_RESOLVE_TIMEOUT_SECS),
tokio::net::lookup_host((host.as_str(), 0)),
)
.await
.map_err(|_| -> Box<dyn std::error::Error + Send + Sync> {
format!("Timed out resolving host '{}'", host).into()
})??;
let addrs: Vec<std::net::SocketAddr> = lookup.collect();
ensure_public_socket_addrs(&addrs)?;
let resolved: reqwest::dns::Addrs = Box::new(addrs.into_iter());
Ok(resolved)
})
}
}
fn remote_redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= MAX_REMOTE_REDIRECTS {
return attempt.error("too many redirects");
}
if validate_remote_url(attempt.url().as_str()).is_err() {
return attempt.error(BlockedTargetError);
}
attempt.follow()
})
}
fn build_remote_http_client_with_resolver<R>(resolver: R) -> Result<reqwest::Client, String>
where
R: reqwest::dns::Resolve + 'static,
{
base_http_client_builder()
.dns_resolver(Arc::new(resolver))
.redirect(remote_redirect_policy())
.build()
.map_err(|error| format!("Failed to create HTTP client: {}", error))
}
/// Client for URLs supplied by extensions, manifests, or registry entries.
/// Blocks connections to non-public addresses at connect time and validates
/// every redirect target.
///
/// If the user has HTTP(S)_PROXY set, the proxy resolves the hostname and
/// this check only applies to the proxy address itself.
pub fn build_remote_http_client() -> Result<reqwest::Client, String> {
build_remote_http_client_with_resolver(PublicOnlyResolver)
}
fn is_blocked_target_error(error: &reqwest::Error) -> bool {
let mut source = std::error::Error::source(error);
while let Some(inner_error) = source {
if inner_error.is::<BlockedTargetError>() {
return true;
}
source = inner_error.source();
}
false
}
#[derive(Debug)]
pub enum HttpRetryError {
Transient {
@@ -106,6 +218,10 @@ impl RetryConfig {
}
pub fn classify_reqwest_error(prefix: &str, error: reqwest::Error) -> HttpRetryError {
if is_blocked_target_error(&error) {
return HttpRetryError::Permanent(format!("{}: {}", prefix, BLOCKED_TARGET_MESSAGE));
}
let detail = if error.is_timeout() {
format!("network timeout: {}", error)
} else if error.is_connect() {
@@ -772,7 +888,7 @@ pub async fn download_url_bytes(
) -> Result<Vec<u8>, String> {
let parsed_url =
reqwest::Url::parse(url).map_err(|error| format!("Invalid URL '{}': {}", url, error))?;
let client = build_http_client()?;
let client = build_remote_http_client()?;
let operation_label = request_error_label.to_string();
let read_error_label = read_error_label.to_string();
let config = RetryConfig::default();
@@ -1197,3 +1313,128 @@ mod tests {
}
}
}
#[cfg(test)]
mod remote_client_tests {
use super::*;
use std::io::Read as _;
fn spawn_http_server(response: String) -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
let mut buffer = [0_u8; 4096];
let _ = stream.read(&mut buffer);
let _ = stream.write_all(response.as_bytes());
}
});
port
}
fn ok_response(body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
}
fn redirect_response(location: &str) -> String {
format!(
"HTTP/1.1 302 Found\r\nLocation: {}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
location
)
}
/// Test resolver: "public.test" pretends to be a public host but points at
/// a local test server; every other name goes through the real guard.
#[derive(Clone, Copy)]
struct TestResolver {
public_test_port: u16,
}
impl reqwest::dns::Resolve for TestResolver {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
if name.as_str() == "public.test" {
let addr: std::net::SocketAddr = format!("127.0.0.1:{}", self.public_test_port)
.parse()
.unwrap();
return Box::pin(async move {
let resolved: reqwest::dns::Addrs = Box::new(std::iter::once(addr));
Ok(resolved)
});
}
PublicOnlyResolver.resolve(name)
}
}
#[test]
fn resolver_guard_rejects_any_non_public_address() {
let public: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
let loopback: std::net::SocketAddr = "127.0.0.1:443".parse().unwrap();
let mapped: std::net::SocketAddr = "[::ffff:169.254.169.254]:80".parse().unwrap();
assert!(ensure_public_socket_addrs(&[public]).is_ok());
assert!(ensure_public_socket_addrs(&[public, loopback]).is_err());
assert!(ensure_public_socket_addrs(&[mapped]).is_err());
}
#[tokio::test]
async fn remote_client_blocks_redirect_to_ip_literal() {
let internal_port = spawn_http_server(ok_response("INTERNAL-DATA"));
let public_port = spawn_http_server(redirect_response(&format!(
"http://127.0.0.1:{}/",
internal_port
)));
let client = build_remote_http_client_with_resolver(TestResolver {
public_test_port: public_port,
})
.unwrap();
let error = client
.get(format!("http://public.test:{}/", public_port))
.send()
.await
.expect_err("redirect to loopback must be blocked");
assert!(is_blocked_target_error(&error), "unexpected error: {error}");
assert!(matches!(
classify_reqwest_error("download", error),
HttpRetryError::Permanent(message) if message.contains(BLOCKED_TARGET_MESSAGE)
));
}
#[tokio::test]
async fn remote_client_blocks_redirect_to_ipv6_literal() {
let public_port = spawn_http_server(redirect_response("http://[::ffff:127.0.0.1]:9/"));
let client = build_remote_http_client_with_resolver(TestResolver {
public_test_port: public_port,
})
.unwrap();
let error = client
.get(format!("http://public.test:{}/", public_port))
.send()
.await
.expect_err("redirect to mapped loopback must be blocked");
assert!(is_blocked_target_error(&error), "unexpected error: {error}");
}
#[tokio::test]
async fn remote_client_follows_redirect_between_public_hosts() {
let public_port = spawn_http_server(ok_response("PUBLIC-DATA"));
let client = build_remote_http_client_with_resolver(TestResolver {
public_test_port: public_port,
})
.unwrap();
let body = client
.get(format!("http://public.test:{}/", public_port))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, "PUBLIC-DATA");
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ use super::fs_ops::{
replace_extension_dir,
};
use super::http::{
build_codeload_fallback_url, build_http_client, download_to_file_across_urls_with_retry,
build_codeload_fallback_url, build_remote_http_client, download_to_file_across_urls_with_retry,
UrlCandidates,
};
use super::paths::{get_extension_dir, get_extensions_base_dir};
@@ -133,7 +133,7 @@ pub async fn download_extension(
.map_err(|error| format!("Failed to create staging directory: {}", error))?;
let result = async {
let client = build_http_client()?;
let client = build_remote_http_client()?;
let candidates = UrlCandidates::new(validated_url.clone())
.with_fallback(build_codeload_fallback_url(&validated_url));
+209 -25
View File
@@ -46,25 +46,98 @@ pub fn require_integrity(integrity: &Option<String>, label: &str) -> Result<(),
}
}
pub const BLOCKED_TARGET_MESSAGE: &str = "Access denied: target host is not allowed";
fn is_non_public_ipv4_address(ipv4: &std::net::Ipv4Addr) -> bool {
let [first, second, third, _] = ipv4.octets();
first == 0
|| ipv4.is_private()
|| ipv4.is_loopback()
|| ipv4.is_link_local()
|| ipv4.is_broadcast()
|| ipv4.is_multicast()
|| (first == 100 && (64..=127).contains(&second))
|| (first == 192 && second == 0 && third == 0)
|| (first == 192 && second == 0 && third == 2)
|| (first == 198 && second == 51 && third == 100)
|| (first == 203 && second == 0 && third == 113)
|| first >= 240
// 198.18.0.0/15 is intentionally not blocked: fake-IP proxy/TUN tools
// (Clash, sing-box, Surge) hand out addresses from it for every public host.
}
fn embedded_ipv4_from_segments(high: u16, low: u16) -> std::net::Ipv4Addr {
let [a, b] = high.to_be_bytes();
let [c, d] = low.to_be_bytes();
std::net::Ipv4Addr::new(a, b, c, d)
}
fn is_non_public_ipv6_address(ipv6: &std::net::Ipv6Addr) -> bool {
if ipv6.is_loopback() || ipv6.is_unspecified() {
return true;
}
if let Some(mapped_ipv4) = ipv6.to_ipv4_mapped() {
return is_non_public_ipv4_address(&mapped_ipv4);
}
let segments = ipv6.segments();
// Deprecated IPv4-compatible addresses (::a.b.c.d).
if segments[..6].iter().all(|segment| *segment == 0) {
return is_non_public_ipv4_address(&embedded_ipv4_from_segments(segments[6], segments[7]));
}
// NAT64 well-known prefix 64:ff9b::/96 and local-use prefix 64:ff9b:1::/48.
if segments[0] == 0x0064 && segments[1] == 0xff9b {
if segments[2..6].iter().all(|segment| *segment == 0) {
return is_non_public_ipv4_address(&embedded_ipv4_from_segments(
segments[6],
segments[7],
));
}
return segments[2] == 0x0001;
}
// 6to4 (2002::/16) embeds an IPv4 address in segments 1 and 2.
if segments[0] == 0x2002 {
return is_non_public_ipv4_address(&embedded_ipv4_from_segments(segments[1], segments[2]));
}
ipv6.is_unique_local()
|| ipv6.is_unicast_link_local()
|| ipv6.is_multicast()
|| (segments[0] & 0xffc0) == 0xfec0
|| (segments[0] == 0x2001 && segments[1] == 0x0000)
|| (segments[0] == 0x2001 && segments[1] == 0x0db8)
}
/// Returns true for any address that is not a globally routable unicast
/// address: loopback, private, link-local (including cloud metadata),
/// CGNAT, multicast, reserved, documentation, and IPv6 forms that embed
/// such an IPv4 address (IPv4-mapped, IPv4-compatible, NAT64, 6to4).
pub fn is_private_ip_address(ip_address: &std::net::IpAddr) -> bool {
match ip_address {
std::net::IpAddr::V4(ipv4) => {
ipv4.is_private()
|| ipv4.is_loopback()
|| ipv4.is_link_local()
|| ipv4.is_broadcast()
|| *ipv4 == std::net::Ipv4Addr::UNSPECIFIED
|| *ipv4 == std::net::Ipv4Addr::new(169, 254, 169, 254)
}
std::net::IpAddr::V6(ipv6) => {
ipv6.is_loopback()
|| ipv6.is_unspecified()
|| ipv6.is_unique_local()
|| ipv6.is_unicast_link_local()
}
std::net::IpAddr::V4(ipv4) => is_non_public_ipv4_address(ipv4),
std::net::IpAddr::V6(ipv6) => is_non_public_ipv6_address(ipv6),
}
}
fn is_blocked_hostname(host: &str) -> bool {
let normalized_host = host.trim_end_matches('.').to_ascii_lowercase();
normalized_host.is_empty()
|| normalized_host == "localhost"
|| normalized_host.ends_with(".localhost")
|| normalized_host == "metadata.google.internal"
|| normalized_host == "metadata"
|| normalized_host.ends_with(".local")
}
/// String-level check for extension download URLs. Hostnames are checked
/// again at connect time by the resolver in `http::build_remote_http_client`,
/// which also re-validates every redirect hop with this function.
pub fn validate_remote_url(url: &str) -> Result<reqwest::Url, String> {
let parsed_url =
reqwest::Url::parse(url).map_err(|error| format!("Invalid URL '{}': {}", url, error))?;
@@ -76,21 +149,20 @@ pub fn validate_remote_url(url: &str) -> Result<reqwest::Url, String> {
let host = parsed_url
.host_str()
.ok_or_else(|| "URL host is required".to_string())?
.to_ascii_lowercase();
.ok_or_else(|| "URL host is required".to_string())?;
if host == "localhost"
|| host == "metadata.google.internal"
|| host == "metadata"
|| host.ends_with(".local")
{
return Err("Access denied: target host is not allowed".to_string());
}
// `host_str()` keeps the brackets around IPv6 literals ("[::1]").
let unbracketed_host = host
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or(host);
if let Ok(ip_address) = host.parse::<std::net::IpAddr>() {
if let Ok(ip_address) = unbracketed_host.parse::<std::net::IpAddr>() {
if is_private_ip_address(&ip_address) {
return Err("Access denied: target host is not allowed".to_string());
return Err(BLOCKED_TARGET_MESSAGE.to_string());
}
} else if is_blocked_hostname(unbracketed_host) {
return Err(BLOCKED_TARGET_MESSAGE.to_string());
}
Ok(parsed_url)
@@ -306,3 +378,115 @@ pub fn validate_binary_relative_path(value: &str, label: &str) -> Result<PathBuf
Ok(path)
}
#[cfg(test)]
mod remote_url_tests {
use super::{is_private_ip_address, validate_remote_url};
#[test]
fn blocks_non_public_addresses() {
for address in [
"0.0.0.0",
"0.1.2.3",
"10.0.0.1",
"100.64.0.1",
"100.127.255.254",
"127.0.0.1",
"127.9.9.9",
"169.254.169.254",
"172.16.0.1",
"192.0.0.1",
"192.168.1.1",
"224.0.0.1",
"240.0.0.1",
"255.255.255.255",
"::",
"::1",
"::ffff:127.0.0.1",
"::ffff:169.254.169.254",
"::127.0.0.1",
"64:ff9b::7f00:1",
"64:ff9b::a9fe:a9fe",
"2002:7f00:1::",
"2002:a9fe:a9fe::",
"fd00:ec2::254",
"fc00::1",
"fe80::1",
"fec0::1",
"ff02::1",
"2001:db8::1",
"2001::1",
] {
let parsed: std::net::IpAddr = address.parse().unwrap();
assert!(
is_private_ip_address(&parsed),
"{address} should be blocked"
);
}
}
#[test]
fn allows_public_addresses() {
for address in [
"1.1.1.1",
"8.8.8.8",
"140.82.112.3",
"198.18.0.1",
"100.128.0.1",
"2606:4700:4700::1111",
"2a00:1450:4001::200e",
"::ffff:8.8.8.8",
"64:ff9b::808:808",
"2002:808:808::",
] {
let parsed: std::net::IpAddr = address.parse().unwrap();
assert!(
!is_private_ip_address(&parsed),
"{address} should be allowed"
);
}
}
#[test]
fn rejects_non_public_url_hosts() {
for url in [
"http://127.0.0.1/",
"http://2130706433/",
"http://0177.0.0.1/",
"http://0x7f.1/",
"http://0xa9fea9fe/latest/meta-data/",
"http://[::1]:8080/",
"http://[::ffff:127.0.0.1]/",
"http://[::ffff:a9fe:a9fe]/",
"http://[fd00:ec2::254]/",
"http://[fe80::1]/",
"http://100.64.0.1/",
"http://0.1.2.3/",
"http://localhost/",
"http://localhost./",
"http://LOCALHOST./",
"http://foo.localhost/",
"http://printer.local/",
"http://metadata.google.internal/",
"file:///etc/passwd",
"ftp://example.com/",
] {
assert!(
validate_remote_url(url).is_err(),
"{url} should be rejected"
);
}
}
#[test]
fn accepts_public_url_hosts() {
for url in [
"https://github.com/owner/repo/archive/refs/tags/v1.0.0.zip",
"https://raw.githubusercontent.com/owner/repo/main/README.md",
"https://1.1.1.1/",
"https://[2606:4700:4700::1111]/",
] {
assert!(validate_remote_url(url).is_ok(), "{url} should be accepted");
}
}
}