Skip to main content

webui_handler/
html_encode.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Minimal HTML entity encoding for safe embedding of dynamic values.
5//!
6//! A focused, zero-dependency implementation that covers the six characters
7//! needed for XSS prevention in HTML text and attribute contexts.
8
9use std::borrow::Cow;
10
11/// Characters escaped and their replacements:
12///
13/// * `&` → `&`
14/// * `<` → `&lt;`
15/// * `>` → `&gt;`
16/// * `"` → `&quot;`
17/// * `'` → `&#x27;`
18/// * `/` → `&#x2F;`
19///
20/// Returns [`Cow::Borrowed`] when the input contains no characters that need
21/// escaping (zero-allocation fast path).
22pub fn encode_safe(input: &str) -> Cow<'_, str> {
23    let bytes = input.as_bytes();
24
25    // Fast path: find the first byte that needs escaping.
26    let first = bytes
27        .iter()
28        .position(|b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\'' | b'/'));
29
30    let Some(pos) = first else {
31        return Cow::Borrowed(input);
32    };
33
34    // Slow path: allocate and build the escaped string.
35    let mut out = String::with_capacity(input.len() + 6);
36    out.push_str(&input[..pos]);
37
38    let mut start = pos;
39    for (i, &b) in bytes[pos..].iter().enumerate() {
40        let replacement = match b {
41            b'&' => "&amp;",
42            b'<' => "&lt;",
43            b'>' => "&gt;",
44            b'"' => "&quot;",
45            b'\'' => "&#x27;",
46            b'/' => "&#x2F;",
47            _ => continue,
48        };
49        // Flush the unescaped run before this match.
50        out.push_str(&input[start..pos + i]);
51        out.push_str(replacement);
52        start = pos + i + 1;
53    }
54    // Flush any remaining unescaped tail.
55    out.push_str(&input[start..]);
56
57    Cow::Owned(out)
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn no_escaping_returns_borrowed() {
66        let input = "Hello World 123";
67        let result = encode_safe(input);
68        assert!(matches!(result, Cow::Borrowed(_)));
69        assert_eq!(result, "Hello World 123");
70    }
71
72    #[test]
73    fn empty_string_returns_borrowed() {
74        let result = encode_safe("");
75        assert!(matches!(result, Cow::Borrowed(_)));
76        assert_eq!(result, "");
77    }
78
79    #[test]
80    fn escapes_ampersand() {
81        assert_eq!(encode_safe("a&b"), "a&amp;b");
82    }
83
84    #[test]
85    fn escapes_less_than() {
86        assert_eq!(encode_safe("<script>"), "&lt;script&gt;");
87    }
88
89    #[test]
90    fn escapes_greater_than() {
91        assert_eq!(encode_safe("a>b"), "a&gt;b");
92    }
93
94    #[test]
95    fn escapes_double_quote() {
96        assert_eq!(encode_safe(r#"a"b"#), "a&quot;b");
97    }
98
99    #[test]
100    fn escapes_single_quote() {
101        assert_eq!(encode_safe("a'b"), "a&#x27;b");
102    }
103
104    #[test]
105    fn escapes_forward_slash() {
106        assert_eq!(encode_safe("a/b"), "a&#x2F;b");
107    }
108
109    #[test]
110    fn escapes_all_special_chars() {
111        assert_eq!(encode_safe(r#"&<>"'/"#), "&amp;&lt;&gt;&quot;&#x27;&#x2F;");
112    }
113
114    #[test]
115    fn preserves_unicode() {
116        assert_eq!(encode_safe("こんにちは"), "こんにちは");
117        assert!(matches!(encode_safe("こんにちは"), Cow::Borrowed(_)));
118    }
119
120    #[test]
121    fn mixed_unicode_and_special() {
122        assert_eq!(encode_safe("日本語&テスト"), "日本語&amp;テスト");
123    }
124
125    #[test]
126    fn escapes_at_start() {
127        assert_eq!(encode_safe("&start"), "&amp;start");
128    }
129
130    #[test]
131    fn escapes_at_end() {
132        assert_eq!(encode_safe("end&"), "end&amp;");
133    }
134
135    #[test]
136    fn multiple_consecutive_escapes() {
137        assert_eq!(encode_safe("&&"), "&amp;&amp;");
138    }
139
140    #[test]
141    fn realistic_nonce() {
142        // CSP nonces are base64 — no special chars expected.
143        let nonce = "YWJjZGVmZ2hpamtsbW5vcA==";
144        let result = encode_safe(nonce);
145        assert!(matches!(result, Cow::Borrowed(_)));
146    }
147
148    #[test]
149    fn realistic_attribute_value() {
150        assert_eq!(
151            encode_safe("John O'Brien & Associates"),
152            "John O&#x27;Brien &amp; Associates"
153        );
154    }
155
156    #[test]
157    fn realistic_xss_attempt() {
158        assert_eq!(
159            encode_safe("<script>alert('xss')</script>"),
160            "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;&#x2F;script&gt;"
161        );
162    }
163
164    #[test]
165    fn escapes_all_known_special_chars() {
166        // Verify all six special characters are escaped correctly.
167        let test_cases = [
168            ("", ""),
169            ("hello", "hello"),
170            ("&", "&amp;"),
171            ("<", "&lt;"),
172            (">", "&gt;"),
173            ("\"", "&quot;"),
174            ("'", "&#x27;"),
175            ("/", "&#x2F;"),
176            ("a&b<c>d\"e'f/g", "a&amp;b&lt;c&gt;d&quot;e&#x27;f&#x2F;g"),
177        ];
178        for (input, expected) in test_cases {
179            assert_eq!(encode_safe(input), expected, "Failed for input: {input:?}");
180        }
181    }
182}