1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
//! Sanitization for text that crosses an untrusted data boundary into a UI.
//!
//! Vendor responses and cached diagnostics are data, not terminal programs.
//! Keep ordinary Unicode and line breaks, but remove terminal control bytes
//! before the text is persisted or handed to Pango/ratatui/ANSI renderers.
/// Generous bound for one remote label or diagnostic field. Legitimate values
/// are normally a few dozen characters; the cap prevents a valid-but-hostile
/// JSON response from turning one UI cell or cache sidecar into megabytes.
pub const MAX_UNTRUSTED_FIELD_CHARS: usize = 4 * 1024;
/// Strip terminal control characters while preserving readable line layout.
///
/// Newlines are safe and useful in diagnostics. Tabs and carriage returns are
/// normalized to spaces; every other Unicode control character (including ESC,
/// BEL, DEL, and C1 controls) is removed. Invisible bidirectional markers and
/// overrides are also removed so an untrusted label cannot visually reorder
/// neighboring UI text. The result is capped by character, not byte, so UTF-8
/// is never split.
pub fn sanitize_untrusted_field(value: &str) -> String {
value
.chars()
.filter_map(|ch| match ch {
'\n' => Some('\n'),
'\t' | '\r' => Some(' '),
_ if ch.is_control() || is_bidi_control(ch) => None,
_ => Some(ch),
})
.take(MAX_UNTRUSTED_FIELD_CHARS)
.collect()
}
fn is_bidi_control(ch: char) -> bool {
matches!(
ch,
'\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_terminal_sequences_but_keeps_text_and_newlines() {
let input = "before\x1b]52;c;Y2xpcGJvYXJk\x07after\nnext\tcolumn\rreturn\u{202e}spoof";
assert_eq!(
sanitize_untrusted_field(input),
"before]52;c;Y2xpcGJvYXJkafter\nnext column returnspoof"
);
}
#[test]
fn caps_untrusted_fields_without_splitting_unicode() {
let input = "é".repeat(MAX_UNTRUSTED_FIELD_CHARS + 10);
let output = sanitize_untrusted_field(&input);
assert_eq!(output.chars().count(), MAX_UNTRUSTED_FIELD_CHARS);
assert!(output.chars().all(|ch| ch == 'é'));
}
}