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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 22 additions & 2 deletions src-tauri/src/core/services/downloader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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 _;
Expand Down
55 changes: 49 additions & 6 deletions src-tauri/src/core/services/mod_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<R: Read>(reader: &mut R) -> Result<String, AppError> {
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,
Expand All @@ -238,16 +256,12 @@ 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)?;
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
Expand Down Expand Up @@ -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);
}
}
Loading