Skip to main content

av_core/
text.rs

1//! Unicode text guards: block characters that let hostile input spoof how
2//! logs, receipts, and identifiers look on a terminal.
3//!
4//! The dangerous set here is the "Trojan Source" family — bidirectional
5//! overrides and zero-width formatting characters — that render invisibly (or
6//! reverse the visible order of surrounding text) while remaining part of the
7//! string's bytes. When those characters ride along in a JWT identity claim
8//! or a tool name, an operator investigating an audit chain sees one thing
9//! while the on-wire bytes say another.
10
11/// Code points that visually reorder or hide surrounding text.
12///
13/// Includes the bidirectional formatting/override/isolate family (U+202A..E,
14/// U+2066..9), the LTR/RTL/Arabic marks (U+061C, U+200E..F), the zero-width
15/// glyphs (U+200B..D, U+2060, U+FEFF), the invisible operators (U+2061..4),
16/// the deprecated shaping controls (U+206A..F), the soft hyphen, the
17/// Mongolian vowel separator, and the line/paragraph separators (which
18/// inject fake line breaks into log views). Kept as a small hand-curated
19/// set so this stays a std-only helper — a full Unicode-Cf gate
20/// would need an extra crate for a benefit no legitimate identifier needs.
21const DANGEROUS_CODEPOINTS: &[char] = &[
22    '\u{00AD}', // SOFT HYPHEN (renders invisibly outside line breaks)
23    '\u{061C}', // ARABIC LETTER MARK
24    '\u{180E}', // MONGOLIAN VOWEL SEPARATOR (invisible, Cf in older Unicode)
25    '\u{200B}', // ZERO WIDTH SPACE
26    '\u{200C}', // ZERO WIDTH NON-JOINER
27    '\u{200D}', // ZERO WIDTH JOINER
28    '\u{200E}', // LEFT-TO-RIGHT MARK
29    '\u{200F}', // RIGHT-TO-LEFT MARK
30    '\u{2028}', // LINE SEPARATOR (injects fake line breaks into log views)
31    '\u{2029}', // PARAGRAPH SEPARATOR
32    '\u{202A}', // LEFT-TO-RIGHT EMBEDDING
33    '\u{202B}', // RIGHT-TO-LEFT EMBEDDING
34    '\u{202C}', // POP DIRECTIONAL FORMATTING
35    '\u{202D}', // LEFT-TO-RIGHT OVERRIDE
36    '\u{202E}', // RIGHT-TO-LEFT OVERRIDE (the classic "reverse the string" glyph)
37    '\u{2060}', // WORD JOINER
38    '\u{2061}', // FUNCTION APPLICATION (invisible operator)
39    '\u{2062}', // INVISIBLE TIMES
40    '\u{2063}', // INVISIBLE SEPARATOR
41    '\u{2064}', // INVISIBLE PLUS
42    '\u{2066}', // LEFT-TO-RIGHT ISOLATE
43    '\u{2067}', // RIGHT-TO-LEFT ISOLATE
44    '\u{2068}', // FIRST STRONG ISOLATE
45    '\u{2069}', // POP DIRECTIONAL ISOLATE
46    '\u{206A}', // INHIBIT SYMMETRIC SWAPPING (deprecated format control)
47    '\u{206B}', // ACTIVATE SYMMETRIC SWAPPING
48    '\u{206C}', // INHIBIT ARABIC FORM SHAPING
49    '\u{206D}', // ACTIVATE ARABIC FORM SHAPING
50    '\u{206E}', // NATIONAL DIGIT SHAPES
51    '\u{206F}', // NOMINAL DIGIT SHAPES
52    '\u{FEFF}', // ZERO WIDTH NO-BREAK SPACE / BOM
53];
54
55/// True if `s` carries any character that visually reorders or hides
56/// surrounding text (Trojan-Source-class spoofing).
57pub fn contains_bidi_or_zero_width(s: &str) -> bool {
58    s.chars().any(is_bidi_or_zero_width)
59}
60
61/// True if `c` is one of the dangerous code points above — the
62/// per-character form for callers that sanitize (replace) rather than
63/// refuse.
64pub fn is_bidi_or_zero_width(c: char) -> bool {
65    DANGEROUS_CODEPOINTS.contains(&c)
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn plain_ascii_and_common_utf8_pass() {
74        for s in ["", "hello", "agent:test", "réseau", "支付", "abc\u{1F600}xyz"] {
75            assert!(!contains_bidi_or_zero_width(s), "wrong reject: {s:?}");
76        }
77    }
78
79    /// Every dangerous code point flags whether it appears alone, at the
80    /// start, in the middle, at the end, or inside legitimate UTF-8.
81    #[test]
82    fn every_dangerous_code_point_is_detected_in_every_position() {
83        for &c in DANGEROUS_CODEPOINTS {
84            for position in [
85                format!("{c}"),
86                format!("{c}legit"),
87                format!("le{c}git"),
88                format!("legit{c}"),
89                format!("réseau{c}pay"),
90            ] {
91                assert!(
92                    contains_bidi_or_zero_width(&position),
93                    "missed U+{:04X} in {position:?}",
94                    c as u32,
95                );
96            }
97        }
98    }
99
100    /// The infamous Trojan Source RLO attack: `admin\u{202E}nimda` looks
101    /// like two admin fields on a terminal but is one hostile identifier.
102    #[test]
103    fn trojan_source_admin_swap_is_detected() {
104        let hostile = "admin\u{202E}nimda";
105        assert!(contains_bidi_or_zero_width(hostile));
106        // Sanity: the raw bytes and the visible glyphs disagree — the whole
107        // point of the attack, and the whole point of blocking the guard.
108        assert_ne!(hostile.len(), "adminnimda".len());
109    }
110}