Skip to content

Commit fd29861

Browse files
codexByron
andcommitted
fix: prevent launcher option and shell injection (#124)
Opening an attacker-controlled dash-leading path could be interpreted as launcher options. On Windows, cmd /c start also parsed embedded quotes and metacharacters as command language, while the legacy gnome-open fallback could load a module even after a double-dash separator. Add command-construction regressions for malicious option-shaped paths and Windows shell metacharacters. Use supported separators on macOS and KDE, and rewrite dash-leading relative paths for launchers without separator support. Keep Windows values out of shell syntax by passing the default target through the environment and invoking custom applications directly, with explorer.exe as a PowerShell-free fallback. Exclude cmd-based opening by default, while providing an explicit insecure Cargo feature for users who need compatibility it and accept their unsafe handling of untrusted input. Validated with default and all-feature cargo tests, clippy, and cross-target cargo check --tests for aarch64 Linux and Windows. Co-authored-by: Sebastian Thiel <sebastian.thiel@icloud.com>
1 parent 7c19c0a commit fd29861

5 files changed

Lines changed: 379 additions & 30 deletions

File tree

β€ŽCargo.tomlβ€Ž

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ include = ["src/**/*", "LICENSE.md", "README.md", "changelog.md"]
1313
rust-version = "1.62"
1414

1515
[features]
16+
## Restore the historical `cmd /c start` launcher on Windows. It cannot safely
17+
## receive untrusted paths because `cmd` interprets its arguments as shell syntax.
18+
##
19+
## Do not enable this feature when paths or URLs may be attacker-controlled.
20+
insecure = []
21+
1622
## If enabled, link to `system` on Windows and use `ShellExecuteW` intead of a command invocation
1723
## when launching something in 'detached' mode.
1824
## That way, it should be possible to open currently opened (for writing) files as well.

β€Žsrc/lib.rsβ€Ž

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@
8585
//!
8686
//! It cannot be overemphasized how fragile this all is in UNIX environments. It is common for the various MIME tables to incorrectly specify the application "owning" a given filetype.
8787
//! It is common for openers to behave strangely. Use with caution, as this crate merely inherits a particular platforms shortcomings.
88+
//!
89+
//! ## Security boundaries
90+
//!
91+
//! Paths passed to system launchers are separated from launcher options where the
92+
//! launcher supports it. Applications selected through [`with()`] have arbitrary
93+
//! command-line grammars, however, so the crate cannot guarantee that every custom
94+
//! application treats a dash-leading path as data. Launching a document or custom
95+
//! URL scheme also trusts the configured local handler and the launched content.
96+
//! The `insecure` Cargo feature restores the legacy `cmd /c start` launcher on
97+
//! Windows for compatibility, but must not be enabled when paths or URLs may be
98+
//! attacker-controlled.
8899
89100
#[cfg(target_os = "windows")]
90101
use windows as os;

β€Žsrc/macos.rsβ€Ž

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,36 @@ use std::{ffi::OsStr, process::Command};
22

33
pub fn commands<T: AsRef<OsStr>>(path: T) -> Vec<Command> {
44
let mut cmd = Command::new("/usr/bin/open");
5-
cmd.arg(path.as_ref());
5+
cmd.arg("--").arg(path.as_ref());
66
vec![cmd]
77
}
88

99
pub fn with_command<T: AsRef<OsStr>>(path: T, app: impl Into<String>) -> Command {
1010
let mut cmd = Command::new("/usr/bin/open");
11-
cmd.arg(path.as_ref()).arg("-a").arg(app.into());
11+
cmd.arg("-a").arg(app.into()).arg("--").arg(path.as_ref());
1212
cmd
1313
}
14+
15+
#[cfg(test)]
16+
mod tests {
17+
use super::*;
18+
19+
#[test]
20+
fn separates_paths_from_open_options() {
21+
assert_eq!(
22+
commands("-aCalculator")[0].get_args().collect::<Vec<_>>(),
23+
[OsStr::new("--"), OsStr::new("-aCalculator")]
24+
);
25+
assert_eq!(
26+
with_command("-aCalculator", "Preview")
27+
.get_args()
28+
.collect::<Vec<_>>(),
29+
[
30+
OsStr::new("-a"),
31+
OsStr::new("Preview"),
32+
OsStr::new("--"),
33+
OsStr::new("-aCalculator")
34+
]
35+
);
36+
}
37+
}

β€Žsrc/unix.rsβ€Ž

Lines changed: 96 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,24 @@
1-
use std::{ffi::OsStr, process::Command};
1+
use std::{
2+
ffi::{OsStr, OsString},
3+
process::Command,
4+
};
5+
6+
/// Prevent launchers from interpreting a path that starts with `-` as an option.
7+
///
8+
/// Prefixing such paths with `./` preserves their meaning as paths relative to the
9+
/// current directory. This is used for launchers that do not support `--` as an
10+
/// end-of-options marker.
11+
fn option_safe_path(path: &OsStr) -> OsString {
12+
use std::os::unix::ffi::OsStrExt;
13+
14+
if path.as_bytes().first() == Some(&b'-') {
15+
let mut safe = OsString::from("./");
16+
safe.push(path);
17+
safe
18+
} else {
19+
path.to_os_string()
20+
}
21+
}
222

323
pub fn commands<T: AsRef<OsStr>>(path: T) -> Vec<Command> {
424
let path = path.as_ref();
@@ -9,18 +29,23 @@ pub fn commands<T: AsRef<OsStr>>(path: T) -> Vec<Command> {
929
commands.push(crate::wsl::command(path));
1030
}
1131

12-
let fallback_commands = [
13-
("xdg-open", vec![path]),
14-
("gio", vec![OsStr::new("open"), path]),
15-
("gnome-open", vec![path]),
16-
("kde-open", vec![path]),
17-
];
32+
let safe_path = option_safe_path(path);
1833

19-
commands.extend(fallback_commands.iter().map(|(command, args)| {
20-
let mut cmd = Command::new(command);
21-
cmd.args(args);
22-
cmd
23-
}));
34+
let mut xdg = Command::new("xdg-open");
35+
xdg.arg(&safe_path);
36+
commands.push(xdg);
37+
38+
let mut gio = Command::new("gio");
39+
gio.arg("open").arg(&safe_path);
40+
commands.push(gio);
41+
42+
let mut gnome = Command::new("gnome-open");
43+
gnome.arg(&safe_path);
44+
commands.push(gnome);
45+
46+
let mut kde = Command::new("kde-open");
47+
kde.arg("--").arg(path);
48+
commands.push(kde);
2449

2550
commands
2651
}
@@ -30,3 +55,62 @@ pub fn with_command<T: AsRef<OsStr>>(path: T, app: impl Into<String>) -> Command
3055
cmd.arg(path.as_ref());
3156
cmd
3257
}
58+
59+
#[cfg(test)]
60+
mod tests {
61+
use super::*;
62+
63+
fn args(command: &Command) -> Vec<&OsStr> {
64+
command.get_args().collect()
65+
}
66+
67+
#[test]
68+
fn protects_dash_leading_paths_from_launcher_option_parsing() {
69+
let commands = commands("--load-modules=/tmp/evil.so");
70+
71+
let expected_len = 4;
72+
let commands = &commands[commands.len() - expected_len..];
73+
assert_eq!(commands.len(), expected_len);
74+
assert_eq!(commands[0].get_program(), "xdg-open");
75+
assert_eq!(
76+
args(&commands[0]),
77+
[OsStr::new("./--load-modules=/tmp/evil.so")],
78+
"xdg-open has no end-of-options marker, so the path must not start with a dash"
79+
);
80+
assert_eq!(commands[1].get_program(), "gio");
81+
assert_eq!(
82+
args(&commands[1]),
83+
[
84+
OsStr::new("open"),
85+
OsStr::new("./--load-modules=/tmp/evil.so")
86+
],
87+
"gio has no end-of-options marker, so the path must not start with a dash"
88+
);
89+
let kde = commands.last().unwrap();
90+
assert_eq!(commands[2].get_program(), "gnome-open");
91+
assert_eq!(
92+
args(&commands[2]),
93+
[OsStr::new("./--load-modules=/tmp/evil.so")],
94+
"gnome-open would otherwise interpret the path as a module-loading option"
95+
);
96+
assert_eq!(kde.get_program(), "kde-open");
97+
assert_eq!(
98+
args(kde),
99+
[OsStr::new("--"), OsStr::new("--load-modules=/tmp/evil.so")],
100+
"kde-open supports separating its options from an unchanged path"
101+
);
102+
}
103+
104+
#[test]
105+
fn leaves_urls_and_regular_paths_unchanged() {
106+
for path in ["https://example.com", "document.pdf", "/tmp/-document.pdf"] {
107+
let commands = commands(path);
108+
let expected_len = 4;
109+
let commands = &commands[commands.len() - expected_len..];
110+
assert_eq!(args(&commands[0]), [OsStr::new(path)]);
111+
assert_eq!(args(&commands[1]), [OsStr::new("open"), OsStr::new(path)]);
112+
assert_eq!(args(&commands[2]), [OsStr::new(path)]);
113+
assert_eq!(args(&commands[3]), [OsStr::new("--"), OsStr::new(path)]);
114+
}
115+
}
116+
}

0 commit comments

Comments
Β (0)