mirror of
https://github.com/aleksey-hoffman/sigma-file-manager.git
synced 2026-10-18 17:20:21 -05:00
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:
1 parent
c2295cecd5
commit
45cedf892f
6 files changed
+467
-40
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user