From 688c05adf2783a4d434d73438d986053a9b85dd1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:28:20 +0000 Subject: [PATCH] fix(security): limit info.json decompression size to prevent zip bomb DoS Co-authored-by: TheDragonSoft <75906592+TheDragonSoft@users.noreply.github.com> --- src-tauri/src/core/services/downloader.rs | 4 +-- src-tauri/src/core/services/mod_store.rs | 33 ++++++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/core/services/downloader.rs b/src-tauri/src/core/services/downloader.rs index fc8b739..f3602a9 100644 --- a/src-tauri/src/core/services/downloader.rs +++ b/src-tauri/src/core/services/downloader.rs @@ -11,6 +11,7 @@ use tauri::{AppHandle, Emitter}; use tokio::io::AsyncWriteExt; use tokio::sync::Semaphore; +use crate::core::services::mod_store::read_entry_bounded; use crate::core::services::portal_client::{encode_path_component, plausible_name}; use crate::error::AppError; use crate::models::{InstalledChangedPayload, InstalledChangedReason}; @@ -458,8 +459,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 = read_entry_bounded(&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)); diff --git a/src-tauri/src/core/services/mod_store.rs b/src-tauri/src/core/services/mod_store.rs index 340fee3..8a68728 100644 --- a/src-tauri/src/core/services/mod_store.rs +++ b/src-tauri/src/core/services/mod_store.rs @@ -18,6 +18,22 @@ use crate::models::{ const MOD_LIST_FILE: &str = "mod-list.json"; +/// Maximum allowable decompressed size for an info.json entry (2 MiB). +/// Protects against zip bomb / memory exhaustion DoS attacks during archive reading. +pub const MAX_INFO_JSON_SIZE: u64 = 2 * 1024 * 1024; + +/// Read up to `MAX_INFO_JSON_SIZE` bytes from a zip entry into a String. +pub fn read_entry_bounded(mut entry: R) -> Result { + let mut s = String::new(); + entry.by_ref().take(MAX_INFO_JSON_SIZE + 1).read_to_string(&mut s)?; + if s.len() > MAX_INFO_JSON_SIZE as usize { + return Err(AppError::Parse(format!( + "info.json exceeds maximum size limit of {MAX_INFO_JSON_SIZE} bytes" + ))); + } + Ok(s) +} + // --------------------------------------------------------------------------- // Directory auto-detection (Phase 3) // --------------------------------------------------------------------------- @@ -238,15 +254,13 @@ 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)?; + let s = read_entry_bounded(&mut entry)?; 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)?; + let s = read_entry_bounded(&mut entry)?; nested = Some(s); } } @@ -1755,4 +1769,15 @@ mod tests { let _ = fs::remove_dir_all(&temp_dir); } + + #[test] + fn test_oversized_info_json_rejected() { + let huge_data = vec![b'a'; (MAX_INFO_JSON_SIZE + 100) as usize]; + let cursor = std::io::Cursor::new(huge_data); + let err = read_entry_bounded(cursor).unwrap_err(); + assert!( + err.to_string().contains("exceeds maximum size limit"), + "expected size limit error, got: {err}" + ); + } }