Skip to content

Commit 92c54d3

Browse files
author
curious-rabbit
committed
fix: sanitize escape sequence injection
1 parent 90e73d7 commit 92c54d3

6 files changed

Lines changed: 257 additions & 18 deletions

File tree

src/error.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
use std::io::IsTerminal;
2+
3+
use crate::sanitize::sanitize_for_terminal;
4+
15
pub fn print_error(msg: impl Into<String>) {
2-
eprintln!("[fd error]: {}", msg.into());
6+
let msg = msg.into();
7+
if std::io::stderr().is_terminal() {
8+
eprintln!("[fd error]: {}", sanitize_for_terminal(&msg));
9+
} else {
10+
eprintln!("[fd error]: {msg}");
11+
}
312
}

src/exec/mod.rs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,6 @@ impl CommandSet {
6363
if cmd.number_of_tokens() > 1 {
6464
bail!("Only one placeholder allowed for batch commands");
6565
}
66-
if cmd.args[0].has_tokens() {
67-
bail!("First argument of exec-batch is expected to be a fixed executable");
68-
}
6966
Ok(cmd)
7067
})
7168
.collect::<Result<Vec<_>>>()?,
@@ -178,7 +175,7 @@ impl CommandBuilder {
178175
self.finish()?;
179176
}
180177

181-
let arg = self.path_arg.generate(path, separator);
178+
let arg = self.path_arg.generate_for_exec(path, separator);
182179
if !self
183180
.cmd
184181
.args_would_fit(iter::once(&arg).chain(&self.post_args))
@@ -245,6 +242,12 @@ impl CommandTemplate {
245242
bail!("No executable provided for --exec or --exec-batch");
246243
}
247244

