Skip to content

Commit 5875fb4

Browse files
committed
Bugfix: Close updater AppleScript injection via bundle path
`sync_with_admin_privileges` used to splat the running app's bundle path into the `do shell script` template inside single quotes. A `'` anywhere in the bundle's ancestor folder (e.g. `~/Don't Touch/Cmdr.app`) escaped the wrapping and the rest of the path ran as root through the admin auth dialog. - Move the script body to a constant that reads positional arguments and pipes them through AppleScript's `quoted form of` before splicing into the shell command. - Pass src/dest as osascript argv entries. - Cover the construction with a unit test pinned to a malicious dest (`Don't '; touch /tmp/pwned; '.app`).
1 parent 0e6940e commit 5875fb4

1 file changed

Lines changed: 63 additions & 8 deletions

File tree

apps/desktop/src-tauri/src/updater/installer.rs

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! (Full Disk Access) permissions intact across updates.
55
66
use std::collections::HashSet;
7+
use std::ffi::OsString;
78
use std::fs;
89
use std::path::{Path, PathBuf};
910
use std::process::Command;
@@ -278,20 +279,35 @@ fn is_permission_error(error: &str) -> bool {
278279
error.contains("Permission denied") || error.contains("Operation not permitted")
279280
}
280281

282+
/// AppleScript that reads two positional arguments and feeds them through
283+
/// `quoted form of` before splicing into the shell command. This is the only safe way
284+
/// to forward filesystem paths into `do shell script` — interpolating the paths into
285+
/// the script text leaves them open to shell-injection (a `'` in any ancestor folder
286+
/// name escapes the single-quote wrapping and the rest of the path runs as root).
287+
const ADMIN_SYNC_SCRIPT: &str = "on run argv\n\
288+
set src to item 1 of argv\n\
289+
set dst to item 2 of argv\n\
290+
do shell script \"rsync -a --delete \" & quoted form of src & \" \" & quoted form of dst with administrator privileges\n\
291+
end run";
292+
293+
/// Builds the argv passed to `osascript`. Split out from [`sync_with_admin_privileges`]
294+
/// so it can be unit-tested without invoking the auth dialog.
295+
fn build_admin_sync_argv(staged_contents: &Path, bundle_contents: &Path) -> Vec<OsString> {
296+
let mut src: OsString = staged_contents.as_os_str().to_os_string();
297+
src.push("/"); // trailing slash for rsync
298+
let mut dst: OsString = bundle_contents.as_os_str().to_os_string();
299+
dst.push("/");
300+
vec!["-e".into(), ADMIN_SYNC_SCRIPT.into(), src, dst]
301+
}
302+
281303
/// Performs the file sync using `osascript` with admin privileges.
282304
/// Uses `rsync -a --delete` because it's the simplest way to express the full sync in a
283305
/// single shell command that macOS's `do shell script` can execute.
284306
fn sync_with_admin_privileges(staged_contents: &Path, bundle_contents: &Path) -> Result<(), String> {
285-
let src = format!("{}/", staged_contents.display()); // trailing slash for rsync
286-
let dest = format!("{}/", bundle_contents.display());
287-
let script = format!(
288-
"do shell script \"rsync -a --delete '{}' '{}'\" with administrator privileges",
289-
src, dest
290-
);
307+
let argv = build_admin_sync_argv(staged_contents, bundle_contents);
291308

292309
let output = Command::new("osascript")
293-
.arg("-e")
294-
.arg(&script)
310+
.args(&argv)
295311
.output()
296312
.map_err(|e| format!("Couldn't run osascript: {e}"))?;
297313

@@ -378,4 +394,43 @@ mod tests {
378394
let exe = Path::new("/Users/jane/code/cmdr/.claude/worktrees/feature/target/aarch64-apple-darwin/release/Cmdr");
379395
assert_eq!(find_app_bundle_above(exe), None);
380396
}
397+
398+
#[test]
399+
fn build_admin_sync_argv_keeps_malicious_dest_as_single_argument() {
400+
// Bundle path with an embedded single quote and a shell metacharacter sequence
401+
// that would inject a command if the path were splatted into the script text.
402+
let staged = Path::new("/private/tmp/cmdr-update-staging-default/Cmdr.app/Contents");
403+
let dest = Path::new("/Applications/Don't '; touch /tmp/pwned; '.app/Contents");
404+
405+
let argv = build_admin_sync_argv(staged, dest);
406+
407+
assert_eq!(argv[0], "-e", "first arg must be the -e flag");
408+
assert_eq!(argv[1], ADMIN_SYNC_SCRIPT, "second arg must be the constant script");
409+
// The malicious dest stays a single argv entry; nothing from the path leaks into
410+
// the script template, so `quoted form of` (inside the script) handles the quote
411+
// safely at runtime.
412+
assert_eq!(
413+
argv[3].to_string_lossy(),
414+
"/Applications/Don't '; touch /tmp/pwned; '.app/Contents/"
415+
);
416+
// Script template must NOT mention the dest path; it must reference positional args only.
417+
assert!(
418+
!ADMIN_SYNC_SCRIPT.contains("pwned") && !ADMIN_SYNC_SCRIPT.contains("Don't"),
419+
"script template must be a constant, not built from path strings"
420+
);
421+
// And it MUST use AppleScript's own quoter (defense against future edits).
422+
assert!(
423+
ADMIN_SYNC_SCRIPT.contains("quoted form of"),
424+
"script must pass paths through `quoted form of` before shelling out"
425+
);
426+
}
427+
428+
#[test]
429+
fn build_admin_sync_argv_appends_trailing_slash_for_rsync() {
430+
let staged = Path::new("/tmp/staged/Contents");
431+
let dest = Path::new("/Applications/Cmdr.app/Contents");
432+
let argv = build_admin_sync_argv(staged, dest);
433+
assert!(argv[2].to_string_lossy().ends_with("/Contents/"));
434+
assert!(argv[3].to_string_lossy().ends_with("/Contents/"));
435+
}
381436
}

0 commit comments

Comments
 (0)