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), and the word joiner. Kept as a small
16/// hand-curated set so this stays a std-only helper — a full Unicode-Cf gate
17/// would need an extra crate for a benefit no legitimate identifier needs.
18const DANGEROUS_CODEPOINTS: &[char] = &[
19    '\u{061C}', // ARABIC LETTER MARK
20    '\u{200B}', // ZERO WIDTH SPACE
21    '\u{200C}', // ZERO WIDTH NON-JOINER
22    '\u{200D}', // ZERO WIDTH JOINER
23    '\u{200E}', // LEFT-TO-RIGHT MARK
24    '\u{200F}', // RIGHT-TO-LEFT MARK
25    '\u{202A}', // LEFT-TO-RIGHT EMBEDDING
26    '\u{202B}', // RIGHT-TO-LEFT EMBEDDING
27    '\u{202C}', // POP DIRECTIONAL FORMATTING
28    '\u{202D}', // LEFT-TO-RIGHT OVERRIDE
29    '\u{202E}', // RIGHT-TO-LEFT OVERRIDE (the classic "reverse the string" glyph)
30    '\u{2060}', // WORD JOINER
31    '\u{2066}', // LEFT-TO-RIGHT ISOLATE
32    '\u{2067}', // RIGHT-TO-LEFT ISOLATE
33    '\u{2068}', // FIRST STRONG ISOLATE
34    '\u{2069}', // POP DIRECTIONAL ISOLATE
35    '\u{FEFF}', // ZERO WIDTH NO-BREAK SPACE / BOM
36];
37
38/// True if `s` carries any character that visually reorders or hides
39/// surrounding text (Trojan-Source-class spoofing).
40pub fn contains_bidi_or_zero_width(s: &str) -> bool {
41    s.chars().any(|c| DANGEROUS_CODEPOINTS.contains(&c))
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    #[test]
49    fn plain_ascii_and_common_utf8_pass() {
50        for s in ["", "hello", "agent:test", "réseau", "支付", "abc\u{1F600}xyz"] {
51            assert!(!contains_bidi_or_zero_width(s), "wrong reject: {s:?}");
52        }
53    }
54
55    /// Every dangerous code point flags whether it appears alone, at the
56    /// start, in the middle, at the end, or inside legitimate UTF-8.
57    #[test]
58    fn every_dangerous_code_point_is_detected_in_every_position() {
59        for &c in DANGEROUS_CODEPOINTS {
60            for position in [
61                format!("{c}"),
62                format!("{c}legit"),
63                format!("le{c}git"),
64                format!("legit{c}"),
65                format!("réseau{c}pay"),
66            ] {
67                assert!(
68                    contains_bidi_or_zero_width(&position),
69                    "missed U+{:04X} in {position:?}",
70                    c as u32,
71                );
72            }
73        }
74    }
75
76    /// The infamous Trojan Source RLO attack: `admin\u{202E}nimda` looks
77    /// like two admin fields on a terminal but is one hostile identifier.
78    #[test]
79    fn trojan_source_admin_swap_is_detected() {
80        let hostile = "admin\u{202E}nimda";
81        assert!(contains_bidi_or_zero_width(hostile));
82        // Sanity: the raw bytes and the visible glyphs disagree — the whole
83        // point of the attack, and the whole point of blocking the guard.
84        assert_ne!(hostile.len(), "adminnimda".len());
85    }
86}