From 71c894e843522fd1804283b7bd347cb2c31fad21 Mon Sep 17 00:00:00 2001 From: curious-rabbit Date: Mon, 20 Apr 2026 14:25:49 +0200 Subject: [PATCH] santize filenames --- CHANGELOG.md | 1 + src/assets.rs | 16 +++++++---- src/bin/bat/main.rs | 4 ++- src/input.rs | 16 +++++------ src/lib.rs | 1 + src/preprocessor.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++ src/printer.rs | 12 ++++++--- 7 files changed, 98 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc4e54e..18a322a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Syntax highlighting for Python files using uv as script runner in shebang #3689 (@janlarres) ## Bugfixes +- Sanitize control characters in filenames before displaying them in the file header, error messages, and the terminal title, preventing ANSI escape injection via crafted filenames. Closes #3054, see #3691 (@curious-rabbit) - Treat ZIP archives as binary content based on their magic header, see #3686 (@officialasishkumar) - Fix i686 `.deb` package using incorrect architecture name (`i686` instead of `i386`), preventing installation on Debian. Closes #3611, see #3650 (@Sim-hu) - Fix inconsistent `.deb` MUSL package names (aarch64-musl used `arm64` instead of `musl-linux-arm64`, and `musleabihf` target missed `bat-musl` prefix). Closes #3482, see #3642 (@mvanhorn) diff --git a/src/assets.rs b/src/assets.rs index 29247bd7..9483f032 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -158,7 +158,9 @@ impl HighlightingAssets { let syntax_match = mapping.get_syntax_for(path); if let Some(MappingTarget::MapToUnknown) = syntax_match { - return Err(Error::UndetectedSyntax(path.to_string_lossy().into())); + return Err(Error::UndetectedSyntax( + crate::preprocessor::sanitize_for_terminal(&path.to_string_lossy()), + )); } if let Some(MappingTarget::MapTo(syntax_name)) = syntax_match { @@ -175,13 +177,17 @@ impl HighlightingAssets { ) { (Some(syntax), _) => Ok(syntax), - (_, Some(MappingTarget::MapExtensionToUnknown)) => { - Err(Error::UndetectedSyntax(path.to_string_lossy().into())) - } + (_, Some(MappingTarget::MapExtensionToUnknown)) => Err(Error::UndetectedSyntax( + crate::preprocessor::sanitize_for_terminal(&path.to_string_lossy()), + )), _ => self .get_syntax_for_file_extension(file_name, &mapping.ignored_suffixes)? - .ok_or_else(|| Error::UndetectedSyntax(path.to_string_lossy().into())), + .ok_or_else(|| { + Error::UndetectedSyntax(crate::preprocessor::sanitize_for_terminal( + &path.to_string_lossy(), + )) + }), } } diff --git a/src/bin/bat/main.rs b/src/bin/bat/main.rs index f3fa01f4..71ed8527 100644 --- a/src/bin/bat/main.rs +++ b/src/bin/bat/main.rs @@ -259,7 +259,9 @@ pub fn list_themes( fn set_terminal_title_to(new_terminal_title: String) { let osc_command_for_setting_terminal_title = "\x1b]0;"; let osc_end_command = "\x07"; - print!("{osc_command_for_setting_terminal_title}{new_terminal_title}{osc_end_command}"); + // Prevent BEL/ESC/C1 bytes in the title from terminating or nesting the OSC. + let safe_title = bat::sanitize_for_terminal(&new_terminal_title); + print!("{osc_command_for_setting_terminal_title}{safe_title}{osc_end_command}"); io::stdout().flush().unwrap(); } diff --git a/src/input.rs b/src/input.rs index 30f13f98..231e2a34 100644 --- a/src/input.rs +++ b/src/input.rs @@ -216,20 +216,20 @@ impl<'a> Input<'a> { description, metadata: self.metadata, reader: { - let mut file = File::open(&path) - .map_err(|e| format!("'{}': {e}", path.to_string_lossy()))?; + let path_display = + crate::preprocessor::sanitize_for_terminal(&path.to_string_lossy()); + let mut file = + File::open(&path).map_err(|e| format!("'{path_display}': {e}"))?; if file.metadata()?.is_dir() { - return Err(format!("'{}' is a directory.", path.to_string_lossy()).into()); + return Err(format!("'{path_display}' is a directory.").into()); } if let Some(stdout) = stdout_identifier { - let input_identifier = Identifier::try_from(file).map_err(|e| { - format!("{}: Error identifying file: {e}", path.to_string_lossy()) - })?; + let input_identifier = Identifier::try_from(file) + .map_err(|e| format!("{path_display}: Error identifying file: {e}"))?; if stdout.surely_conflicts_with(&input_identifier) { return Err(format!( - "IO circle detected. The input from '{}' is also an output. Aborting to avoid infinite loop.", - path.to_string_lossy() + "IO circle detected. The input from '{path_display}' is also an output. Aborting to avoid infinite loop.", ) .into()); } diff --git a/src/lib.rs b/src/lib.rs index 4c60f10e..685214dc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,7 @@ mod vscreen; pub(crate) mod wrapping; pub use nonprintable_notation::{BinaryBehavior, NonprintableNotation}; +pub use preprocessor::sanitize_for_terminal; pub use preprocessor::StripAnsiMode; pub use pretty_printer::{Input, PrettyPrinter, Syntax}; pub use syntax_mapping::{MappingTarget, SyntaxMapping}; diff --git a/src/preprocessor.rs b/src/preprocessor.rs index 6b4e2935..a34f9f9e 100644 --- a/src/preprocessor.rs +++ b/src/preprocessor.rs @@ -149,6 +149,35 @@ pub fn strip_ansi(line: &str) -> String { buffer } +/// Escape C0, DEL, and C1 control characters so a string from an untrusted +/// filename or path can be safely written to the terminal. +pub fn sanitize_for_terminal(input: &str) -> String { + if !input + .chars() + .any(|c| matches!(c, '\x00'..='\x08' | '\x0A'..='\x1F' | '\x7F'..='\u{9F}')) + { + return input.to_owned(); + } + + let mut out = String::with_capacity(input.len() + 8); + for c in input.chars() { + match c { + '\t' => out.push('\t'), + '\x00'..='\x1F' => { + out.push('^'); + out.push(char::from_u32(0x40 + c as u32).unwrap_or('?')); + } + '\x7F' => out.push_str("^?"), + '\u{80}'..='\u{9F}' => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{{{:x}}}", c as u32); + } + other => out.push(other), + } + } + out +} + /// Strips overstrike sequences (backspace formatting) from input. /// /// Overstrike formatting is used by man pages and some help output: @@ -261,3 +290,40 @@ fn test_strip_overstrike() { // Unicode with overstrike assert_eq!(strip_overstrike("ä\x08äöü", 2), "äöü"); } + +#[test] +fn test_sanitize_for_terminal_passthrough() { + assert_eq!(sanitize_for_terminal(""), ""); + assert_eq!(sanitize_for_terminal("hello.txt"), "hello.txt"); + assert_eq!(sanitize_for_terminal("résumé.pdf"), "résumé.pdf"); + assert_eq!(sanitize_for_terminal("日本語.md"), "日本語.md"); + assert_eq!( + sanitize_for_terminal("path/with spaces/file.log"), + "path/with spaces/file.log" + ); + assert_eq!(sanitize_for_terminal("a\tb"), "a\tb"); +} + +#[test] +fn test_sanitize_for_terminal_c0_controls() { + assert_eq!( + sanitize_for_terminal("\x1b[31mINJECTED\x1b[0m.txt"), + "^[[31mINJECTED^[[0m.txt" + ); + assert_eq!(sanitize_for_terminal("bad\x07rest"), "bad^Grest"); + assert_eq!(sanitize_for_terminal("\x00\x08\n\r\x7F"), "^@^H^J^M^?"); + assert_eq!(sanitize_for_terminal("\u{9b}31m"), "\\u{9b}31m"); + assert_eq!( + sanitize_for_terminal("\u{9d}0;pwned\x07"), + "\\u{9d}0;pwned^G" + ); +} + +#[test] +fn test_sanitize_for_terminal_idempotent_on_sanitized() { + let dirty = "\x1b]0;pwned\x07file.txt"; + let clean = sanitize_for_terminal(dirty); + assert_eq!(sanitize_for_terminal(&clean), clean); + assert!(!clean.contains('\x1b')); + assert!(!clean.contains('\x07')); +} diff --git a/src/printer.rs b/src/printer.rs index c58c914d..17f7aa30 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -29,7 +29,9 @@ use crate::error::*; use crate::input::OpenedInput; use crate::line_range::{MaxBufferedLineNumber, RangeCheckResult}; use crate::output::OutputHandle; -use crate::preprocessor::{expand_tabs, replace_nonprintable, strip_ansi, strip_overstrike}; +use crate::preprocessor::{ + expand_tabs, replace_nonprintable, sanitize_for_terminal, strip_ansi, strip_overstrike, +}; use crate::style::StyleComponent; use crate::terminal::{as_terminal_escaped, to_ansi_color}; use crate::vscreen::{AnsiStyle, EscapeSequence, EscapeSequenceIterator}; @@ -489,7 +491,7 @@ impl Printer for InteractivePrinter<'_> { (but will be present if the output of 'bat' is piped). You can use 'bat -A' \ to show the binary file contents.", Yellow.paint("[bat warning]"), - input.description.summary(), + sanitize_for_terminal(&input.description.summary()), )?; } else if self.config.style_components.grid() { self.print_horizontal_line(handle, '┬')?; @@ -543,9 +545,11 @@ impl Printer for InteractivePrinter<'_> { "{}{}{mode}", description .kind() - .map(|kind| format!("{kind}: ")) + .map(|kind| format!("{}: ", sanitize_for_terminal(kind))) .unwrap_or_else(|| "".into()), - self.colors.header_value.paint(description.title()), + self.colors + .header_value + .paint(sanitize_for_terminal(description.title())), ); self.print_header_multiline_component(handle, &header_filename) }