|
4 | 4 | //! (Full Disk Access) permissions intact across updates. |
5 | 5 |
|
6 | 6 | use std::collections::HashSet; |
| 7 | +use std::ffi::OsString; |
7 | 8 | use std::fs; |
8 | 9 | use std::path::{Path, PathBuf}; |
9 | 10 | use std::process::Command; |
@@ -278,20 +279,35 @@ fn is_permission_error(error: &str) -> bool { |
278 | 279 | error.contains("Permission denied") || error.contains("Operation not permitted") |
279 | 280 | } |
280 | 281 |
|
| 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 | + |
281 | 303 | /// Performs the file sync using `osascript` with admin privileges. |
282 | 304 | /// Uses `rsync -a --delete` because it's the simplest way to express the full sync in a |
283 | 305 | /// single shell command that macOS's `do shell script` can execute. |
284 | 306 | 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); |
291 | 308 |
|
292 | 309 | let output = Command::new("osascript") |
293 | | - .arg("-e") |
294 | | - .arg(&script) |
| 310 | + .args(&argv) |
295 | 311 | .output() |
296 | 312 | .map_err(|e| format!("Couldn't run osascript: {e}"))?; |
297 | 313 |
|
@@ -378,4 +394,43 @@ mod tests { |
378 | 394 | let exe = Path::new("/Users/jane/code/cmdr/.claude/worktrees/feature/target/aarch64-apple-darwin/release/Cmdr"); |
379 | 395 | assert_eq!(find_app_bundle_above(exe), None); |
380 | 396 | } |
| 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 | + } |
381 | 436 | } |
0 commit comments