diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d598447..37b96c4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -113,6 +113,73 @@ fn atomic_write(target: &Path, bytes: &[u8]) -> std::io::Result<()> { Ok(()) } +fn safe_path_component<'a>(value: &'a str, label: &str) -> Result<&'a str, String> { + if value.is_empty() + || value == "." + || value == ".." + || value.contains(['/', '\\']) + || Path::new(value).is_absolute() + { + return Err(format!("Invalid {}", label)); + } + Ok(value) +} + +fn resolve_image_directory(parent_dir: &str, image_directory: &str) -> Result<(PathBuf, PathBuf), String> { + let root = Path::new(parent_dir) + .canonicalize() + .map_err(|e| format!("Invalid image parent directory: {}", e))?; + let requested_dir = if image_directory.is_empty() { + root.clone() + } else { + root.join(safe_path_component(image_directory, "image directory")?) + }; + + fs::create_dir_all(&requested_dir).map_err(|e| e.to_string())?; + let image_dir = requested_dir.canonicalize().map_err(|e| e.to_string())?; + if !image_dir.starts_with(&root) { + return Err("Image directory must remain inside the document directory".to_string()); + } + Ok((root, image_dir)) +} + +fn ensure_path_within_root(root: &Path, path: &Path) -> Result<(), String> { + let resolved = match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => path.canonicalize().map_err(|e| e.to_string())?, + _ => path.to_path_buf(), + }; + if resolved.starts_with(root) { + Ok(()) + } else { + Err("Image path must remain inside the document directory".to_string()) + } +} + +const MAX_VSIX_DOWNLOAD_BYTES: usize = 20 * 1024 * 1024; +const MAX_VSIX_ENTRIES: usize = 10_000; +const MAX_VSIX_UNCOMPRESSED_BYTES: u64 = 100 * 1024 * 1024; +const MAX_THEME_JSON_BYTES: u64 = 2 * 1024 * 1024; + +fn validate_vsix_archive_limits( + archive: &mut zip::ZipArchive, +) -> Result<(), String> { + if archive.len() > MAX_VSIX_ENTRIES { + return Err("VSIX contains too many files".to_string()); + } + + let mut total_size = 0_u64; + for index in 0..archive.len() { + let file = archive.by_index(index).map_err(|e| e.to_string())?; + total_size = total_size + .checked_add(file.size()) + .ok_or("VSIX uncompressed size overflow")?; + if total_size > MAX_VSIX_UNCOMPRESSED_BYTES { + return Err("VSIX expands beyond the allowed size".to_string()); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -199,6 +266,35 @@ mod tests { assert!(out.contains("`^[not a footnote]`"), "got: {out}"); assert!(out.contains("[jump](#real)"), "got: {out}"); } + + #[test] + fn path_components_reject_traversal_separators_and_absolute_paths() { + for invalid in ["", ".", "..", "../theme", "folder/theme", "folder\\theme", "/tmp/theme"] { + assert!(safe_path_component(invalid, "test").is_err(), "{invalid}"); + } + assert_eq!(safe_path_component("SynthWave '84", "test").unwrap(), "SynthWave '84"); + } + + #[cfg(unix)] + #[test] + fn image_directory_rejects_symlink_escape() { + use std::os::unix::fs::symlink; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("markpad-path-root-{nonce}")); + let outside = std::env::temp_dir().join(format!("markpad-path-outside-{nonce}")); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&outside).unwrap(); + symlink(&outside, root.join("images")).unwrap(); + + assert!(resolve_image_directory(root.to_str().unwrap(), "images").is_err()); + + fs::remove_dir_all(root).unwrap(); + fs::remove_dir_all(outside).unwrap(); + } } struct WatcherState { @@ -780,7 +876,7 @@ fn save_theme(app: AppHandle, theme: String) -> Result<(), String> { let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?; let theme_path = config_dir.join("theme.txt"); - fs::write(theme_path, theme).map_err(|e| e.to_string()) + atomic_write(&theme_path, theme.as_bytes()).map_err(|e| e.to_string()) } #[tauri::command] @@ -836,14 +932,30 @@ async fn fetch_vscode_theme(app: AppHandle, url: String) -> Result MAX_VSIX_DOWNLOAD_BYTES as u64) { + return Err("VSIX download exceeds the allowed size".to_string()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? { + if bytes.len() + chunk.len() > MAX_VSIX_DOWNLOAD_BYTES { + return Err("VSIX download exceeds the allowed size".to_string()); + } + bytes.extend_from_slice(&chunk); + } - let reader = Cursor::new(bytes.as_ref()); + let reader = Cursor::new(bytes); let mut archive = zip::ZipArchive::new(reader).map_err(|e| e.to_string())?; + validate_vsix_archive_limits(&mut archive)?; let mut package_json_data = String::new(); if let Ok(mut file) = archive.by_name("extension/package.json") { + if file.size() > MAX_THEME_JSON_BYTES { + return Err("VSIX package manifest exceeds the allowed size".to_string()); + } file.read_to_string(&mut package_json_data) .map_err(|e| e.to_string())?; } else { @@ -892,6 +1004,9 @@ async fn fetch_vscode_theme(app: AppHandle, url: String) -> Result MAX_THEME_JSON_BYTES { + return Err("VSIX theme file exceeds the allowed size".to_string()); + } let mut theme_json = String::new(); theme_file .read_to_string(&mut theme_json) @@ -906,10 +1021,11 @@ async fn fetch_vscode_theme(app: AppHandle, url: String) -> Result Result, String> { #[tauri::command] fn read_vscode_theme(app: AppHandle, name: String) -> Result { let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; + let name = safe_path_component(&name, "theme name")?; let theme_file_path = config_dir.join("themes").join(format!("{}.json", name)); fs::read_to_string(theme_file_path).map_err(|e| e.to_string()) } @@ -944,6 +1061,7 @@ fn read_vscode_theme(app: AppHandle, name: String) -> Result { #[tauri::command] fn delete_vscode_theme(app: AppHandle, name: String) -> Result<(), String> { let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; + let name = safe_path_component(&name, "theme name")?; let theme_file_path = config_dir.join("themes").join(format!("{}.json", name)); fs::remove_file(theme_file_path).map_err(|e| e.to_string()) } @@ -1100,12 +1218,10 @@ fn clipboard_read_image(macos_image_scaling: bool) -> Result { #[tauri::command] fn save_image(parent_dir: String, filename: String, base64_data: String, image_directory: String) -> Result { - let img_dir = Path::new(&parent_dir).join(&image_directory); - if !img_dir.exists() { - fs::create_dir_all(&img_dir).map_err(|e| e.to_string())?; - } - - let file_path = img_dir.join(&filename); + let filename = safe_path_component(&filename, "image filename")?; + let (root, img_dir) = resolve_image_directory(&parent_dir, &image_directory)?; + let file_path = img_dir.join(filename); + ensure_path_within_root(&root, &file_path)?; // remove potential data:image/png;base64, prefix let b64 = if let Some(pos) = base64_data.find("base64,") { @@ -1119,10 +1235,10 @@ fn save_image(parent_dir: String, filename: String, base64_data: String, image_d .decode(b64) .map_err(|e: base64::DecodeError| e.to_string())?; - fs::write(&file_path, bytes).map_err(|e| e.to_string())?; + atomic_write(&file_path, &bytes).map_err(|e| e.to_string())?; let rel_path = if image_directory.is_empty() { - filename + filename.to_string() } else { format!("{}/{}", image_directory, filename) }; @@ -1132,10 +1248,7 @@ fn save_image(parent_dir: String, filename: String, base64_data: String, image_d #[tauri::command] fn copy_file_to_img(src_path: String, parent_dir: String, image_directory: String) -> Result { - let img_dir = Path::new(&parent_dir).join(&image_directory); - if !img_dir.exists() { - fs::create_dir_all(&img_dir).map_err(|e| e.to_string())?; - } + let (root, img_dir) = resolve_image_directory(&parent_dir, &image_directory)?; let src = Path::new(&src_path); if !src.exists() { @@ -1157,6 +1270,7 @@ fn copy_file_to_img(src_path: String, parent_dir: String, image_directory: Strin } let final_dest = img_dir.join(&dest_name); + ensure_path_within_root(&root, &final_dest)?; fs::copy(src, &final_dest).map_err(|e| e.to_string())?; let rel_path = if image_directory.is_empty() {