From 00b73d4f39acc06abb5b5b729c7705a1946477e8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:44:18 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20Add=20canon?= =?UTF-8?q?ical=20path=20validation=20to=20mod=20file=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonicalize target mod file paths and verify that they reside strictly inside the canonicalized mods directory using `starts_with`. This prevents path traversal and symlink escape vulnerabilities when validating or uninstalling mod zip files. Added unit tests covering path traversal rejection and symlink boundary validation. Co-authored-by: TheDragonSoft <75906592+TheDragonSoft@users.noreply.github.com> --- src-tauri/src/core/services/mod_store.rs | 53 ++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src-tauri/src/core/services/mod_store.rs b/src-tauri/src/core/services/mod_store.rs index 340fee3..9482042 100644 --- a/src-tauri/src/core/services/mod_store.rs +++ b/src-tauri/src/core/services/mod_store.rs @@ -778,6 +778,17 @@ fn validated_zip_path(dir: &Path, file_name: &str) -> Result "{file_name} not found in the mods directory" ))); } + + // Security: canonicalize both target file and parent directory to ensure + // path traversal or symlinks cannot target files outside the mods directory. + let canonical_path = fs::canonicalize(&path)?; + let canonical_dir = fs::canonicalize(dir)?; + if !canonical_path.starts_with(&canonical_dir) { + return Err(AppError::Config(format!( + "mod file {file_name:?} resolves outside mods directory" + ))); + } + Ok(path) } @@ -1076,6 +1087,48 @@ mod tests { use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; + #[test] + fn validated_zip_path_accepts_valid_zip_and_rejects_traversal() { + let dir = unique_dir("validated-zip-path"); + let valid_zip = dir.join("ValidMod_1.0.0.zip"); + write_zip(&valid_zip, r#"{"name":"ValidMod","version":"1.0.0"}"#); + + // Valid zip inside mods directory must succeed + let res = validated_zip_path(&dir, "ValidMod_1.0.0.zip"); + assert!(res.is_ok()); + + // Invalid filenames / traversal characters must fail + assert!(validated_zip_path(&dir, "").is_err()); + assert!(validated_zip_path(&dir, "../outside.zip").is_err()); + assert!(validated_zip_path(&dir, "sub/mod.zip").is_err()); + assert!(validated_zip_path(&dir, "sub\\mod.zip").is_err()); + assert!(validated_zip_path(&dir, "ValidMod_1.0.0.txt").is_err()); + assert!(validated_zip_path(&dir, "NonExistent_1.0.0.zip").is_err()); + + let _ = fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn validated_zip_path_rejects_symlink_resolving_outside_mods_dir() { + let root = unique_dir("validated-zip-symlink-root"); + let mods_dir = root.join("mods"); + let outside_dir = root.join("outside"); + fs::create_dir_all(&mods_dir).unwrap(); + fs::create_dir_all(&outside_dir).unwrap(); + + let outside_zip = outside_dir.join("Outside_1.0.0.zip"); + write_zip(&outside_zip, r#"{"name":"Outside","version":"1.0.0"}"#); + + let symlink_path = mods_dir.join("SymlinkMod_1.0.0.zip"); + std::os::unix::fs::symlink(&outside_zip, &symlink_path).unwrap(); + + let err = validated_zip_path(&mods_dir, "SymlinkMod_1.0.0.zip").unwrap_err(); + assert!(err.to_string().contains("resolves outside mods directory")); + + let _ = fs::remove_dir_all(&root); + } + #[test] fn parses_standard_zip_names() { assert_eq!(