From 555c6c0d7e319fbc13566bd2c69da9d3b8a8fd37 Mon Sep 17 00:00:00 2001 From: Matias Palma Date: Mon, 20 Apr 2026 22:12:14 -0400 Subject: [PATCH 1/2] fix: canonicalize and quote paths in reveal_path to prevent explorer argument injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reveal_path on Windows built /select, with a format string and passed the whole thing through std::process::Command::arg. Two problems: 1. A crafted path containing commas lets explorer parse the suffix as additional arguments — the classic /select,foo.lnk,evil shape the issue describes. 2. Command::arg wraps the argument in outer quotes on Windows, which Explorer doesn't recognize; explorer "/select,..." typically just falls back to opening the user's home folder instead of selecting the file. Fix in three layers: - Canonicalize the caller-supplied path up front on every platform. This fails loudly if the path doesn't exist, so reveal_path can no longer be used to probe the filesystem with speculative arguments. - On Windows, strip the \\?\ verbatim prefix that canonicalize emits (Explorer can't navigate to it) and build the argument as /select,"" using CommandExt::raw_arg, which hands the slice to CreateProcess verbatim without Rust's auto-quoting. The inner quotes neutralize comma-injection in filenames. - On macOS and Linux pass the canonical PathBuf through the regular argv API, which has no shell-parsing concerns. --- apps/desktop/src-tauri/src/lib.rs | 32 ++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9519c4ac..46683231 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1076,10 +1076,34 @@ async fn open_url(url: String) -> Result<(), String> { #[tauri::command] fn reveal_path(path: String) -> Result<(), String> { + // Resolve the caller-supplied string against the filesystem before + // handing it to a shell helper. `canonicalize` fails if the target + // doesn't exist, so a non-existent or partial path can't be used to + // probe the filesystem by spawning explorer/open/xdg-open with garbage. + let canonical = std::path::Path::new(&path) + .canonicalize() + .map_err(|e| format!("Failed to resolve path for reveal: {}", e))?; + #[cfg(target_os = "windows")] { + use std::os::windows::process::CommandExt; + + // `Path::canonicalize` on Windows returns the `\\?\C:\...` verbatim + // prefix, which Explorer treats as an unknown target. Strip it so + // `/select,` receives a normal drive-letter path. + let as_str = canonical.to_string_lossy(); + let clean = as_str.strip_prefix(r"\\?\").unwrap_or(&as_str); + + // Build `/select,""` and hand Explorer the raw command line. + // `Command::arg` would wrap the whole value in outer quotes, which + // Explorer parses as one opaque token and falls back to the home + // folder. `raw_arg` skips that wrapping. The inner quotes also + // defend against paths that contain commas — without them + // `/select,C:\foo,bar\file` is split into three Explorer arguments + // and an attacker-controlled filename can piggy-back extra ones. + let raw = format!("/select,\"{}\"", clean); silent_command("explorer") - .arg(format!("/select,{}", path)) + .raw_arg(raw) .spawn() .map_err(|e| e.to_string())?; } @@ -1087,15 +1111,13 @@ fn reveal_path(path: String) -> Result<(), String> { { silent_command("open") .arg("-R") - .arg(path) + .arg(&canonical) .spawn() .map_err(|e| e.to_string())?; } #[cfg(target_os = "linux")] { - let parent = std::path::Path::new(&path) - .parent() - .unwrap_or(std::path::Path::new(&path)); + let parent = canonical.parent().unwrap_or(&canonical); silent_command("xdg-open") .arg(parent) .spawn() From 92e1c4a71fdc291b87fefdc9065b8b5eac415788 Mon Sep 17 00:00:00 2001 From: Matias Palma Date: Mon, 20 Apr 2026 23:14:04 -0400 Subject: [PATCH 2/2] fix: preserve UNC paths and strip trailing backslashes in reveal_path on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #163: - On Windows, canonicalize() returns verbatim paths in two shapes: \\?\C:\... for drive letters and \\?\UNC\server\share\... for UNC shares. Previously only the \\?\ prefix was stripped, which turned the UNC form into UNC\server\share\... — not a valid Windows path, so reveal on a network-mounted file silently broke. Now we map \\?\UNC\... back to \\server\share\... and keep the simple strip for drive-letter paths. - raw_arg writes the literal bytes we hand it, so a clean path ending in \ (e.g. the root of a drive) produced /select,"C:\" — the trailing backslash escaped the closing quote and Explorer saw a malformed argument. Trim trailing backslashes before wrapping in the inner quotes; Explorer selects the directory just fine without the separator. --- apps/desktop/src-tauri/src/lib.rs | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 46683231..40c47352 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1088,11 +1088,27 @@ fn reveal_path(path: String) -> Result<(), String> { { use std::os::windows::process::CommandExt; - // `Path::canonicalize` on Windows returns the `\\?\C:\...` verbatim - // prefix, which Explorer treats as an unknown target. Strip it so - // `/select,` receives a normal drive-letter path. + // `Path::canonicalize` on Windows returns verbatim paths: `\\?\C:\…` + // for drive-letter targets and `\\?\UNC\server\share\…` for UNC + // shares. Explorer doesn't understand the verbatim prefix on either + // form, and naïvely stripping only `\\?\` on a UNC path leaves + // `UNC\server\share\…`, which is not a valid Windows path at all. + // Map the two verbatim shapes back to the forms Explorer navigates. let as_str = canonical.to_string_lossy(); - let clean = as_str.strip_prefix(r"\\?\").unwrap_or(&as_str); + let clean = if let Some(unc) = as_str.strip_prefix(r"\\?\UNC\") { + format!(r"\\{}", unc) + } else if let Some(drive) = as_str.strip_prefix(r"\\?\") { + drive.to_string() + } else { + as_str.into_owned() + }; + + // Trim any trailing backslashes before building the quoted argument. + // With `raw_arg` we hand Explorer the literal bytes we write, so + // `/select,"C:\"` ends with `\"` — the backslash escapes the closing + // quote and the argument becomes malformed. Explorer happily selects + // the directory without the trailing separator, so stripping is safe. + let clean_trimmed = clean.trim_end_matches('\\'); // Build `/select,""` and hand Explorer the raw command line. // `Command::arg` would wrap the whole value in outer quotes, which @@ -1101,7 +1117,7 @@ fn reveal_path(path: String) -> Result<(), String> { // defend against paths that contain commas — without them // `/select,C:\foo,bar\file` is split into three Explorer arguments // and an attacker-controlled filename can piggy-back extra ones. - let raw = format!("/select,\"{}\"", clean); + let raw = format!("/select,\"{}\"", clean_trimmed); silent_command("explorer") .raw_arg(raw) .spawn()