Skip to main content

datui_lib/
sanitize.rs

1//! Keeping untrusted text from becoming terminal commands.
2//!
3//! datui displays text it did not write: cell values, column names, filenames,
4//! and parser error messages all originate in whatever file the user opened.
5//! A terminal does not distinguish text from commands, so a cell containing
6//! `\x1b]52;c;...\x07` is a clipboard write, and one containing `\x1b[2J` wipes
7//! the screen. That is the standard vulnerability class for any program that
8//! renders foreign text.
9//!
10//! ratatui defends against this in [`Buffer::set_stringn`], which drops
11//! graphemes containing control characters. It does **not** defend against it
12//! in `Span` and `Line` rendering, which is what almost everything actually
13//! uses: `Span::render_ref` appends zero-width graphemes to the preceding cell,
14//! and an ESC is zero-width. The crossterm backend then writes each cell symbol
15//! out with `Print`, unfiltered, and the escape reaches the terminal intact.
16//!
17//! Sanitising at the roughly two hundred places that build a `Span` would work
18//! until someone adds the two hundred and first. So the sweep happens once, at
19//! the end of `App::render`, over the finished buffer. Every path into the
20//! screen has converged by then, including paths added later and paths nobody
21//! remembered to audit.
22//!
23//! [`Buffer::set_stringn`]: ratatui::buffer::Buffer::set_stringn
24
25use ratatui::buffer::Buffer;
26
27/// What a stripped control character is replaced with.
28///
29/// A visible marker rather than deletion: silently dropping bytes would let a
30/// hostile value disguise itself as a different, plausible value. Seeing
31/// `total: 1<?>0` is a hint that something is wrong with the data, where
32/// `total: 10` is a lie.
33const REPLACEMENT: char = '\u{fffd}';
34
35/// True for characters that must never reach the terminal from untrusted text.
36///
37/// The C0 controls, DEL, and the C1 range, which some terminals accept as
38/// single-byte equivalents of the two-byte escape sequences (`\u{009b}` for
39/// CSI, for instance). Tab is allowed through: it is layout rather than
40/// control, and ratatui may place one legitimately.
41#[inline]
42fn is_forbidden(c: char) -> bool {
43    let n = c as u32;
44    (n < 0x20 && c != '\t') || n == 0x7f || (0x80..=0x9f).contains(&n)
45}
46
47/// Returns a display-safe copy of `s`, or `None` if it was already safe.
48///
49/// The `None` case is the common one by a wide margin, and returning it avoids
50/// allocating for the overwhelming majority of cells.
51pub fn sanitized(s: &str) -> Option<String> {
52    if !s.chars().any(is_forbidden) {
53        return None;
54    }
55    Some(
56        s.chars()
57            .map(|c| if is_forbidden(c) { REPLACEMENT } else { c })
58            .collect(),
59    )
60}
61
62/// Replaces control characters in every cell of a finished buffer.
63///
64/// Call this once, after all rendering, and before the buffer is handed to a
65/// backend. It is the last point at which datui controls what the terminal
66/// receives.
67pub fn sanitize_buffer(buf: &mut Buffer) {
68    for cell in buf.content.iter_mut() {
69        if let Some(clean) = sanitized(cell.symbol()) {
70            cell.set_symbol(&clean);
71        }
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use ratatui::layout::Rect;
79
80    #[test]
81    fn leaves_ordinary_text_alone() {
82        assert_eq!(sanitized("hello"), None);
83        assert_eq!(sanitized(""), None);
84        // Tab is layout, not control.
85        assert_eq!(sanitized("a\tb"), None);
86        // Non-ASCII text must survive untouched.
87        assert_eq!(sanitized("héllo · 日本語 · 🦀"), None);
88    }
89
90    #[test]
91    fn replaces_c0_controls() {
92        assert_eq!(sanitized("\x1b[2J").unwrap(), "\u{fffd}[2J");
93        assert_eq!(sanitized("a\x07b").unwrap(), "a\u{fffd}b");
94        assert_eq!(sanitized("a\rb").unwrap(), "a\u{fffd}b");
95        assert_eq!(sanitized("a\nb").unwrap(), "a\u{fffd}b");
96        assert_eq!(sanitized("a\x00b").unwrap(), "a\u{fffd}b");
97    }
98
99    #[test]
100    fn replaces_del_and_c1() {
101        assert_eq!(sanitized("a\x7fb").unwrap(), "a\u{fffd}b");
102        // Single-byte CSI, accepted by some terminals.
103        assert_eq!(sanitized("\u{009b}31m").unwrap(), "\u{fffd}31m");
104        assert_eq!(sanitized("\u{0080}").unwrap(), "\u{fffd}");
105        assert_eq!(sanitized("\u{009f}").unwrap(), "\u{fffd}");
106    }
107
108    #[test]
109    fn keeps_the_character_after_the_c1_range() {
110        // U+00A0 is a no-break space, not a control. Off-by-one guard.
111        assert_eq!(sanitized("\u{00a0}"), None);
112    }
113
114    #[test]
115    fn sweeps_a_whole_buffer() {
116        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
117        buf[(0, 0)].set_symbol("a");
118        buf[(1, 0)].set_symbol("\x1b");
119        // A cell symbol can hold several graphemes: Span rendering appends
120        // zero-width ones to the preceding cell, which is how an escape gets
121        // in alongside a visible character in the first place.
122        buf[(2, 0)].set_symbol("b\x1b]52;c;x\x07");
123        buf[(3, 0)].set_symbol("c");
124
125        sanitize_buffer(&mut buf);
126
127        assert_eq!(buf[(0, 0)].symbol(), "a");
128        assert_eq!(buf[(1, 0)].symbol(), "\u{fffd}");
129        assert_eq!(buf[(2, 0)].symbol(), "b\u{fffd}]52;c;x\u{fffd}");
130        assert_eq!(buf[(3, 0)].symbol(), "c");
131    }
132}