245+
// The first argument is the command to execute and must be a fixed string;
246+
// allowing a placeholder here would turn every matched file into an execve target.
247+
if args[0].has_tokens() {
248+
bail!("First argument of --exec/--exec-batch must be a fixed executable, not a placeholder");
249+
}
250+
248251
// If a placeholder token was not supplied, append one at the end of the command.
249252
if !has_placeholder {
250253
args.push(FormatTemplate::Tokens(vec![Token::Placeholder]));
@@ -262,9 +265,9 @@ impl CommandTemplate {
262265
/// Using the internal `args` field, and a supplied `input` variable, a `Command` will be
263266
/// build.
264267
fn generate(&self, input: &Path, path_separator: Option<&str>) -> io::Result<Command> {
265-
let mut cmd = Command::new(self.args[0].generate(input, path_separator));
268+
let mut cmd = Command::new(self.args[0].generate_for_exec(input, path_separator));
266269
for arg in &self.args[1..] {
267-
cmd.try_arg(arg.generate(input, path_separator))?;
270+
cmd.try_arg(arg.generate_for_exec(input, path_separator))?;
268271
}
269272
Ok(cmd)
270273
}
@@ -374,8 +377,8 @@ mod tests {
374377

375378
#[test]
376379
fn tokens_with_literal_braces_and_placeholder() {
377-
let template = CommandTemplate::new(vec!["{{{},end}"]).unwrap();
378-
assert_eq!(generate_str(&template, "foo"), vec!["{foo,end}"]);
380+
let template = CommandTemplate::new(vec!["echo", "{{{},end}"]).unwrap();
381+
assert_eq!(generate_str(&template, "foo"), vec!["echo", "{foo,end}"]);
379382
}
380383

381384
#[test]
@@ -429,6 +432,13 @@ mod tests {
429432
assert!(CommandSet::new(vec![vec!["echo"], vec![]]).is_err());
430433
}
431434

435+
#[test]
436+
fn placeholder_as_executable_rejected() {
437+
assert!(CommandSet::new(vec![vec!["{}"]]).is_err());
438+
assert!(CommandSet::new(vec![vec!["{/}", "arg"]]).is_err());
439+
assert!(CommandSet::new_batch(vec![vec!["{}"]]).is_err());
440+
}
441+
432442
#[test]
433443
fn generate_custom_path_separator() {
434444
let arg = FormatTemplate::Tokens(vec![Token::Placeholder]);

src/fmt/mod.rs

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ pub enum Token {
2424
Text(String),
2525
}
2626

27+
impl Token {
28+
fn is_path_derived(&self) -> bool {
29+
matches!(
30+
self,
31+
Token::Placeholder
32+
| Token::Basename
33+
| Token::Parent
34+
| Token::NoExt
35+
| Token::BasenameNoExt
36+
)
37+
}
38+
}
39+
2740
impl Display for Token {
2841
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
2942
match *self {
@@ -110,8 +123,26 @@ impl FormatTemplate {
110123
/// the path separator in all placeholder tokens. Fixed text and tokens are not affected by
111124
/// path separator substitution.
112125
pub fn generate(&self, path: impl AsRef<Path>, path_separator: Option<&str>) -> OsString {
126+
self.generate_impl(path.as_ref(), path_separator, false)
127+
}
128+
129+
/// Like `generate`, but prepends `./` when a path-derived token produces a leading `-`,
130+
/// so the target of `--exec` does not see it as an option.
131+
pub fn generate_for_exec(
132+
&self,
133+
path: impl AsRef<Path>,
134+
path_separator: Option<&str>,
135+
) -> OsString {
136+
self.generate_impl(path.as_ref(), path_separator, true)
137+
}
138+
139+
fn generate_impl(
140+
&self,
141+
path: &Path,
142+
path_separator: Option<&str>,
143+
protect_leading_dash: bool,
144+
) -> OsString {
113145
use Token::*;
114-
let path = path.as_ref();
115146

116147
match *self {
117148
Self::Tokens(ref tokens) => {
@@ -134,6 +165,16 @@ impl FormatTemplate {
134165
Text(string) => s.push(string),
135166
}
136167
}
168+
169+
if protect_leading_dash
170+
&& tokens.first().is_some_and(|t| t.is_path_derived())
171+
&& s.as_encoded_bytes().first() == Some(&b'-')
172+
{
173+
let mut prefixed = OsString::from("./");
174+
prefixed.push(s);
175+
return prefixed;
176+
}
177+
137178
s
138179
}
139180
Self::Text(ref text) => OsString::from(text),
@@ -278,4 +319,86 @@ mod fmt_tests {
278319
basenameNoExt=file }"
279320
);
280321
}
322+
323+
fn tmpl(s: &str) -> FormatTemplate {
324+
FormatTemplate::parse(s)
325+
}
326+
327+
#[test]
328+
fn exec_basename_with_leading_dash_is_guarded() {
329+
let out = tmpl("{/}")
330+
.generate_for_exec(Path::new("./some/dir/-rf"), None)
331+
.into_string()
332+
.unwrap();
333+
assert_eq!(out, "./-rf");
334+
}
335+
336+
#[test]
337+
fn exec_basename_no_ext_with_leading_dash_is_guarded() {
338+
let out = tmpl("{/.}")
339+
.generate_for_exec(Path::new("./some/dir/-evil.txt"), None)
340+
.into_string()
341+
.unwrap();
342+
assert_eq!(out, "./-evil");
343+
}
344+
345+
#[test]
346+
fn exec_parent_with_leading_dash_is_guarded() {
347+
let out = tmpl("{//}")
348+
.generate_for_exec(Path::new("-startdir/inner/file.txt"), None)
349+
.into_string()
350+
.unwrap();
351+
assert_eq!(out, "./-startdir/inner");
352+
}
353+
354+
#[test]
355+
fn exec_plain_placeholder_already_safe() {
356+
let out = tmpl("{}")
357+
.generate_for_exec(Path::new("./some/dir/-rf"), None)
358+
.into_string()
359+
.unwrap();
360+
assert_eq!(out, "./some/dir/-rf");
361+
}
362+
363+
#[test]
364+
fn exec_user_literal_dash_prefix_not_rewritten() {
365+
let out = tmpl("-{/}")
366+
.generate_for_exec(Path::new("./some/dir/normal.txt"), None)
367+
.into_string()
368+
.unwrap();
369+
assert_eq!(out, "-normal.txt");
370+
}
371+
372+
#[test]
373+
fn exec_user_literal_dash_before_dash_basename() {
374+
let out = tmpl("-{/}")
375+
.generate_for_exec(Path::new("./some/dir/-name"), None)
376+
.into_string()
377+
.unwrap();
378+
assert_eq!(out, "--name");
379+
}
380+
381+
#[test]
382+
fn exec_suffix_after_placeholder_preserves_guard() {
383+
let out = tmpl("{/}.bak")
384+
.generate_for_exec(Path::new("./some/dir/-foo"), None)
385+
.into_string()
386+
.unwrap();
387+
assert_eq!(out, "./-foo.bak");
388+
}
389+
390+
#[test]
391+
fn format_mode_does_not_protect_leading_dash() {
392+
let out = tmpl("{/}")
393+
.generate(Path::new("./some/dir/-rf"), None)
394+
.into_string()
395+
.unwrap();
396+
assert_eq!(out, "-rf");
397+
}
398+
399+
#[test]
400+
fn exec_empty_result_not_prefixed() {
401+
let out = tmpl("{/}").generate_for_exec(Path::new(""), None);
402+
assert!(out.is_empty());
403+
}
281404
}

src/main.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod fmt;
1111
mod hyperlink;
1212
mod output;
1313
mod regex_helper;
14+
mod sanitize;
1415
mod walk;
1516

1617
use std::env;
@@ -65,7 +66,13 @@ fn main() {
6566
exit_code.exit();
6667
}
6768
Err(err) => {
68-
eprintln!("[fd error]: {err:#}");
69+
// Gap fix: anyhow errors bubbled from run() may embed user paths/args.
70+
let formatted = format!("{err:#}");
71+
if std::io::stderr().is_terminal() {
72+
eprintln!("[fd error]: {}", sanitize::sanitize_for_terminal(&formatted));
73+
} else {
74+
eprintln!("[fd error]: {formatted}");
75+
}
6976
ExitCode::GeneralError.exit();
7077
}
7178
}

src/output.rs

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,21 @@ use crate::config::Config;
77
use crate::dir_entry::DirEntry;
88
use crate::fmt::FormatTemplate;
99
use crate::hyperlink::PathUrl;
10+
use crate::sanitize::sanitize_for_terminal;
1011

1112
fn replace_path_separator(path: &str, new_path_separator: &str) -> String {
1213
path.replace(std::path::MAIN_SEPARATOR, new_path_separator)
1314
}
1415

16+
/// Sanitize a string for terminal output only; raw bytes pass through on pipes.
17+
fn maybe_sanitize<'a>(s: &'a str, config: &Config) -> Cow<'a, str> {
18+
if config.interactive_terminal {
19+
sanitize_for_terminal(s)
20+
} else {
21+
Cow::Borrowed(s)
22+
}
23+
}
24+
1525
// TODO: this function is performance critical and can probably be optimized
1626
pub fn print_entry<W: Write>(stdout: &mut W, entry: &DirEntry, config: &Config) -> io::Result<()> {
1727
let mut has_hyperlink = false;
@@ -76,7 +86,8 @@ fn print_entry_format<W: Write>(
7686
config.path_separator.as_deref(),
7787
);
7888
// TODO: support writing raw bytes on unix?
79-
write!(stdout, "{}", output.to_string_lossy())
89+
let s = output.to_string_lossy();
90+
write!(stdout, "{}", maybe_sanitize(&s, config))
8091
}
8192

8293
// TODO: this function is performance critical and can probably be optimized
@@ -86,7 +97,6 @@ fn print_entry_colorized<W: Write>(
8697
config: &Config,
8798
ls_colors: &LsColors,
8899
) -> io::Result<()> {
89-
// Split the path between the parent and the last component
90100
let mut offset = 0;
91101
let path = entry.stripped_path(config);
92102
let path_str = path.to_string_lossy();
@@ -112,14 +122,16 @@ fn print_entry_colorized<W: Write>(
112122
.style_for_indicator(Indicator::Directory)
113123
.map(Style::to_nu_ansi_term_style)
114124
.unwrap_or_default();
115-
write!(stdout, "{}", style.paint(parent_str))?;
125+
let safe_parent = maybe_sanitize(&parent_str, config);
126+
write!(stdout, "{}", style.paint(safe_parent.as_ref()))?;
116127
}
117128

118129
let style = entry
119130
.style(ls_colors)
120131
.map(Style::to_nu_ansi_term_style)
121132
.unwrap_or_default();
122-
write!(stdout, "{}", style.paint(&path_str[offset..]))?;
133+
let safe_basename = maybe_sanitize(&path_str[offset..], config);
134+
write!(stdout, "{}", style.paint(safe_basename.as_ref()))?;
123135

124136
print_trailing_slash(
125137
stdout,
@@ -143,7 +155,8 @@ fn print_entry_uncolorized_base<W: Write>(
143155
if let Some(ref separator) = config.path_separator {
144156
*path_string.to_mut() = replace_path_separator(&path_string, separator);
145157
}
146-
write!(stdout, "{path_string}")?;
158+
let safe = maybe_sanitize(&path_string, config);
159+
write!(stdout, "{safe}")?;
147160
print_trailing_slash(stdout, entry, config, None)
148161
}
149162

@@ -165,10 +178,9 @@ fn print_entry_uncolorized<W: Write>(
165178
use std::os::unix::ffi::OsStrExt;
166179

167180
if config.interactive_terminal || config.path_separator.is_some() {
168-
// Fall back to the base implementation
169181
print_entry_uncolorized_base(stdout, entry, config)
170182
} else {
171-
// Print path as raw bytes, allowing invalid UTF-8 filenames to be passed to other processes
183+
// Piped output: raw bytes so invalid UTF-8 filenames reach downstream tools intact.
172184
stdout.write_all(entry.stripped_path(config).as_os_str().as_bytes())?;
173185
print_trailing_slash(stdout, entry, config, None)
174186
}

0 commit comments

Comments
 (0)