From d72163160e0333a17ff35a0919945c6fbcac60e1 Mon Sep 17 00:00:00 2001 From: curious-rabbit Date: Wed, 1 Jul 2026 07:09:49 +0200 Subject: [PATCH 1/2] add sanitize option --- CHANGELOG.md | 2 + README.md | 4 + assets/completions/_bat.ps1.in | 6 + assets/completions/bat.bash.in | 5 + assets/completions/bat.fish.in | 1 + assets/completions/bat.zsh.in | 1 + doc/long-help.txt | 8 ++ src/bin/bat/app.rs | 43 +++++-- src/bin/bat/clap_app.rs | 18 +++ src/config.rs | 3 + src/preprocessor.rs | 173 +++++++++++++++++++++++++- src/pretty_printer.rs | 12 ++ src/printer.rs | 23 +++- src/vscreen.rs | 216 +++++++++++++++++++++++++++++++-- tests/integration_tests.rs | 152 +++++++++++++++++++++++ 15 files changed, 639 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78fdef3e..0e2fc216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ## Features +- Add a `--sanitize=` flag for safe display of untrusted input. It implies `--strip-ansi` at the same value and additionally substitutes terminal-active control bytes (cursor moves, charset switches, beep, etc.) and Unicode bidi / zero-width formatting characters with the Unicode replacement character (U+FFFD). Mitigates Trojan-Source-style spoofing (CVE-2021-42574). See #3729 (@curious-rabbit) - Map justfile, Justfile, .justfile, and *.justfile to Makefile syntax highlighting, see #3623 (@zachvalenta) - Preserve `--diff` change markers and snip separators when `--plain` is set. Closes #3630, see #3643 (@mvanhorn) - Added support for `hidden_file_extensions` from `.sublime-syntax` files, see #3613 (@Matei02355) @@ -22,6 +23,7 @@ - Syntax highlighting for Python files using uv as script runner in shebang #3689 (@janlarres) ## Bugfixes +- `--strip-ansi`: also strip 8-bit C1 introducers (U+0090, U+0098, U+009B, U+009D, U+009E, U+009F) and DCS/SOS/PM/APC sequence bodies, which previously passed through. See #3729 (@curious-rabbit) - Fix `--ignored-suffix` not falling back to first-line/shebang detection when the ignored suffix is also a registered extension (e.g. `--ignored-suffix .txt` on a shebang script), see #2745 and #3816 (@adnrivera) - Fix `capacity overflow` panic when printing a snip separator at `--terminal-width=1` with multiple line ranges. Closes #3803, see #3804 (@leeewee) - Pass `--no-paging` to `bat` invocations inside the bash / zsh / fish / PowerShell shell completion scripts so that shell-level pager wiring (e.g. `LESSOPEN='|-bat -f -pp %s'`) cannot inject ANSI escape sequences into the completion candidates. Closes #3760 (@mvanhorn) diff --git a/README.md b/README.md index 0f89875c..9eb4c095 100644 --- a/README.md +++ b/README.md @@ -843,6 +843,10 @@ If your version of `bat` supports the `--strip-ansi=auto` option, it can be used before syntax highlighting. Alternatively, you may disable both syntax highlighting and wrapping by passing the `--color=never --wrap=never` options to `bat`. +For untrusted input, the `--sanitize=auto|always|never` option additionally replaces terminal-active +control bytes and Unicode bidi / zero-width formatting characters with the Unicode replacement +character. It implies `--strip-ansi` at the same value. + > [!NOTE] > The `auto` option of `--strip-ansi` avoids removing escape sequences when the syntax is plain text. diff --git a/assets/completions/_bat.ps1.in b/assets/completions/_bat.ps1.in index b90e2a33..2f01f768 100644 --- a/assets/completions/_bat.ps1.in +++ b/assets/completions/_bat.ps1.in @@ -100,6 +100,11 @@ Register-ArgumentCompleter -Native -CommandName '{{PROJECT_EXECUTABLE}}' -Script ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, [CompletionResultType]::ParameterValue, $_)} break } + '*;--sanitize' { + $ArrayWhen | + ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, [CompletionResultType]::ParameterValue, $_)} + break + } '*;--strip-ansi' { $ArrayWhen | ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, [CompletionResultType]::ParameterValue, $_)} @@ -158,6 +163,7 @@ Register-ArgumentCompleter -Native -CommandName '{{PROJECT_EXECUTABLE}}' -Script [CompletionResult]::new('--ignored-suffix' , 'ignored-suffix' , [CompletionResultType]::ParameterName, 'Ignore extension. For example: ''bat --ignored-suffix ".dev" my_file.json.dev'' will use JSON syntax, and ignore ''.dev''') [CompletionResult]::new('--squeeze-blank' , 'squeeze-blank' , [CompletionResultType]::ParameterName, 'Squeeze consecutive empty lines into a single empty line.') [CompletionResult]::new('--squeeze-limit' , 'squeeze-limit' , [CompletionResultType]::ParameterName, 'Set the maximum number of consecutive empty lines to be printed.') + [CompletionResult]::new('--sanitize' , 'sanitize' , [CompletionResultType]::ParameterName, 'Specify when to sanitize untrusted input for safe display. Implies --strip-ansi and also replaces terminal-active and bidi / zero-width bytes. (auto, always, *never*).') [CompletionResult]::new('--strip-ansi' , 'strip-ansi' , [CompletionResultType]::ParameterName, 'Specify when to strip ANSI escape sequences from the input. The automatic mode will remove escape sequences unless the syntax highlighting language is plain text. (auto, always, *never*).') # [CompletionResult]::new('-p' , 'p' , [CompletionResultType]::ParameterName, 'Show plain style (alias for ''--style=plain'').') [CompletionResult]::new('--plain' , 'plain' , [CompletionResultType]::ParameterName, 'Show plain style (alias for ''--style=plain'').') diff --git a/assets/completions/bat.bash.in b/assets/completions/bat.bash.in index 813c532e..5c4f646b 100644 --- a/assets/completions/bat.bash.in +++ b/assets/completions/bat.bash.in @@ -132,6 +132,10 @@ _bat() { COMPREPLY=($(compgen -W "auto never always" -- "$cur")) return 0 ;; + --sanitize) + COMPREPLY=($(compgen -W "auto never always" -- "$cur")) + return 0 + ;; --completion) COMPREPLY=($(compgen -W "bash fish zsh ps1" -- "$cur")) return 0 @@ -221,6 +225,7 @@ _bat() { --list-themes --squeeze-blank --squeeze-limit + --sanitize --strip-ansi --style --line-range diff --git a/assets/completions/bat.fish.in b/assets/completions/bat.fish.in index 9d993cb4..b2e35664 100644 --- a/assets/completions/bat.fish.in +++ b/assets/completions/bat.fish.in @@ -221,6 +221,7 @@ complete -c $bat -s s -l squeeze-blank -d "Squeeze consecutive empty lines into complete -c $bat -l squeeze-limit -x -d "Set the maximum number of consecutive empty lines to be printed" -n __bat_no_excl_args complete -c $bat -l strip-ansi -x -a "auto never always" -d "Specify when to strip ANSI escape sequences from the input" -n __bat_no_excl_args +complete -c $bat -l sanitize -x -a "auto never always" -d "Specify when to sanitize untrusted input for safe display" -n __bat_no_excl_args complete -c $bat -s p -l plain -d "Disable decorations" -n __bat_no_excl_args diff --git a/assets/completions/bat.zsh.in b/assets/completions/bat.zsh.in index 94001875..775ae9df 100644 --- a/assets/completions/bat.zsh.in +++ b/assets/completions/bat.zsh.in @@ -54,6 +54,7 @@ _{{PROJECT_EXECUTABLE}}_main() { --squeeze-blank'[squeeze consecutive empty lines into a single empty line]' --squeeze-limit='[set the maximum number of consecutive empty lines]:limit:' --strip-ansi='[specify when to strip ANSI escape sequences]:when:(auto never always)' + --sanitize='[specify when to sanitize untrusted input for safe display]:when:(auto never always)' --style='[comma-separated list of style elements to display]: : _values "style [default]" default auto full plain changes header header-filename header-filesize grid rule numbers snip' \*{-r+,--line-range=}'[only print the specified line range]:start\:end' diff --git a/doc/long-help.txt b/doc/long-help.txt index 7d3cc0e5..5b549e07 100644 --- a/doc/long-help.txt +++ b/doc/long-help.txt @@ -164,6 +164,14 @@ Options: escape sequences unless the syntax highlighting language is plain text. Possible values: auto, always, *never*. + --sanitize + Specify when to sanitize input bytes for safe terminal display. Implies --strip-ansi to + the same value, and additionally substitutes terminal-active control bytes (cursor moves, + charset switches, beep, etc.) and Unicode bidi / zero-width formatting characters with the + Unicode replacement character (U+FFFD). Tab, LF, FF, and CRLF pass through. Useful for + displaying untrusted file content (e.g. file-manager preview panes). Possible values: + auto, always, *never*. + --style Configure which elements (line numbers, file headers, grid borders, Git modifications, ..) to display in addition to the file contents. The argument is a comma-separated list of diff --git a/src/bin/bat/app.rs b/src/bin/bat/app.rs index 3124cdcd..cc37075c 100644 --- a/src/bin/bat/app.rs +++ b/src/bin/bat/app.rs @@ -38,6 +38,15 @@ pub fn env_no_color() -> bool { env::var_os("NO_COLOR").is_some_and(|x| !x.is_empty()) } +fn parse_strip_ansi_value(raw: Option<&str>, flag_name: &str) -> StripAnsiMode { + match raw { + Some("never") | None => StripAnsiMode::Never, + Some("always") => StripAnsiMode::Always, + Some("auto") => StripAnsiMode::Auto, + _ => unreachable!("other values for {flag_name} are not allowed"), + } +} + enum HelpType { Short, Long, @@ -458,16 +467,32 @@ impl App { 4 }, ), - strip_ansi: match self - .matches - .get_one::("strip-ansi") - .map(|s| s.as_str()) - { - Some("never") => StripAnsiMode::Never, - Some("always") => StripAnsiMode::Always, - Some("auto") => StripAnsiMode::Auto, - _ => unreachable!("other values for --strip-ansi are not allowed"), + strip_ansi: { + let sanitize = parse_strip_ansi_value( + self.matches + .get_one::("sanitize") + .map(|s| s.as_str()), + "--sanitize", + ); + let strip_ansi = parse_strip_ansi_value( + self.matches + .get_one::("strip-ansi") + .map(|s| s.as_str()), + "--strip-ansi", + ); + // --sanitize implies --strip-ansi to the same value. + if sanitize != StripAnsiMode::Never { + sanitize + } else { + strip_ansi + } }, + sanitize: parse_strip_ansi_value( + self.matches + .get_one::("sanitize") + .map(|s| s.as_str()), + "--sanitize", + ), quiet_empty: self.matches.get_flag("quiet-empty"), unbuffered: self.matches.get_flag("unbuffered"), theme: theme(self.theme_options()).to_string(), diff --git a/src/bin/bat/clap_app.rs b/src/bin/bat/clap_app.rs index 6dfdf829..decd0364 100644 --- a/src/bin/bat/clap_app.rs +++ b/src/bin/bat/clap_app.rs @@ -480,6 +480,24 @@ pub fn build_app(interactive_output: bool) -> Command { language is plain text. Possible values: auto, always, *never*.") .hide_short_help(true) ) + .arg( + Arg::new("sanitize") + .long("sanitize") + .overrides_with("sanitize") + .value_name("when") + .value_parser(["auto", "always", "never"]) + .default_value("never") + .hide_default_value(true) + .help("Sanitize untrusted input for safe display (auto, always, *never*)") + .long_help("Specify when to sanitize input bytes for safe terminal display. \ + Implies --strip-ansi to the same value, and additionally substitutes \ + terminal-active control bytes (cursor moves, charset switches, beep, etc.) \ + and Unicode bidi / zero-width formatting characters with the Unicode \ + replacement character (U+FFFD). Tab, LF, FF, and CRLF pass through. Useful \ + for displaying untrusted file content (e.g. file-manager preview panes). \ + Possible values: auto, always, *never*.") + .hide_short_help(true) + ) .arg( Arg::new("style") .long("style") diff --git a/src/config.rs b/src/config.rs index 97720fb5..121097d9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -111,6 +111,9 @@ pub struct Config<'a> { // Whether or not to strip ANSI escape codes from the input pub strip_ansi: StripAnsiMode, + // Substitute terminal-active and spoofing-relevant bytes; implies strip_ansi. + pub sanitize: StripAnsiMode, + /// Whether or not to produce no output when input is empty pub quiet_empty: bool, diff --git a/src/preprocessor.rs b/src/preprocessor.rs index a34f9f9e..b8e326c7 100644 --- a/src/preprocessor.rs +++ b/src/preprocessor.rs @@ -139,16 +139,106 @@ pub fn replace_nonprintable( /// Strips ANSI escape sequences from the input. pub fn strip_ansi(line: &str) -> String { let mut buffer = String::with_capacity(line.len()); - for seq in EscapeSequenceOffsetsIterator::new(line) { if let EscapeSequenceOffsets::Text { .. } = seq { buffer.push_str(&line[seq.index_of_start()..seq.index_past_end()]); } } - buffer } +/// Strips ANSI escape sequences and substitutes terminal-active control bytes +/// and visual-spoofing Unicode codepoints (bidi, zero-width) with U+FFFD. +pub fn sanitize(line: &str) -> String { + let stripped = strip_ansi(line); + let mut buffer = String::with_capacity(stripped.len()); + let bytes = stripped.as_bytes(); + let mut start = 0; + let mut i = 0; + while i < bytes.len() { + if !is_sanitize_trigger(bytes[i]) { + i += 1; + continue; + } + let len = sanitize_at(bytes, i, &stripped, &mut buffer, &mut start); + i += len; + } + buffer.push_str(&stripped[start..]); + buffer +} + +#[inline] +fn is_sanitize_trigger(b: u8) -> bool { + // C0 controls minus \t \n \f; DEL; UTF-8 leads with dangerous codepoints. + matches!(b, 0x00..=0x08 | 0x0B | 0x0D..=0x1F | 0x7F | 0xC2 | 0xE2 | 0xEF) +} + +/// Substitutes the byte/sequence at `bytes[i]` (or passes it through on +/// false-alarm trigger), flushing the prefix from `start`. Returns bytes consumed. +fn sanitize_at( + bytes: &[u8], + i: usize, + full: &str, + buffer: &mut String, + start: &mut usize, +) -> usize { + buffer.push_str(&full[*start..i]); + let consumed = match bytes[i] { + b'\r' if bytes.get(i + 1) == Some(&b'\n') => { + buffer.push_str("\r\n"); + 2 + } + // 0xC2 leads U+0080..U+00FF; filter the C1 range. + 0xC2 if matches!(bytes.get(i + 1), Some(0x80..=0x9F)) => { + buffer.push('\u{FFFD}'); + 2 + } + 0xE2 if is_dangerous_e2(bytes, i) => { + buffer.push('\u{FFFD}'); + 3 + } + // 0xEF 0xBB 0xBF = U+FEFF (BOM / zero-width no-break space). + 0xEF if bytes.get(i + 1) == Some(&0xBB) && bytes.get(i + 2) == Some(&0xBF) => { + buffer.push('\u{FFFD}'); + 3 + } + // False-alarm trigger: pass the full UTF-8 sequence through. + lead @ (0xC2 | 0xE2 | 0xEF) => { + let n = utf8_len_from_lead(lead); + buffer.push_str(&full[i..i + n]); + n + } + _ => { + buffer.push('\u{FFFD}'); + 1 + } + }; + *start = i + consumed; + consumed +} + +#[inline] +fn is_dangerous_e2(bytes: &[u8], i: usize) -> bool { + // U+200B..D (zero-width), U+202A..E (bidi controls), U+2066..9 (isolates). + matches!( + (bytes.get(i + 1), bytes.get(i + 2)), + (Some(0x80), Some(0x8B..=0x8D | 0xAA..=0xAE)) | (Some(0x81), Some(0xA6..=0xA9)) + ) +} + +#[inline] +fn utf8_len_from_lead(lead: u8) -> usize { + if lead < 0x80 { + 1 + } else if lead < 0xE0 { + 2 + } else if lead < 0xF0 { + 3 + } else { + 4 + } +} + /// 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 { @@ -270,6 +360,85 @@ fn test_strip_ansi() { ); } +#[test] +fn test_strip_ansi_8bit_c1_introducers() { + assert_eq!(strip_ansi("a\u{9B}31mRED\u{9B}0mb"), "aREDb"); + assert_eq!(strip_ansi("a\x1bP1;0|payload\x1b\\b"), "ab"); + assert_eq!(strip_ansi("a\u{90}body\u{9C}b"), "ab"); +} + +#[test] +fn test_strip_ansi_single_char_esc() { + // strip_ansi must consume both bytes of single-byte ESC sequences (RIS, DECSC, keypad, VT52). + assert_eq!(strip_ansi("a\x1bcb"), "ab"); + assert_eq!(strip_ansi("a\x1b7b\x1b8c"), "abc"); + assert_eq!(strip_ansi("a\x1b=b\x1b>c"), "abc"); + assert_eq!(strip_ansi("a\x1bZb"), "ab"); +} + +#[test] +fn test_strip_ansi_preserves_control_bytes() { + // strip_ansi removes only ANSI escape sequences; control bytes pass through. + assert_eq!(strip_ansi("safe\rEVIL"), "safe\rEVIL"); + assert_eq!(strip_ansi("a\x08b\x07c\x0E\x0Fd"), "a\x08b\x07c\x0E\x0Fd"); +} + +#[test] +fn test_sanitize_substitutes_dangerous_bytes() { + let r = '\u{FFFD}'; + assert_eq!(sanitize("safe\rEVIL"), format!("safe{r}EVIL")); + assert_eq!(sanitize("a\x08b"), format!("a{r}b")); + assert_eq!(sanitize("a\x07b"), format!("a{r}b")); + assert_eq!(sanitize("a\x0Bb"), format!("a{r}b")); + assert_eq!(sanitize("a\x0Eb\x0Fc"), format!("a{r}b{r}c")); + assert_eq!(sanitize("a\u{8D}b"), format!("a{r}b")); + assert_eq!(sanitize("a\u{85}b"), format!("a{r}b")); + assert_eq!(sanitize("trailing\r"), format!("trailing{r}")); + assert_eq!(sanitize("a\x7Fb"), format!("a{r}b")); +} + +#[test] +fn test_sanitize_substitutes_bidi_and_zero_width() { + let r = '\u{FFFD}'; + // Trojan-Source bidi formatting: U+202A..U+202E + assert_eq!(sanitize("a\u{202A}b"), format!("a{r}b")); + assert_eq!(sanitize("a\u{202E}b"), format!("a{r}b")); + // Bidi isolates: U+2066..U+2069 + assert_eq!(sanitize("a\u{2066}b"), format!("a{r}b")); + assert_eq!(sanitize("a\u{2069}b"), format!("a{r}b")); + // Zero-width: U+200B..U+200D + assert_eq!(sanitize("a\u{200B}b"), format!("a{r}b")); + assert_eq!(sanitize("a\u{200D}b"), format!("a{r}b")); + // BOM in middle of file: U+FEFF + assert_eq!(sanitize("a\u{FEFF}b"), format!("a{r}b")); +} + +#[test] +fn test_sanitize_preserves_legitimate_bytes() { + assert_eq!(sanitize("crlf\r\nline\r\n"), "crlf\r\nline\r\n"); + assert_eq!(sanitize("a\tb\nc"), "a\tb\nc"); + assert_eq!(sanitize("plain ascii"), "plain ascii"); + assert_eq!(sanitize("üñíçödé"), "üñíçödé"); + // FF (U+000C) passes through; section separator in C source / Emacs Lisp. + assert_eq!(sanitize("section1\x0Csection2"), "section1\x0Csection2"); + // Common Unicode that shares a UTF-8 lead byte with dangerous codepoints + // must pass through unchanged. + assert_eq!(sanitize("snowman ☃ moon ☾"), "snowman ☃ moon ☾"); + assert_eq!(sanitize("emoji 🎉 ☃"), "emoji 🎉 ☃"); + assert_eq!(sanitize("0xC2 lead: ÿ ñ ç"), "0xC2 lead: ÿ ñ ç"); + assert_eq!(sanitize("CJK 漢字 emoji 🦀"), "CJK 漢字 emoji 🦀"); +} + +#[test] +fn test_sanitize_strips_ansi() { + let r = '\u{FFFD}'; + // sanitize is a strict superset of strip_ansi. + assert_eq!(sanitize("a\x1B[31mb\x1B[0mc"), "abc"); + assert_eq!(sanitize("a\u{9B}31mb\u{9B}0mc"), "abc"); + // ANSI then dangerous byte: ANSI gone, byte substituted. + assert_eq!(sanitize("\x1B[31mhello\rEVIL"), format!("hello{r}EVIL")); +} + #[test] fn test_strip_overstrike() { // Bold: X\x08X (same char repeated) diff --git a/src/pretty_printer.rs b/src/pretty_printer.rs index 89a9854e..582e972b 100644 --- a/src/pretty_printer.rs +++ b/src/pretty_printer.rs @@ -192,6 +192,18 @@ impl<'a> PrettyPrinter<'a> { self } + /// Whether to sanitize untrusted input for safe display (default: never) + /// + /// Implies [`strip_ansi`](Self::strip_ansi) at the same value, and also + /// replaces terminal-active and bidi / zero-width bytes with U+FFFD. + pub fn sanitize(&mut self, mode: StripAnsiMode) -> &mut Self { + self.config.sanitize = mode; + if mode != StripAnsiMode::Never { + self.config.strip_ansi = mode; + } + self + } + /// Text wrapping mode (default: do not wrap) pub fn wrapping_mode(&mut self, mode: WrappingMode) -> &mut Self { self.config.wrapping_mode = mode; diff --git a/src/printer.rs b/src/printer.rs index fc8a70ba..42405902 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -30,7 +30,8 @@ use crate::input::OpenedInput; use crate::line_range::{MaxBufferedLineNumber, RangeCheckResult}; use crate::output::OutputHandle; use crate::preprocessor::{ - expand_tabs, replace_nonprintable, sanitize_for_terminal, strip_ansi, strip_overstrike, + expand_tabs, replace_nonprintable, sanitize, sanitize_for_terminal, strip_ansi, + strip_overstrike, }; use crate::style::StyleComponent; use crate::terminal::{as_terminal_escaped, to_ansi_color}; @@ -210,6 +211,7 @@ pub(crate) struct InteractivePrinter<'a> { background_color_highlight: Option, consecutive_empty_lines: usize, strip_ansi: bool, + sanitize: bool, strip_overstrike: bool, } @@ -273,7 +275,9 @@ impl<'a> InteractivePrinter<'a> { let needs_to_match_syntax = (!is_printing_binary || matches!(config.binary, BinaryBehavior::AsText)) - && (config.colored_output || config.strip_ansi == StripAnsiMode::Auto); + && (config.colored_output + || config.strip_ansi == StripAnsiMode::Auto + || config.sanitize == StripAnsiMode::Auto); let (is_plain_text, strip_overstrike, highlighter_from_set) = if needs_to_match_syntax { // Determine the type of syntax for highlighting @@ -319,6 +323,14 @@ impl<'a> InteractivePrinter<'a> { _ => false, }; + let sanitize = match config.sanitize { + _ if config.show_nonprintable => false, + StripAnsiMode::Always => true, + StripAnsiMode::Auto if is_plain_text => false, + StripAnsiMode::Auto => true, + _ => false, + }; + Ok(InteractivePrinter { panel_width, colors, @@ -332,6 +344,7 @@ impl<'a> InteractivePrinter<'a> { background_color_highlight, consecutive_empty_lines: 0, strip_ansi, + sanitize, strip_overstrike, }) } @@ -675,8 +688,10 @@ impl Printer for InteractivePrinter<'_> { } } - // If ANSI escape sequences are supposed to be stripped, do it before syntax highlighting. - if self.strip_ansi { + // Sanitize is the strict superset; otherwise strip-ansi alone. + if self.sanitize { + line = sanitize(&line).into() + } else if self.strip_ansi { line = strip_ansi(&line).into() } diff --git a/src/vscreen.rs b/src/vscreen.rs index 3acbb72f..6478f577 100644 --- a/src/vscreen.rs +++ b/src/vscreen.rs @@ -386,12 +386,23 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> { } fn next_text(&mut self) -> Option { - self.chars_take_while(|c| c != '\x1B') + self.chars_take_while(|c| !is_sequence_introducer(c)) .map(|(start, end)| EscapeSequenceOffsets::Text { start, end }) } fn next_sequence(&mut self) -> Option { let (start_sequence, c) = self.chars.next().expect("to not be finished"); + + // Handle 8-bit C1 introducers as their 7-bit `ESC ` equivalents. + match c { + '\u{9B}' => return self.next_csi_body(start_sequence), + '\u{9D}' => return self.next_osc_body(start_sequence), + '\u{90}' | '\u{98}' | '\u{9E}' | '\u{9F}' => { + return self.next_string_terminated_body(start_sequence) + } + _ => {} + } + match self.chars.peek() { None => Some(EscapeSequenceOffsets::Unknown { start: start_sequence, @@ -400,12 +411,24 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> { Some((_, ']')) => self.next_osc(start_sequence), Some((_, '[')) => self.next_csi(start_sequence), - Some((i, c)) => match c { + // 7-bit DCS/SOS/PM/APC: `ESC P/X/^/_` introduces a string body. + Some((_, 'P' | 'X' | '^' | '_')) => { + self.chars.next(); + self.next_string_terminated_body(start_sequence) + } + Some(&(i, c)) => match c { '\x20'..='\x2F' => self.next_nf(start_sequence), - c => Some(EscapeSequenceOffsets::Unknown { - start: start_sequence, - end: i + c.len_utf8(), - }), + c => { + // Single-byte ESC sequence (RIS, DECSC/DECRC, keypad, VT52 etc.). + let end = match self.chars.next() { + Some((j, fc)) => j + fc.len_utf8(), + None => i + c.len_utf8(), + }; + Some(EscapeSequenceOffsets::Unknown { + start: start_sequence, + end, + }) + } }, } } @@ -413,12 +436,27 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> { fn next_osc(&mut self, start_sequence: usize) -> Option { let (osc_open_index, osc_open_char) = self.chars.next().expect("to not be finished"); debug_assert_eq!(osc_open_char, ']'); + let start_command = osc_open_index + osc_open_char.len_utf8(); + Some(self.read_osc_body(start_sequence, start_command)) + } + /// OSC body parser entered after the 8-bit introducer U+009D was consumed. + fn next_osc_body(&mut self, start_sequence: usize) -> Option { + let start_command = start_sequence + '\u{9D}'.len_utf8(); + Some(self.read_osc_body(start_sequence, start_command)) + } + + fn read_osc_body( + &mut self, + start_sequence: usize, + start_command: usize, + ) -> EscapeSequenceOffsets { let mut start_terminator: usize; let mut end_sequence: usize; loop { - match self.chars_take_while(|c| !matches!(c, '\x07' | '\x1B')) { + // ST is BEL, ESC `\\`, or U+009C. + match self.chars_take_while(|c| !matches!(c, '\x07' | '\x1B' | '\u{9C}')) { None => { start_terminator = self.text.len(); end_sequence = start_terminator; @@ -437,6 +475,11 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> { break; } + Some((ti, '\u{9C}')) => { + end_sequence = ti + '\u{9C}'.len_utf8(); + break; + } + Some((ti, '\x1B')) => { match self.chars.next() { Some((i, '\\')) => { @@ -466,20 +509,82 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> { } } - Some(EscapeSequenceOffsets::OSC { + EscapeSequenceOffsets::OSC { start_sequence, - start_command: osc_open_index + osc_open_char.len_utf8(), + start_command, start_terminator, end: end_sequence, + } + } + + /// DCS/SOS/PM/APC body parser. Emitted as `Unknown` so the body is stripped. + fn next_string_terminated_body( + &mut self, + start_sequence: usize, + ) -> Option { + let mut end_sequence: usize; + + loop { + match self.chars_take_while(|c| !matches!(c, '\x07' | '\x1B' | '\u{9C}')) { + None => { + end_sequence = self.text.len(); + break; + } + Some((_, end)) => { + end_sequence = end; + } + } + + match self.chars.next() { + Some((ti, '\x07')) => { + end_sequence = ti + '\x07'.len_utf8(); + break; + } + Some((ti, '\u{9C}')) => { + end_sequence = ti + '\u{9C}'.len_utf8(); + break; + } + Some((ti, '\x1B')) => match self.chars.next() { + Some((i, '\\')) => { + end_sequence = i + '\\'.len_utf8(); + break; + } + None => { + end_sequence = ti + '\x1B'.len_utf8(); + break; + } + _ => {} + }, + None => break, + Some((_, tc)) => { + panic!("this should not be reached: char {tc:?}") + } + } + } + + Some(EscapeSequenceOffsets::Unknown { + start: start_sequence, + end: end_sequence, }) } fn next_csi(&mut self, start_sequence: usize) -> Option { let (csi_open_index, csi_open_char) = self.chars.next().expect("to not be finished"); debug_assert_eq!(csi_open_char, '['); + Some(self.read_csi_body(start_sequence, csi_open_index + csi_open_char.len_utf8())) + } - let start_parameters: usize = csi_open_index + csi_open_char.len_utf8(); + /// CSI body parser entered after the 8-bit introducer U+009B was consumed. + fn next_csi_body(&mut self, start_sequence: usize) -> Option { + let start_parameters = start_sequence + '\u{9B}'.len_utf8(); + Some(self.read_csi_body(start_sequence, start_parameters)) + } + fn read_csi_body( + &mut self, + start_sequence: usize, + start_parameters: usize, + ) -> EscapeSequenceOffsets { // Keep iterating while within the range of `0x30-0x3F`. let mut start_intermediates: usize = start_parameters; if let Some((_, end)) = self.chars_take_while(|c| matches!(c, '\x30'..='\x3F')) { @@ -498,13 +603,13 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> { Some((i, c)) => i + c.len_utf8(), }; - Some(EscapeSequenceOffsets::CSI { + EscapeSequenceOffsets::CSI { start_sequence, start_parameters, start_intermediates, start_final_byte, end: end_of_sequence, - }) + } } fn next_nf(&mut self, start_sequence: usize) -> Option { @@ -543,13 +648,25 @@ impl Iterator for EscapeSequenceOffsetsIterator<'_> { type Item = EscapeSequenceOffsets; fn next(&mut self) -> Option { match self.chars.peek() { - Some((_, '\x1B')) => self.next_sequence(), + Some((_, c)) if is_sequence_introducer(*c) => self.next_sequence(), Some((_, _)) => self.next_text(), None => None, } } } +/// True for ESC and the 8-bit C1 sequence introducers (DCS/SOS/CSI/OSC/PM/APC). +#[inline] +fn is_sequence_introducer(c: char) -> bool { + if (c as u32) < 0x80 { + return c == '\x1B'; + } + matches!( + c, + '\u{90}' | '\u{98}' | '\u{9B}' | '\u{9D}' | '\u{9E}' | '\u{9F}' + ) +} + /// An iterator over ANSI/VT escape sequences within a string. /// /// ## Example @@ -717,6 +834,79 @@ mod tests { ); } + #[test] + fn test_escape_sequence_offsets_iterator_parses_8bit_csi() { + let mut iter = EscapeSequenceOffsetsIterator::new("\u{9B}31m"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::CSI { + start_sequence: 0, + start_parameters: 2, + start_intermediates: 4, + start_final_byte: 4, + end: 5, + }) + ); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_escape_sequence_offsets_iterator_parses_8bit_osc_with_8bit_st() { + let mut iter = EscapeSequenceOffsetsIterator::new("\u{9D}0;title\u{9C}"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::OSC { + start_sequence: 0, + start_command: 2, + start_terminator: 9, + end: 11, + }) + ); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_escape_sequence_offsets_iterator_parses_7bit_dcs_consumes_body() { + let mut iter = EscapeSequenceOffsetsIterator::new("\x1BP1;0;|payload\x1B\\rest"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::Unknown { start: 0, end: 16 }) + ); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::Text { start: 16, end: 20 }) + ); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_escape_sequence_offsets_iterator_8bit_dcs_consumes_body() { + let mut iter = EscapeSequenceOffsetsIterator::new("\u{90}body\u{9C}rest"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::Unknown { start: 0, end: 8 }) + ); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::Text { start: 8, end: 12 }) + ); + } + + #[test] + fn test_escape_sequence_offsets_iterator_truncated_dcs_consumes_to_eof() { + // Unterminated DCS must still be consumed, not emitted as `Text`. + let input = "\x1BPno-terminator"; + let mut iter = EscapeSequenceOffsetsIterator::new(input); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::Unknown { + start: 0, + end: input.len(), + }) + ); + assert_eq!(iter.next(), None); + } + #[test] fn test_escape_sequence_offsets_iterator_parses_csi() { let mut iter = EscapeSequenceOffsetsIterator::new("\x1B[m"); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 3366bf0e..33de3fbf 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -3844,6 +3844,158 @@ fn strip_ansi_auto_does_not_strip_ansi_when_plain_text_by_option() { assert!(output.contains("\x1B[33mYellow")) } +#[test] +fn sanitize_implies_strip_ansi() { + bat() + .arg("--style=plain") + .arg("--decorations=always") + .arg("--color=never") + .arg("--sanitize=always") + .write_stdin("\x1B[33mYellow\x1B[m") + .assert() + .success() + .stdout("Yellow"); +} + +#[test] +fn sanitize_strips_osc_clipboard_hijack() { + // OSC 52 sets the system clipboard. A file containing this would silently + // overwrite the user's clipboard if displayed unfiltered. + bat() + .arg("--style=plain") + .arg("--decorations=always") + .arg("--color=never") + .arg("--sanitize=always") + .write_stdin("safe\x1B]52;c;cm0=\x07payload") + .assert() + .success() + .stdout("safepayload"); +} + +#[test] +fn sanitize_strips_osc_8_hyperlink_spoof() { + // OSC 8 hyperlinks let displayed text point to an arbitrary URL. + bat() + .arg("--style=plain") + .arg("--decorations=always") + .arg("--color=never") + .arg("--sanitize=always") + .write_stdin("\x1B]8;;https://evil.example\x07click here\x1B]8;;\x07") + .assert() + .success() + .stdout("click here"); +} + +#[test] +fn sanitize_strips_window_title_injection() { + // OSC 0/1/2 set the terminal window title. + bat() + .arg("--style=plain") + .arg("--decorations=always") + .arg("--color=never") + .arg("--sanitize=always") + .write_stdin("hello\x1B]0;evil-title\x07world") + .assert() + .success() + .stdout("helloworld"); +} + +#[test] +fn sanitize_strips_8bit_csi() { + // 8-bit CSI introducer (U+009B) is the single-codepoint equivalent of ESC [. + bat() + .arg("--style=plain") + .arg("--decorations=always") + .arg("--color=never") + .arg("--sanitize=always") + .write_stdin("a\u{9B}31mRED\u{9B}0mb") + .assert() + .success() + .stdout("aREDb"); +} + +#[test] +fn sanitize_substitutes_bare_cr() { + // Bare CR (not part of CRLF) is the line-overwrite forgery vector. + bat() + .arg("--style=plain") + .arg("--decorations=always") + .arg("--color=never") + .arg("--sanitize=always") + .write_stdin("safe\rEVIL") + .assert() + .success() + .stdout("safe\u{FFFD}EVIL"); +} + +#[test] +fn sanitize_preserves_crlf() { + bat() + .arg("--style=plain") + .arg("--decorations=always") + .arg("--color=never") + .arg("--sanitize=always") + .write_stdin("line1\r\nline2\r\n") + .assert() + .success() + .stdout("line1\r\nline2\r\n"); +} + +#[test] +fn sanitize_substitutes_bidi_controls() { + // Trojan-Source attack (CVE-2021-42574): U+202E (RLO) reorders display. + bat() + .arg("--style=plain") + .arg("--decorations=always") + .arg("--color=never") + .arg("--sanitize=always") + .write_stdin("admin\u{202E}check") + .assert() + .success() + .stdout("admin\u{FFFD}check"); +} + +#[test] +fn sanitize_substitutes_zero_width() { + // Zero-width chars allow invisible content / identifier confusion. + bat() + .arg("--style=plain") + .arg("--decorations=always") + .arg("--color=never") + .arg("--sanitize=always") + .write_stdin("ad\u{200B}min") + .assert() + .success() + .stdout("ad\u{FFFD}min"); +} + +#[test] +fn sanitize_preserves_form_feed_in_source() { + // FF (U+000C) is used as a section separator in C source and Emacs Lisp. + bat() + .arg("--style=plain") + .arg("--decorations=always") + .arg("--color=never") + .arg("--sanitize=always") + .write_stdin("section1\x0Csection2") + .assert() + .success() + .stdout("section1\x0Csection2"); +} + +#[test] +fn sanitize_preserves_unicode_text() { + bat() + .arg("--style=plain") + .arg("--decorations=always") + .arg("--color=never") + .arg("--sanitize=always") + .write_stdin("snowman ☃ CJK 漢字 emoji 🦀") + .assert() + .success() + .stdout("snowman ☃ CJK 漢字 emoji 🦀"); +} + // Tests that style components can be removed with `-component`. #[test] fn style_components_can_be_removed() { From 0a52e4321f808f57a91174a98daeab84e7f7de2a Mon Sep 17 00:00:00 2001 From: curious-rabbit Date: Wed, 1 Jul 2026 07:54:51 +0200 Subject: [PATCH 2/2] improve patch --- src/preprocessor.rs | 8 +++----- src/pretty_printer.rs | 7 ++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/preprocessor.rs b/src/preprocessor.rs index b8e326c7..971ae3d4 100644 --- a/src/preprocessor.rs +++ b/src/preprocessor.rs @@ -155,11 +155,9 @@ pub fn sanitize(line: &str) -> String { let bytes = stripped.as_bytes(); let mut start = 0; let mut i = 0; - while i < bytes.len() { - if !is_sanitize_trigger(bytes[i]) { - i += 1; - continue; - } + // Skip directly to the next trigger byte instead of testing each one. + while let Some(off) = bytes[i..].iter().position(|&b| is_sanitize_trigger(b)) { + i += off; let len = sanitize_at(bytes, i, &stripped, &mut buffer, &mut start); i += len; } diff --git a/src/pretty_printer.rs b/src/pretty_printer.rs index 582e972b..1dc510f4 100644 --- a/src/pretty_printer.rs +++ b/src/pretty_printer.rs @@ -194,13 +194,10 @@ impl<'a> PrettyPrinter<'a> { /// Whether to sanitize untrusted input for safe display (default: never) /// - /// Implies [`strip_ansi`](Self::strip_ansi) at the same value, and also - /// replaces terminal-active and bidi / zero-width bytes with U+FFFD. + /// Strips ANSI escape sequences and additionally substitutes terminal-active + /// control bytes and bidi / zero-width codepoints with U+FFFD. pub fn sanitize(&mut self, mode: StripAnsiMode) -> &mut Self { self.config.sanitize = mode; - if mode != StripAnsiMode::Never { - self.config.strip_ansi = mode; - } self }