diff --git a/src-tauri/src/core/services/downloader.rs b/src-tauri/src/core/services/downloader.rs index fc8b739..da04443 100644 --- a/src-tauri/src/core/services/downloader.rs +++ b/src-tauri/src/core/services/downloader.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -21,6 +22,8 @@ const MAX_CONCURRENT_DOWNLOADS: usize = 3; const PROGRESS_INTERVAL: Duration = Duration::from_millis(120); /// Automatic retries for transient failures (network blips, 429, 5xx). const MAX_ATTEMPTS: u32 = 3; +/// Maximum allowed decompressed size for `info.json` (10 MB) to protect against zip bombs / DoS. +const MAX_INFO_JSON_SIZE: u64 = 10 * 1024 * 1024; /// Event payload for "download-updated" — mirrors the frontend QueueItem. #[derive(Debug, Clone, Serialize)] @@ -459,7 +462,8 @@ fn verify_mod_zip_sync(path: &Path, expected_name: &str, expected_version: &str) if !entry.is_dir() && is_info_json { let name = entry_name.to_string(); let mut s = String::new(); - std::io::Read::read_to_string(&mut entry, &mut s) + // SECURITY: Bound decompressed size with take() to prevent zip bombs/unbounded memory allocation. + entry.take(MAX_INFO_JSON_SIZE).read_to_string(&mut s) .map_err(|e| AppError::Parse(format!("could not read info.json: {e}")))?; let is_root = name == "info.json"; info_json = Some((name, s)); diff --git a/src-tauri/src/core/services/mod_store.rs b/src-tauri/src/core/services/mod_store.rs index 340fee3..224d405 100644 --- a/src-tauri/src/core/services/mod_store.rs +++ b/src-tauri/src/core/services/mod_store.rs @@ -18,6 +18,9 @@ use crate::models::{ const MOD_LIST_FILE: &str = "mod-list.json"; +/// Maximum allowed decompressed size for `info.json` (10 MB) to protect against zip bombs / DoS. +const MAX_INFO_JSON_SIZE: u64 = 10 * 1024 * 1024; + // --------------------------------------------------------------------------- // Directory auto-detection (Phase 3) // --------------------------------------------------------------------------- @@ -230,7 +233,7 @@ pub fn read_info_json(path: &Path) -> Result { let mut raw: Option = None; let mut nested: Option = None; for i in 0..archive.len() { - let mut entry = archive + let entry = archive .by_index(i) .map_err(|e| AppError::Parse(format!("zip read error: {e}")))?; if entry.is_dir() { @@ -239,14 +242,16 @@ pub fn read_info_json(path: &Path) -> Result { let entry_name = entry.name().to_string(); if entry_name == "info.json" || entry_name == "./info.json" { let mut s = String::new(); - entry.read_to_string(&mut s)?; + // SECURITY: Bound decompressed size with take() to prevent zip bombs/unbounded memory allocation. + entry.take(MAX_INFO_JSON_SIZE).read_to_string(&mut s)?; raw = Some(s); break; } else if nested.is_none() && (entry_name.ends_with("/info.json") || entry_name.ends_with("\\info.json")) { let mut s = String::new(); - entry.read_to_string(&mut s)?; + // SECURITY: Bound decompressed size with take() to prevent zip bombs/unbounded memory allocation. + entry.take(MAX_INFO_JSON_SIZE).read_to_string(&mut s)?; nested = Some(s); } } @@ -1755,4 +1760,30 @@ mod tests { let _ = fs::remove_dir_all(&temp_dir); } + + #[test] + fn test_oversized_info_json_is_truncated_safely() { + let temp_dir = std::env::temp_dir().join(format!("axial-zipbomb-test-{}", std::process::id())); + let _ = fs::create_dir_all(&temp_dir); + + let path = temp_dir.join("oversized_1.0.0.zip"); + let file = fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("info.json", zip::write::SimpleFileOptions::default()).unwrap(); + + // Write a valid json prefix followed by huge padding that exceeds MAX_INFO_JSON_SIZE + let prefix = b"{\"name\":\"oversized\",\"version\":\"1.0.0\","; + zip.write_all(prefix).unwrap(); + // Write padding bytes (e.g. 11 MB of spaces) + let padding = vec![b' '; (MAX_INFO_JSON_SIZE + 1024) as usize]; + zip.write_all(&padding).unwrap(); + zip.write_all(b"}").unwrap(); + zip.finish().unwrap(); + + // Attempting to read_info_json should truncate at MAX_INFO_JSON_SIZE and fail to parse as JSON cleanly + // rather than consuming infinite memory. + assert!(read_info_json(&path).is_err()); + + let _ = fs::remove_dir_all(&temp_dir); + } }