From 434e444713d5a8902e7f569efe75997ceb225ae9 Mon Sep 17 00:00:00 2001 From: slideglide Date: Wed, 22 Jul 2026 15:11:06 +0300 Subject: [PATCH 1/3] fix: resolve SSRF patch bugs causing mod upload failures and protect against decompression bombs - Disabled reqwest automatic redirects so the manual redirect loop works. - Fetch current_url instead of the original url in the redirect loop. - Support multiple IPs per domain in PINNED_ADDRS to prevent failures. - Added additional disallowed IP ranges (0.0.0.0/8, 240.0.0.0/4, etc.) to prevent bypasses. - Separated PngDecoder initialization from memory allocation to prevent decompression bombs by checking dimensions before decoding the full image into memory. --- src/config.rs | 3 +- src/mod_zip.rs | 205 +++++++++++++++++++---------- src/pin_dns.rs | 18 +-- src/types/models/mod_gd_version.rs | 8 +- 4 files changed, 151 insertions(+), 83 deletions(-) diff --git a/src/config.rs b/src/config.rs index be0d09d..c1c208e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -91,6 +91,7 @@ pub async fn build_config() -> anyhow::Result { .pool_max_idle_per_host(4) .connect_timeout(Duration::from_secs(10)) .read_timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) .build()?; Ok(AppData { @@ -206,7 +207,7 @@ impl AppData { /// Client that allows pinning DNS queries to a certain ip address. /// Useful for preventing Server Side Request Forgery. /// - /// To pin an address, you have to use pin_dns::PINNED_ADDR. + /// 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. /// diff --git a/src/mod_zip.rs b/src/mod_zip.rs index 3989b82..a89fcf9 100644 --- a/src/mod_zip.rs +++ b/src/mod_zip.rs @@ -1,7 +1,7 @@ -use std::cell::Cell; 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,12 +13,15 @@ use zip::ZipArchive; use zip::read::ZipFile; use zip::result::ZipError; -use crate::pin_dns::PINNED_ADDR; +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 REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const TOTAL_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); + #[derive(thiserror::Error, Debug)] pub enum ModZipError { #[error("I/O error: {0}")] @@ -49,6 +52,8 @@ pub enum ModZipError { InvalidModJson(String), #[error("Invalid binaries: {0}")] InvalidBinaries(String), + #[error("Timed out downloading .geode file")] + DownloadTimedOut, } pub fn extract_mod_logo(file: &mut ZipFile) -> Result, ModZipError> { @@ -65,11 +70,18 @@ pub fn extract_mod_logo(file: &mut ZipFile) -> Result, ModZi let mut reader = BufReader::new(Cursor::new(logo)); - let mut img = PngDecoder::new(&mut reader) - .and_then(DynamicImage::from_decoder) - .inspect_err(|e| tracing::error!("Failed to create PngDecoder: {}", e))?; + let decoder = PngDecoder::new(&mut reader) + .inspect_err(|e| tracing::error!("Failed to create PngDecoder: {}", e)) + .map_err(|e| ModZipError::ImageError(e))?; - let dimensions = img.dimensions(); + let dimensions = image::ImageDecoder::dimensions(&decoder); + + if (dimensions.0 > 1024) || (dimensions.1 > 1024) { + return Err(ModZipError::InvalidLogo(format!( + "Mod logo dimensions too large ({}x{}). Maximum allowed is 1024x1024.", + dimensions.0, dimensions.1 + ))); + } if dimensions.0 != dimensions.1 { return Err(ModZipError::InvalidLogo(format!( @@ -78,6 +90,9 @@ pub fn extract_mod_logo(file: &mut ZipFile) -> Result, ModZi ))); } + let mut img = DynamicImage::from_decoder(decoder) + .inspect_err(|e| tracing::error!("Failed to decode image: {}", e))?; + if (dimensions.0 > 336) || (dimensions.1 > 336) { img = img.resize(336, 336, image::imageops::FilterType::Lanczos3); } @@ -118,11 +133,20 @@ pub fn validate_mod_logo(file: &mut ZipFile) -> Result<(), ModZipErr let mut reader = BufReader::new(Cursor::new(logo)); - let img = PngDecoder::new(&mut reader) - .and_then(DynamicImage::from_decoder) + let decoder = PngDecoder::new(&mut reader) .inspect_err(|e| tracing::error!("Failed to create PngDecoder: {}", e))?; - let dimensions = img.dimensions(); + let dimensions = image::ImageDecoder::dimensions(&decoder); + + if (dimensions.0 > 1024) || (dimensions.1 > 1024) { + return Err(ModZipError::InvalidLogo(format!( + "Mod logo dimensions too large ({}x{}). Maximum allowed is 1024x1024.", + dimensions.0, dimensions.1 + ))); + } + + let _img = DynamicImage::from_decoder(decoder) + .inspect_err(|e| tracing::error!("Failed to decode image: {}", e))?; if dimensions.0 != dimensions.1 { Err(ModZipError::InvalidLogo(format!( @@ -177,65 +201,82 @@ async fn download( 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 addr = std::net::SocketAddr::new(addrs[0], port); - tracing::debug!("DNS validated as {addr}"); - - // Pin the validated ip address in our cool custom resolver - let response = PINNED_ADDR.scope(Cell::new(Some(addr)), async { - http_client.get(url) - .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; - } + tokio::time::timeout(TOTAL_DOWNLOAD_TIMEOUT, async { + 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(REQUEST_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; + } - let mut response = response - .error_for_status() - .inspect_err(|e| tracing::error!("Failed to fetch .geode file: {e}"))?; + let mut response = response + .error_for_status() + .inspect_err(|e| tracing::error!("Failed to fetch .geode file: {e}"))?; - // 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); + // 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); - if content_length > limit_bytes { - let len_mb = content_length / 1_000_000; - return Err(ModZipError::ModFileTooLarge(len_mb, limit_mb.into())); - } + if content_length > limit_bytes { + let len_mb = content_length / 1_000_000; + return Err(ModZipError::ModFileTooLarge(len_mb, limit_mb.into())); + } - let mut data: Vec = Vec::with_capacity(content_length as usize); + let mut data: Vec = Vec::with_capacity(content_length as usize); - let mut streamed: u64 = 0; - while let Some(chunk) = response.chunk().await? { - streamed += chunk.len() as u64; + let mut streamed: u64 = 0; + loop { + let chunk = response.chunk().await?; - if streamed > limit_bytes { - let len_mb = streamed / 1_000_000; - return Err(ModZipError::ModFileTooLarge(len_mb, limit_mb.into())); + let Some(chunk) = chunk else { + break; + }; + + streamed += chunk.len() as u64; + + if streamed > limit_bytes { + let len_mb = streamed / 1_000_000; + return Err(ModZipError::ModFileTooLarge(len_mb, limit_mb.into())); + } + + data.extend_from_slice(&chunk); } - data.extend_from_slice(&chunk); + return Ok(Bytes::from(data)); } - return Ok(Bytes::from(data)); - } - - Err(ModZipError::TooManyRedirects) + Err(ModZipError::TooManyRedirects) + }) + .await + .unwrap_or(Err(ModZipError::DownloadTimedOut)) } /// Hopefully this gets rid of all nasty ips @@ -243,21 +284,28 @@ 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() - || is_shared_nat(v4) // 100.64.0.0/10 CGNAT + || 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 - .to_ipv4_mapped() - .is_some_and(|v4| is_disallowed_ip(IpAddr::V4(v4))) + || 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))) } } } @@ -268,6 +316,18 @@ fn is_shared_nat(v4: Ipv4Addr) -> bool { 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 @@ -288,7 +348,10 @@ fn ends_with_label(host: &str, suffix_with_dot: &str) -> bool { } fn is_denied_host(host: &str) -> bool { - DOWNLOAD_DENYLIST_DOMAINS.contains(&host) || DOWNLOAD_DENYLIST_TLDS.iter().any(|&i| ends_with_label(host, i)) + DOWNLOAD_DENYLIST_DOMAINS.contains(&host) + || DOWNLOAD_DENYLIST_TLDS + .iter() + .any(|&i| ends_with_label(host, i)) } fn validate_download_url(url: &Url) -> Result, ModZipError> { diff --git a/src/pin_dns.rs b/src/pin_dns.rs index b4d1fba..15e0640 100644 --- a/src/pin_dns.rs +++ b/src/pin_dns.rs @@ -3,22 +3,22 @@ use std::net::{SocketAddr, ToSocketAddrs}; use reqwest::dns::{Name, Resolve, Resolving}; tokio::task_local! { - /// Used to pin an IP address for the resolver. + /// Used to pin IP addresses for the resolver. /// /// # Example (assumes you have an http client that uses PinDnsResolver) /// /// ```rust - /// // Let's assume addr is an address we confirmed is safe to call. - /// let addr = std::net::SocketAddr::new("127.0.0.1", port); + /// // 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_ADDR.scope(Cell::new(Some(addr)), async { + /// 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_ADDR: std::cell::Cell>; + pub static PINNED_ADDRS: Vec; } #[derive(Clone, Default)] @@ -29,14 +29,14 @@ impl Resolve for PinDnsResolver { Box::pin(async move { tracing::debug!("resolving {} with PinDnsResolver", name.as_str()); - if let Ok(Some(addr)) = PINNED_ADDR.try_with(|c| c.get()) { - tracing::debug!("pinned {} to {addr} successfully", 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(std::iter::once(addr)) as Box + Send> + Box::new(addrs.into_iter()) as Box + Send> ); } - tracing::debug!("didn't have a PINNED_ADDR, falling back to OS resolver"); + 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() diff --git a/src/types/models/mod_gd_version.rs b/src/types/models/mod_gd_version.rs index 92f1723..473156a 100644 --- a/src/types/models/mod_gd_version.rs +++ b/src/types/models/mod_gd_version.rs @@ -221,7 +221,9 @@ impl DetailedGDVersion { }) } } - if let Some(win) = self.win && json.windows { + if let Some(win) = self.win + && json.windows + { ret.push(ModGDVersionCreate { gd: win, platform: VerPlatform::Win, @@ -241,7 +243,9 @@ impl DetailedGDVersion { }) } } - if let Some(ios) = self.ios && json.ios { + if let Some(ios) = self.ios + && json.ios + { ret.push(ModGDVersionCreate { gd: ios, platform: VerPlatform::Ios, From a4ee4dcdd73e74d594255bf6a7ebfeb002c8395e Mon Sep 17 00:00:00 2001 From: slideglide Date: Wed, 22 Jul 2026 19:28:11 +0300 Subject: [PATCH 2/3] fix: ugly formatting --- src/mod_zip.rs | 31 ++++++++++++++---------------- src/types/models/mod_gd_version.rs | 8 ++------ 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/src/mod_zip.rs b/src/mod_zip.rs index a89fcf9..8525f88 100644 --- a/src/mod_zip.rs +++ b/src/mod_zip.rs @@ -284,17 +284,17 @@ 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 + || 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() @@ -304,8 +304,8 @@ fn is_disallowed_ip(ip: IpAddr) -> bool { || (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))) + .to_ipv4_mapped() + .is_some_and(|v4| is_disallowed_ip(IpAddr::V4(v4))) } } } @@ -348,10 +348,7 @@ fn ends_with_label(host: &str, suffix_with_dot: &str) -> bool { } fn is_denied_host(host: &str) -> bool { - DOWNLOAD_DENYLIST_DOMAINS.contains(&host) - || DOWNLOAD_DENYLIST_TLDS - .iter() - .any(|&i| ends_with_label(host, i)) + DOWNLOAD_DENYLIST_DOMAINS.contains(&host) || DOWNLOAD_DENYLIST_TLDS.iter().any(|&i| ends_with_label(host, i)) } fn validate_download_url(url: &Url) -> Result, ModZipError> { diff --git a/src/types/models/mod_gd_version.rs b/src/types/models/mod_gd_version.rs index 473156a..92f1723 100644 --- a/src/types/models/mod_gd_version.rs +++ b/src/types/models/mod_gd_version.rs @@ -221,9 +221,7 @@ impl DetailedGDVersion { }) } } - if let Some(win) = self.win - && json.windows - { + if let Some(win) = self.win && json.windows { ret.push(ModGDVersionCreate { gd: win, platform: VerPlatform::Win, @@ -243,9 +241,7 @@ impl DetailedGDVersion { }) } } - if let Some(ios) = self.ios - && json.ios - { + if let Some(ios) = self.ios && json.ios { ret.push(ModGDVersionCreate { gd: ios, platform: VerPlatform::Ios, From efe92544f96b87ecb58c9461312b9834fdb7af29 Mon Sep 17 00:00:00 2001 From: slideglide Date: Wed, 22 Jul 2026 22:58:41 +0300 Subject: [PATCH 3/3] fix: improve code quality --- src/mod_zip.rs | 178 +++++++++++++++++++++++-------------------------- 1 file changed, 84 insertions(+), 94 deletions(-) diff --git a/src/mod_zip.rs b/src/mod_zip.rs index 8525f88..e2610e6 100644 --- a/src/mod_zip.rs +++ b/src/mod_zip.rs @@ -19,7 +19,6 @@ const DOWNLOAD_DENYLIST_DOMAINS: [&str; 1] = ["localhost"]; const DOWNLOAD_DENYLIST_TLDS: [&str; 4] = [".host", ".lan", ".local", ".internal"]; const MAX_REDIRECTS: u8 = 10; -const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const TOTAL_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); #[derive(thiserror::Error, Debug)] @@ -52,8 +51,6 @@ pub enum ModZipError { InvalidModJson(String), #[error("Invalid binaries: {0}")] InvalidBinaries(String), - #[error("Timed out downloading .geode file")] - DownloadTimedOut, } pub fn extract_mod_logo(file: &mut ZipFile) -> Result, ModZipError> { @@ -70,18 +67,19 @@ pub fn extract_mod_logo(file: &mut ZipFile) -> Result, ModZi let mut reader = BufReader::new(Cursor::new(logo)); - let decoder = PngDecoder::new(&mut reader) - .inspect_err(|e| tracing::error!("Failed to create PngDecoder: {}", e)) - .map_err(|e| ModZipError::ImageError(e))?; + 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 + }, + ) + .and_then(DynamicImage::from_decoder) + .inspect_err(|e| tracing::error!("Failed to create PngDecoder: {}", e))?; - let dimensions = image::ImageDecoder::dimensions(&decoder); - - if (dimensions.0 > 1024) || (dimensions.1 > 1024) { - return Err(ModZipError::InvalidLogo(format!( - "Mod logo dimensions too large ({}x{}). Maximum allowed is 1024x1024.", - dimensions.0, dimensions.1 - ))); - } + let dimensions = img.dimensions(); if dimensions.0 != dimensions.1 { return Err(ModZipError::InvalidLogo(format!( @@ -90,9 +88,6 @@ pub fn extract_mod_logo(file: &mut ZipFile) -> Result, ModZi ))); } - let mut img = DynamicImage::from_decoder(decoder) - .inspect_err(|e| tracing::error!("Failed to decode image: {}", e))?; - if (dimensions.0 > 336) || (dimensions.1 > 336) { img = img.resize(336, 336, image::imageops::FilterType::Lanczos3); } @@ -133,20 +128,19 @@ pub fn validate_mod_logo(file: &mut ZipFile) -> Result<(), ModZipErr let mut reader = BufReader::new(Cursor::new(logo)); - let decoder = PngDecoder::new(&mut reader) - .inspect_err(|e| tracing::error!("Failed to create PngDecoder: {}", e))?; + 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))?; - let dimensions = image::ImageDecoder::dimensions(&decoder); - - if (dimensions.0 > 1024) || (dimensions.1 > 1024) { - return Err(ModZipError::InvalidLogo(format!( - "Mod logo dimensions too large ({}x{}). Maximum allowed is 1024x1024.", - dimensions.0, dimensions.1 - ))); - } - - let _img = DynamicImage::from_decoder(decoder) - .inspect_err(|e| tracing::error!("Failed to decode image: {}", e))?; + let dimensions = img.dimensions(); if dimensions.0 != dimensions.1 { Err(ModZipError::InvalidLogo(format!( @@ -201,82 +195,78 @@ async fn download( let limit_bytes: u64 = limit_mb as u64 * 1_000_000; - tokio::time::timeout(TOTAL_DOWNLOAD_TIMEOUT, async { - 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(REQUEST_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; - } - - let mut response = response - .error_for_status() - .inspect_err(|e| tracing::error!("Failed to fetch .geode file: {e}"))?; + 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; + } - // 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 response = response + .error_for_status() + .inspect_err(|e| tracing::error!("Failed to fetch .geode file: {e}"))?; - if content_length > limit_bytes { - let len_mb = content_length / 1_000_000; - return Err(ModZipError::ModFileTooLarge(len_mb, limit_mb.into())); - } + // 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 data: Vec = Vec::with_capacity(content_length as usize); + if content_length > limit_bytes { + let len_mb = content_length / 1_000_000; + return Err(ModZipError::ModFileTooLarge(len_mb, limit_mb.into())); + } - let mut streamed: u64 = 0; - loop { - let chunk = response.chunk().await?; + let mut data: Vec = Vec::with_capacity(content_length as usize); - let Some(chunk) = chunk else { - break; - }; + let mut streamed: u64 = 0; + loop { + let chunk = response.chunk().await?; - streamed += chunk.len() as u64; + let Some(chunk) = chunk else { + break; + }; - if streamed > limit_bytes { - let len_mb = streamed / 1_000_000; - return Err(ModZipError::ModFileTooLarge(len_mb, limit_mb.into())); - } + 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)); + data.extend_from_slice(&chunk); } - Err(ModZipError::TooManyRedirects) - }) - .await - .unwrap_or(Err(ModZipError::DownloadTimedOut)) + return Ok(Bytes::from(data)); + } + + Err(ModZipError::TooManyRedirects) } /// Hopefully this gets rid of all nasty ips