diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..9212a37 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2026-11-04 - Unbounded Archive Entry Decompression (Zip Bomb / OOM DoS) +**Vulnerability:** Untrusted mod zips scanned or downloaded into the application had their `info.json` entries decompressed into memory via `Read::read_to_string` without an upper bound on decompressed byte length. A malicious or malformed zip entry (zip bomb) could decompress into gigabytes of data, exhausting system memory and causing an Out-Of-Memory (OOM) denial-of-service crash. +**Learning:** Archive inspection routines often parse metadata entries before any full file extraction. While the compressed archive itself may be tiny, decompressing directly into a heap-allocated buffer like `String` without stream-level limits exposes the process to decompression bombs. +**Prevention:** Always cap decompressed stream reads from untrusted archive entries using `.take(MAX_BYTES + 1)` before calling `read_to_string`, and reject entries that exceed the maximum expected threshold. diff --git a/src-tauri/src/core/services/downloader.rs b/src-tauri/src/core/services/downloader.rs index fc8b739..5652583 100644 --- a/src-tauri/src/core/services/downloader.rs +++ b/src-tauri/src/core/services/downloader.rs @@ -458,8 +458,7 @@ fn verify_mod_zip_sync(path: &Path, expected_name: &str, expected_version: &str) || entry_name.ends_with("\\info.json"); 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) + let s = crate::core::services::mod_store::read_entry_to_string_capped(&mut entry) .map_err(|e| AppError::Parse(format!("could not read info.json: {e}")))?; let is_root = name == "info.json"; info_json = Some((name, s)); @@ -806,6 +805,27 @@ mod tests { assert!(f3.exists(), "extended mod name with space must NOT be deleted"); } + #[test] + fn verify_mod_zip_sync_rejects_oversized_info_json() { + use std::io::Write as _; + let dir = TempDir::new("bomb"); + let f = dir.path("mod.zip"); + + let file = std::fs::File::create(&f).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file( + "info.json", + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated), + ) + .unwrap(); + let big = vec![b' '; (crate::core::services::mod_store::MAX_INFO_JSON_BYTES + 1024) as usize]; + zip.write_all(&big).unwrap(); + zip.finish().unwrap(); + + let err = verify_mod_zip_sync(&f, "mod", "1.0.0").unwrap_err(); + assert!(err.to_string().contains("exceeds maximum size limit")); + } + #[test] fn verify_mod_zip_sync_ignores_auxiliary_info_json_files() { use std::io::Write as _; diff --git a/src-tauri/src/core/services/mod_store.rs b/src-tauri/src/core/services/mod_store.rs index 340fee3..d7d8d54 100644 --- a/src-tauri/src/core/services/mod_store.rs +++ b/src-tauri/src/core/services/mod_store.rs @@ -213,6 +213,24 @@ pub fn resolve_detection_status(config: &Config) -> DetectionStatus { // info.json reading (Phase 6) // --------------------------------------------------------------------------- +/// Maximum allowed size for an uncompressed info.json (1 MiB is far larger than +/// any legitimate Factorio mod metadata while protecting against zip bomb DoS). +pub const MAX_INFO_JSON_BYTES: u64 = 1024 * 1024; + +/// Read an archive entry into a String, capped at `MAX_INFO_JSON_BYTES` to prevent +/// decompressed stream zip bombs from exhausting system memory. +pub fn read_entry_to_string_capped(reader: &mut R) -> Result { + let mut s = String::new(); + reader.take(MAX_INFO_JSON_BYTES + 1).read_to_string(&mut s)?; + if s.len() > MAX_INFO_JSON_BYTES as usize { + return Err(AppError::Parse( + "info.json exceeds maximum size limit (1 MiB)".into(), + )); + } + Ok(s) +} + +#[derive(Debug)] pub struct InfoJson { pub name: String, pub version: String, @@ -238,16 +256,12 @@ 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)?; - raw = Some(s); + raw = Some(read_entry_to_string_capped(&mut entry)?); 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)?; - nested = Some(s); + nested = Some(read_entry_to_string_capped(&mut entry)?); } } let raw = raw @@ -1755,4 +1769,33 @@ mod tests { let _ = fs::remove_dir_all(&temp_dir); } + + #[test] + fn oversized_info_json_is_rejected_as_zip_bomb() { + 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("bomb_1.0.0.zip"); + + // Write a zip entry with compressed zeros that expands to > 1 MiB (1.5 MiB) + let file = fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file( + "info.json", + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated), + ) + .unwrap(); + let big_payload = vec![b' '; (MAX_INFO_JSON_BYTES + 1024) as usize]; + zip.write_all(&big_payload).unwrap(); + zip.finish().unwrap(); + + let err = read_info_json(&path).unwrap_err(); + match err { + AppError::Parse(msg) => { + assert!(msg.contains("exceeds maximum size limit"), "expected size limit error, got: {msg}"); + } + other => panic!("expected AppError::Parse, got {other:?}"), + } + + let _ = fs::remove_dir_all(&temp_dir); + } }