Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src-tauri/src/core/services/downloader.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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)]
Expand Down Expand Up @@ -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));
Expand Down
37 changes: 34 additions & 3 deletions src-tauri/src/core/services/mod_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -230,7 +233,7 @@ pub fn read_info_json(path: &Path) -> Result<InfoJson, AppError> {
let mut raw: Option<String> = None;
let mut nested: Option<String> = 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() {
Expand All @@ -239,14 +242,16 @@ pub fn read_info_json(path: &Path) -> Result<InfoJson, AppError> {
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);
}
}
Expand Down Expand Up @@ -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);
}
}
Loading