diff --git a/src/config.rs b/src/config.rs index c1c208e..2fe8938 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,8 +7,8 @@ use moka::future::Cache; use tokio::sync::mpsc::Sender; use crate::{ + dns::ValidateDnsResolver, endpoints::mods::IndexQueryParams, - pin_dns::PinDnsResolver, s3_worker::S3WorkerTask, storage::{LocalBackend, PrivateDisk, PublicDisk, S3Backend, S3Configuration}, types::{ @@ -36,7 +36,7 @@ pub struct AppData { mods_cache: Cache>>, http_client: reqwest::Client, - pin_dns_http_client: reqwest::Client, + check_dns_http_client: reqwest::Client, s3_sender: OnceLock>, } @@ -86,12 +86,12 @@ pub async fn build_config() -> anyhow::Result { None }; - let pin_dns_http_client = reqwest::Client::builder() - .dns_resolver(Arc::new(PinDnsResolver)) + let check_dns_http_client = reqwest::Client::builder() + .dns_resolver(Arc::new(ValidateDnsResolver)) .pool_max_idle_per_host(4) .connect_timeout(Duration::from_secs(10)) - .read_timeout(Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()) + .read_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) .build()?; Ok(AppData { @@ -124,7 +124,7 @@ pub async fn build_config() -> anyhow::Result { .connect_timeout(Duration::from_secs(10)) .read_timeout(Duration::from_secs(30)) .build()?, - pin_dns_http_client, + check_dns_http_client, s3_sender: OnceLock::new(), }) } @@ -204,19 +204,14 @@ impl AppData { &self.http_client } - /// Client that allows pinning DNS queries to a certain ip address. + /// Client that validates passed host, denies all private resolved IP addresses. /// Useful for preventing Server Side Request Forgery. /// - /// To pin an address, you have to use pin_dns::PINNED_ADDRS. - /// /// Basically, if you have a URL as user input, *always* use this client. /// - /// The downside is that you get worse DNS performance, doesn't matter when - /// security is involved though. - /// /// For an example, check mod_zip::download() - pub fn pin_dns_http_client(&self) -> &reqwest::Client { - &self.pin_dns_http_client + pub fn check_dns_http_client(&self) -> &reqwest::Client { + &self.check_dns_http_client } pub fn init_s3_sender(&self, sender: Sender) { diff --git a/src/dns.rs b/src/dns.rs new file mode 100644 index 0000000..79fb970 --- /dev/null +++ b/src/dns.rs @@ -0,0 +1,115 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use reqwest::dns::{Name, Resolve, Resolving}; + +#[derive(Clone, Default, Debug)] +pub struct ValidateDnsResolver; + +impl Resolve for ValidateDnsResolver { + fn resolve(&self, name: Name) -> Resolving { + Box::pin(async move { + tracing::debug!("resolving {}", name.as_str()); + + Ok(Box::new(parse_name_to_ips(&name).await.into_iter()) + as Box + Send>) + }) + } +} + +async fn parse_name_to_ips(name: &Name) -> Vec { + let lookup = tokio::net::lookup_host(name.as_str()).await; + + match lookup { + Err(e) => { + tracing::warn!("Failed to lookup host {}: {:?}", name.as_str(), e); + vec![] + } + Ok(iter) => iter.filter(|addr| !is_disallowed_ip(addr.ip())).collect(), + } +} + +/// Most of this function has been stolen from IpAddr::is_global(). +/// +/// Since that's still an unstable rust feature, we'll use this for now. +fn is_disallowed_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() // 127.0.0.0/8 + || v4.is_private() // 10.0.0.0/8 | 192.168.0.0/16 | 172.16.0.0/12 + || v4.is_link_local() // covers 169.254.169.254 cloud metadata + || v4.is_unspecified() // 0.0.0.0 + || v4.is_broadcast() // 255.255.255.255 + || v4.is_documentation() // 192.0.2.0/24 | 198.51.100.0/24 | 203.0.113.0/24 + || v4.is_multicast() // 224.0.0.0/4 + || is_shared_nat(v4) // 100.64.0.0/10 CGNAT + || is_ietf_protocol_assignment(v4) // 192.0.0.0/24, (except 192.0.0.9, 192.0.0.10) + || is_reserved(v4) // 240.0.0.0/4 + || is_benchmarking(v4) // 198.18.0.0/15 + } + IpAddr::V6(v6) => { + v6.is_loopback() // ::1 + || v6.is_unspecified() // :: + || v6.is_unique_local() // fc00::/7 + || v6.is_unicast_link_local() // fe80::/10 + || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0xdb8) // 2001:db8::/32 documentation + || v6.is_multicast() // ff00::/8 + || v6 + .to_ipv4_mapped() + .is_some_and(|v4| is_disallowed_ip(IpAddr::V4(v4))) + } + } +} + +/// Denies ipv4 like `100.64.0.0/10`, used for CGNAT +fn is_shared_nat(v4: Ipv4Addr) -> bool { + let o = v4.octets(); + o[0] == 100 && (o[1] & 0b1100_0000) == 0b0100_0000 +} + +fn is_ietf_protocol_assignment(v4: Ipv4Addr) -> bool { + v4.octets()[0] == 192 + && v4.octets()[1] == 0 + && v4.octets()[2] == 0 + && v4.octets()[3] != 9 + && v4.octets()[3] != 10 +} + +fn is_reserved(v4: Ipv4Addr) -> bool { + (v4.octets()[0] & 0xf0) == 240 +} + +fn is_benchmarking(v4: Ipv4Addr) -> bool { + v4.octets()[0] == 198 && (v4.octets()[1] & 0xfe) == 18 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::IpAddr; + + #[test] + fn flags_private_and_special_ranges() { + let cases: &[(&str, bool)] = &[ + ("127.0.0.1", true), + ("10.0.0.5", true), + ("172.16.0.1", true), + ("192.168.1.1", true), + ("169.254.169.254", true), // cloud metadata + ("100.64.0.1", true), // CGNAT + ("8.8.8.8", false), + ("1.1.1.1", false), + ]; + for (ip, expected) in cases { + let addr: IpAddr = ip.parse().unwrap(); + assert_eq!(is_disallowed_ip(addr), *expected, "failed for {ip}"); + } + } + + #[test] + fn flags_ipv6_special_ranges() { + assert!(is_disallowed_ip("::1".parse().unwrap())); + assert!(is_disallowed_ip("fc00::1".parse().unwrap())); + assert!(is_disallowed_ip("fe80::1".parse().unwrap())); + assert!(!is_disallowed_ip("2606:4700:4700::1111".parse().unwrap())); // public + } +} diff --git a/src/endpoints/mod_versions.rs b/src/endpoints/mod_versions.rs index aefb880..3d48b99 100644 --- a/src/endpoints/mod_versions.rs +++ b/src/endpoints/mod_versions.rs @@ -354,7 +354,7 @@ pub async fn create_version( .collect(); let bytes = download_mod( - data.pin_dns_http_client(), + data.check_dns_http_client(), &download_link, data.max_download_mb(), ) @@ -580,7 +580,7 @@ pub async fn update_version( } let bytes = mod_zip::download_mod_hash_comp( - data.pin_dns_http_client(), + data.check_dns_http_client(), &version.download_link, &version.hash, data.max_download_mb(), diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index 696e8f1..b6c622b 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -218,7 +218,7 @@ pub async fn create( let dev = auth.developer()?; let mut pool = data.db().acquire().await?; let bytes = mod_zip::download_mod( - data.pin_dns_http_client(), + data.check_dns_http_client(), &payload.download_link, data.max_download_mb(), ) diff --git a/src/main.rs b/src/main.rs index 1f1abc0..96d8d64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod auth; mod cli; mod config; mod database; +mod dns; mod endpoints; mod events; mod extractors; @@ -20,7 +21,6 @@ mod jobs; mod logging; mod mod_zip; mod openapi; -mod pin_dns; mod s3_worker; mod storage; mod types; diff --git a/src/mod_zip.rs b/src/mod_zip.rs index e2610e6..a11e32b 100644 --- a/src/mod_zip.rs +++ b/src/mod_zip.rs @@ -1,7 +1,5 @@ use std::io::Seek; use std::io::{BufReader, Cursor, Read}; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs}; -use std::time::Duration; use actix_web::web::Bytes; use image::codecs::png::PngDecoder; @@ -13,14 +11,6 @@ use zip::ZipArchive; use zip::read::ZipFile; use zip::result::ZipError; -use crate::pin_dns::PINNED_ADDRS; - -const DOWNLOAD_DENYLIST_DOMAINS: [&str; 1] = ["localhost"]; -const DOWNLOAD_DENYLIST_TLDS: [&str; 4] = [".host", ".lan", ".local", ".internal"]; -const MAX_REDIRECTS: u8 = 10; - -const TOTAL_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); - #[derive(thiserror::Error, Debug)] pub enum ModZipError { #[error("I/O error: {0}")] @@ -35,10 +25,6 @@ pub enum ModZipError { InvalidLogo(String), #[error("Download link is invalid")] InvalidModFileUrl, - #[error("Too many redirects when downloading .geode file")] - TooManyRedirects, - #[error("Invalid Location header on download URL redirect")] - InvalidRedirect, #[error(".geode file hash mismatch: {0} doesn't match {1}")] ModFileHashMismatch(String, String), #[error("Failed to fetch .geode file: {0}")] @@ -67,15 +53,12 @@ pub fn extract_mod_logo(file: &mut ZipFile) -> Result, ModZi let mut reader = BufReader::new(Cursor::new(logo)); - let mut img = PngDecoder::with_limits( - &mut reader, - { - let mut l = image::Limits::default(); - l.max_image_width = Some(1024); - l.max_image_height = Some(1024); - l - }, - ) + let mut img = PngDecoder::with_limits(&mut reader, { + let mut l = image::Limits::default(); + l.max_image_width = Some(2048); + l.max_image_height = Some(2048); + l + }) .and_then(DynamicImage::from_decoder) .inspect_err(|e| tracing::error!("Failed to create PngDecoder: {}", e))?; @@ -128,15 +111,12 @@ pub fn validate_mod_logo(file: &mut ZipFile) -> Result<(), ModZipErr let mut reader = BufReader::new(Cursor::new(logo)); - let img = PngDecoder::with_limits( - &mut reader, - { - let mut l = image::Limits::default(); - l.max_image_width = Some(1024); - l.max_image_height = Some(1024); - l - }, - ) + let img = PngDecoder::with_limits(&mut reader, { + let mut l = image::Limits::default(); + l.max_image_width = Some(1024); + l.max_image_height = Some(1024); + l + }) .and_then(DynamicImage::from_decoder) .inspect_err(|e| tracing::error!("Failed to create PngDecoder: {}", e))?; @@ -189,275 +169,51 @@ async fn download( url: &str, limit_mb: u32, ) -> Result { - let mut current_url = Url::parse(url).map_err(|_| ModZipError::InvalidModFileUrl)?; + let url = Url::parse(url).map_err(|_| ModZipError::InvalidModFileUrl)?; - tracing::debug!("fetching mod from {current_url}"); - - let limit_bytes: u64 = limit_mb as u64 * 1_000_000; - - for i in 0..MAX_REDIRECTS { - tracing::debug!("starting hop {}", i + 1); - let addrs = validate_download_url(¤t_url)?; - let port = current_url.port_or_known_default().unwrap_or(443); - let socket_addrs: Vec = addrs - .into_iter() - .map(|ip| std::net::SocketAddr::new(ip, port)) - .collect(); - tracing::debug!("DNS validated as {:?}", socket_addrs); - - // Pin the validated ip addresses in our cool custom resolver. - let response = PINNED_ADDRS - .scope(socket_addrs, async { - http_client - .get(current_url.as_str()) - .timeout(TOTAL_DOWNLOAD_TIMEOUT) - .send() - .await - .inspect_err(|e| tracing::error!("Failed to fetch .geode file: {e}")) - }) - .await?; - - if response.status().is_redirection() { - let location = response - .headers() - .get(reqwest::header::LOCATION) - .ok_or(ModZipError::InvalidRedirect)? - .to_str() - .map_err(|_| ModZipError::InvalidRedirect)?; - current_url = current_url - .join(location) - .map_err(|_| ModZipError::InvalidRedirect)?; - continue; - } + if !allowed_scheme(&url) { + return Err(ModZipError::InvalidModFileUrl); + } - let mut response = response - .error_for_status() - .inspect_err(|e| tracing::error!("Failed to fetch .geode file: {e}"))?; + tracing::debug!("fetching mod from {url}"); - // Check Content-Length, but the server can lie about this, so we'll also stream the file - // If the header is somehow unavailable, we'll just check the size when streaming - let content_length = response.content_length().unwrap_or(0); + let limit_bytes: u64 = limit_mb as u64 * 1_000_000; - if content_length > limit_bytes { - let len_mb = content_length / 1_000_000; - return Err(ModZipError::ModFileTooLarge(len_mb, limit_mb.into())); - } + let mut response = http_client + .get(url) + .send() + .await + .inspect_err(|e| tracing::error!("Failed to fetch .geode file: {e}"))? + .error_for_status() + .inspect_err(|e| tracing::error!("Failed to fetch .geode file: {e}"))?; - let mut data: Vec = Vec::with_capacity(content_length as usize); + // Check Content-Length, but the server can lie about this, so we'll also stream the file + // If the header is somehow unavailable, we'll just check the size when streaming + let content_length = response.content_length().unwrap_or(0); - let mut streamed: u64 = 0; - loop { - let chunk = response.chunk().await?; + if content_length > limit_bytes { + let len_mb = content_length / 1_000_000; + return Err(ModZipError::ModFileTooLarge(len_mb, limit_mb.into())); + } - let Some(chunk) = chunk else { - break; - }; + let mut data: Vec = Vec::with_capacity(content_length as usize); - streamed += chunk.len() as u64; + let mut streamed: u64 = 0; - if streamed > limit_bytes { - let len_mb = streamed / 1_000_000; - return Err(ModZipError::ModFileTooLarge(len_mb, limit_mb.into())); - } + while let Some(chunk) = response.chunk().await? { + streamed += chunk.len() as u64; - data.extend_from_slice(&chunk); + if streamed > limit_bytes { + let len_mb = streamed / 1_000_000; + return Err(ModZipError::ModFileTooLarge(len_mb, limit_mb.into())); } - return Ok(Bytes::from(data)); - } - - Err(ModZipError::TooManyRedirects) -} - -/// Hopefully this gets rid of all nasty ips -fn is_disallowed_ip(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() // covers 169.254.169.254 cloud metadata - || v4.is_unspecified() - || v4.is_broadcast() - || v4.is_documentation() - || v4.is_multicast() - || v4.octets()[0] == 0 // 0.0.0.0/8 (routes to localhost on Linux) - || is_shared_nat(v4) // 100.64.0.0/10 CGNAT - || is_ietf_protocol_assignment(v4) // 192.0.0.0/24 - || is_reserved(v4) // 240.0.0.0/4 - || is_benchmarking(v4) // 198.18.0.0/15 - } - IpAddr::V6(v6) => { - v6.is_loopback() - || v6.is_unspecified() - || is_unique_local(v6) // fc00::/7 - || is_ipv6_link_local(v6) // fe80::/10 - || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0xdb8) // 2001:db8::/32 documentation - || v6.is_multicast() - || v6 - .to_ipv4_mapped() - .is_some_and(|v4| is_disallowed_ip(IpAddr::V4(v4))) - } + data.extend_from_slice(&chunk); } -} - -/// Denies ipv4 like `100.64.0.0/10` -fn is_shared_nat(v4: Ipv4Addr) -> bool { - let o = v4.octets(); - o[0] == 100 && (o[1] & 0b1100_0000) == 0b0100_0000 -} - -fn is_ietf_protocol_assignment(v4: Ipv4Addr) -> bool { - v4.octets()[0] == 192 && v4.octets()[1] == 0 && v4.octets()[2] == 0 -} - -fn is_reserved(v4: Ipv4Addr) -> bool { - (v4.octets()[0] & 0xf0) == 240 -} -fn is_benchmarking(v4: Ipv4Addr) -> bool { - v4.octets()[0] == 198 && (v4.octets()[1] & 0xfe) == 18 -} - -/// Denies ipv6 like `fc00::/7` -fn is_unique_local(v6: Ipv6Addr) -> bool { - (v6.segments()[0] & 0xfe00) == 0xfc00 -} - -/// Denies ipv6 like `fe80::/10` -fn is_ipv6_link_local(v6: Ipv6Addr) -> bool { - (v6.segments()[0] & 0xffc0) == 0xfe80 + Ok(Bytes::from(data)) } fn allowed_scheme(url: &Url) -> bool { matches!(url.scheme(), "http" | "https") } - -fn ends_with_label(host: &str, suffix_with_dot: &str) -> bool { - let label = &suffix_with_dot[1..]; - host == label || host.ends_with(suffix_with_dot) -} - -fn is_denied_host(host: &str) -> bool { - DOWNLOAD_DENYLIST_DOMAINS.contains(&host) || DOWNLOAD_DENYLIST_TLDS.iter().any(|&i| ends_with_label(host, i)) -} - -fn validate_download_url(url: &Url) -> Result, ModZipError> { - // First, validate the domain - if !allowed_scheme(url) { - return Err(ModZipError::InvalidModFileUrl); - } - - let domain = url.domain().ok_or(ModZipError::InvalidModFileUrl)?; - - if is_denied_host(domain) { - return Err(ModZipError::InvalidModFileUrl); - } - - // Now resolve and validate the IP itself to make sure - // the DNS isn't pointing to something bad - let host = url - .host_str() - .ok_or(ModZipError::InvalidModFileUrl) - .inspect_err(|_| tracing::warn!("host_str() returned None - very weird!"))?; - let port = url.port_or_known_default().unwrap_or(443); - - let addrs: Vec = (host, port) - .to_socket_addrs() - .map_err(|_| ModZipError::InvalidModFileUrl)? - .map(|s| s.ip()) - .collect(); - - if addrs.is_empty() { - tracing::warn!("{host}:{port} failed DNS resolution"); - return Err(ModZipError::InvalidModFileUrl); - } - - if let Some(ip) = addrs.iter().find(|&ip| is_disallowed_ip(*ip)) { - tracing::warn!("{host}:{port} resolved to disallowed ip {ip}"); - return Err(ModZipError::InvalidModFileUrl); - } - - Ok(addrs) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::net::IpAddr; - - #[test] - fn rejects_non_http_schemes() { - assert!(!allowed_scheme(&Url::parse("file:///etc/passwd").unwrap())); - assert!(!allowed_scheme(&Url::parse("ftp://example.com/x").unwrap())); - - assert!(allowed_scheme(&Url::parse("https://example.com").unwrap())); - assert!(allowed_scheme(&Url::parse("http://example.com").unwrap())); - } - - #[test] - fn exact_domain_match() { - assert!(is_denied_host("localhost")); - } - - #[test] - fn exact_domain_is_case_sensitive_by_design() { - // url::Url normalizes host to lowercase during parsing, so this - // function assumes lowercase input. Document that assumption here. - assert!(!is_denied_host("LOCALHOST")); - } - - #[test] - fn tld_exact_match() { - // host == the bare suffix itself, no subdomain - assert!(is_denied_host("internal")); - assert!(is_denied_host("local")); - } - - #[test] - fn tld_subdomain_match() { - assert!(is_denied_host("foo.internal")); - assert!(is_denied_host("service.lan")); - assert!(is_denied_host("printer.local")); - assert!(is_denied_host("db.host")); - assert!(is_denied_host("deep.nested.sub.internal")); - } - - #[test] - fn allows_unrelated_domains() { - assert!(!is_denied_host("example.com")); - assert!(!is_denied_host("github.com")); - assert!(!is_denied_host("sub.example.com")); - } - - #[test] - fn rejects_ip_literal_hosts() { - assert!(validate_download_url(&Url::parse("http://127.0.0.1/x").unwrap()).is_err()); - assert!(validate_download_url(&Url::parse("http://[::1]/download").unwrap()).is_err()); - } - - #[test] - fn flags_private_and_special_ranges() { - let cases: &[(&str, bool)] = &[ - ("127.0.0.1", true), - ("10.0.0.5", true), - ("172.16.0.1", true), - ("192.168.1.1", true), - ("169.254.169.254", true), // cloud metadata - ("100.64.0.1", true), // CGNAT - ("8.8.8.8", false), - ("1.1.1.1", false), - ]; - for (ip, expected) in cases { - let addr: IpAddr = ip.parse().unwrap(); - assert_eq!(is_disallowed_ip(addr), *expected, "failed for {ip}"); - } - } - - #[test] - fn flags_ipv6_special_ranges() { - assert!(is_disallowed_ip("::1".parse().unwrap())); - assert!(is_disallowed_ip("fc00::1".parse().unwrap())); - assert!(is_disallowed_ip("fe80::1".parse().unwrap())); - assert!(!is_disallowed_ip("2606:4700:4700::1111".parse().unwrap())); // public - } -} diff --git a/src/pin_dns.rs b/src/pin_dns.rs deleted file mode 100644 index 15e0640..0000000 --- a/src/pin_dns.rs +++ /dev/null @@ -1,47 +0,0 @@ -use std::net::{SocketAddr, ToSocketAddrs}; - -use reqwest::dns::{Name, Resolve, Resolving}; - -tokio::task_local! { - /// Used to pin IP addresses for the resolver. - /// - /// # Example (assumes you have an http client that uses PinDnsResolver) - /// - /// ```rust - /// // Let's assume addrs are addresses we confirmed are safe to call. - /// let addrs = vec![std::net::SocketAddr::new("127.0.0.1", port)]; - /// - /// let response = PINNED_ADDRS.scope(addrs, async { - /// http_client.get(url).send().await - /// }).await?; - /// ``` - /// - /// Make sure to only run **the one request you need** inside the callback. - /// This design is a little brittle, but works for our purposes. - pub static PINNED_ADDRS: Vec; -} - -#[derive(Clone, Default)] -pub struct PinDnsResolver; - -impl Resolve for PinDnsResolver { - fn resolve(&self, name: Name) -> Resolving { - Box::pin(async move { - tracing::debug!("resolving {} with PinDnsResolver", name.as_str()); - - if let Ok(addrs) = PINNED_ADDRS.try_with(|c| c.clone()) { - tracing::debug!("pinned {} to {:?} successfully", name.as_str(), addrs); - return Ok( - Box::new(addrs.into_iter()) as Box + Send> - ); - } - - tracing::debug!("didn't have a PINNED_ADDRS, falling back to OS resolver"); - // Fallback to the usual OS resolver otherwise - (name.as_str(), 0) - .to_socket_addrs() - .map(|it| Box::new(it) as Box + Send>) - .map_err(|e| e.into()) - }) - } -} diff --git a/src/s3_worker.rs b/src/s3_worker.rs index 764b343..7a34963 100644 --- a/src/s3_worker.rs +++ b/src/s3_worker.rs @@ -111,7 +111,7 @@ async fn migrate_one( version_id: i32, ) -> anyhow::Result<()> { let bytes = mod_zip::download_mod( - data.pin_dns_http_client(), + data.check_dns_http_client(), original_url, data.max_download_mb(), )