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
11use crate::{ResponseWriter, Result};
12
13/// Characters escaped and their replacements:
14///
15/// * `&` → `&`
16/// * `<` → `&lt;`
17/// * `>` → `&gt;`
18/// * `"` → `&quot;`
19/// * `'` → `&#x27;`
20/// * `/` → `&#x2F;`
21///
22/// Returns [`Cow::Borrowed`] when the input contains no characters that need
23/// escaping (zero-allocation fast path).
24pub fn encode_safe(input: &str) -> Cow<'_, str> {
25    let bytes = input.as_bytes();
26
27    // Fast path: find the first byte that needs escaping.
28    let first = bytes
29        .iter()
30        .position(|b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\'' | b'/'));
31
32    let Some(pos) = first else {
33        return Cow::Borrowed(input);
34    };
35
36    // Slow path: allocate and build the escaped string.
37    let mut out = String::with_capacity(input.len() + 6);
38    out.push_str(&input[..pos]);
39
40    let mut start = pos;
41    for (i, &b) in bytes[pos..].iter().enumerate() {
42        let replacement = match b {
43            b'&' => "&amp;",
44            b'<' => "&lt;",
45            b'>' => "&gt;",
46            b'"' => "&quot;",
47            b'\'' => "&#x27;",
48            b'/' => "&#x2F;",
49            _ => continue,
50        };
51        // Flush the unescaped run before this match.
52        out.push_str(&input[start..pos + i]);
53        out.push_str(replacement);
54        start = pos + i + 1;
55    }
56    // Flush any remaining unescaped tail.
57    out.push_str(&input[start..]);
58
59    Cow::Owned(out)
60}
61
62#[inline]
63fn is_style_end_tag(bytes: &[u8], index: usize) -> bool {
64    bytes[index] == b'<'
65        && bytes[index + 1] == b'/'
66        && bytes[index + 2].eq_ignore_ascii_case(&b's')
67        && bytes[index + 3].eq_ignore_ascii_case(&b't')
68        && bytes[index + 4].eq_ignore_ascii_case(&b'y')
69        && bytes[index + 5].eq_ignore_ascii_case(&b'l')
70        && bytes[index + 6].eq_ignore_ascii_case(&b'e')
71}
72
73/// Return whether CSS needs escaping before it is written into a `<style>`.
74pub(crate) fn style_text_needs_escape(css: &str) -> bool {
75    let bytes = css.as_bytes();
76    let mut index = 0usize;
77    while index + 7 <= bytes.len() {
78        if is_style_end_tag(bytes, index) {
79            return true;
80        }
81        index += 1;
82    }
83    false
84}
85
86/// Write CSS into an HTML `<style>` raw-text element without allowing an
87/// authored, case-insensitive `</style` sequence to terminate the element.
88///
89/// Escaping only the slash (`<\/style`) preserves the CSS string value and
90/// keeps the ordinary path allocation-free.
91pub(crate) fn write_style_text(writer: &mut dyn ResponseWriter, css: &str) -> Result<()> {
92    let bytes = css.as_bytes();
93    let mut chunk_start = 0usize;
94    let mut index = 0usize;
95    while index + 7 <= bytes.len() {
96        if is_style_end_tag(bytes, index) {
97            writer.write(&css[chunk_start..index + 1])?;
98            writer.write("\\/")?;
99            chunk_start = index + 2;
100            index += 7;
101        } else {
102            index += 1;
103        }
104    }
105    writer.write(&css[chunk_start..])
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[derive(Default)]
113    struct StyleWriter {
114        output: String,
115        writes: usize,
116    }
117
118    impl ResponseWriter for StyleWriter {
119        fn write(&mut self, content: &str) -> Result<()> {
120            self.output.push_str(content);
121            self.writes += 1;
122            Ok(())
123        }
124
125        fn end(&mut self) -> Result<()> {
126            Ok(())
127        }
128    }
129
130    #[test]
131    fn no_escaping_returns_borrowed() {
132        let input = "Hello World 123";
133        let result = encode_safe(input);
134        assert!(matches!(result, Cow::Borrowed(_)));
135        assert_eq!(result, "Hello World 123");
136    }
137
138    #[test]
139    fn empty_string_returns_borrowed() {
140        let result = encode_safe("");
141        assert!(matches!(result, Cow::Borrowed(_)));
142        assert_eq!(result, "");
143    }
144
145    #[test]
146    fn escapes_ampersand() {
147        assert_eq!(encode_safe("a&b"), "a&amp;b");
148    }
149
150    #[test]
151    fn escapes_less_than() {
152        assert_eq!(encode_safe("<script>"), "&lt;script&gt;");
153    }
154
155    #[test]
156    fn escapes_greater_than() {
157        assert_eq!(encode_safe("a>b"), "a&gt;b");
158    }
159
160    #[test]
161    fn escapes_double_quote() {
162        assert_eq!(encode_safe(r#"a"b"#), "a&quot;b");
163    }
164
165    #[test]
166    fn escapes_single_quote() {
167        assert_eq!(encode_safe("a'b"), "a&#x27;b");
168    }
169
170    #[test]
171    fn escapes_forward_slash() {
172        assert_eq!(encode_safe("a/b"), "a&#x2F;b");
173    }
174
175    #[test]
176    fn escapes_all_special_chars() {
177        assert_eq!(encode_safe(r#"&<>"'/"#), "&amp;&lt;&gt;&quot;&#x27;&#x2F;");
178    }
179
180    #[test]
181    fn preserves_unicode() {
182        assert_eq!(encode_safe("こんにちは"), "こんにちは");
183        assert!(matches!(encode_safe("こんにちは"), Cow::Borrowed(_)));
184    }
185
186    #[test]
187    fn mixed_unicode_and_special() {
188        assert_eq!(encode_safe("日本語&テスト"), "日本語&amp;テスト");
189    }
190
191    #[test]
192    fn escapes_at_start() {
193        assert_eq!(encode_safe("&start"), "&amp;start");
194    }
195
196    #[test]
197    fn escapes_at_end() {
198        assert_eq!(encode_safe("end&"), "end&amp;");
199    }
200
201    #[test]
202    fn multiple_consecutive_escapes() {
203        assert_eq!(encode_safe("&&"), "&amp;&amp;");
204    }
205
206    #[test]
207    fn realistic_nonce() {
208        // CSP nonces are base64 — no special chars expected.
209        let nonce = "YWJjZGVmZ2hpamtsbW5vcA==";
210        let result = encode_safe(nonce);
211        assert!(matches!(result, Cow::Borrowed(_)));
212    }
213
214    #[test]
215    fn realistic_attribute_value() {
216        assert_eq!(
217            encode_safe("John O'Brien & Associates"),
218            "John O&#x27;Brien &amp; Associates"
219        );
220    }
221
222    #[test]
223    fn realistic_xss_attempt() {
224        assert_eq!(
225            encode_safe("<script>alert('xss')</script>"),
226            "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;&#x2F;script&gt;"
227        );
228    }
229
230    #[test]
231    fn escapes_all_known_special_chars() {
232        // Verify all six special characters are escaped correctly.
233        let test_cases = [
234            ("", ""),
235            ("hello", "hello"),
236            ("&", "&amp;"),
237            ("<", "&lt;"),
238            (">", "&gt;"),
239            ("\"", "&quot;"),
240            ("'", "&#x27;"),
241            ("/", "&#x2F;"),
242            ("a&b<c>d\"e'f/g", "a&amp;b&lt;c&gt;d&quot;e&#x27;f&#x2F;g"),
243        ];
244        for (input, expected) in test_cases {
245            assert_eq!(encode_safe(input), expected, "Failed for input: {input:?}");
246        }
247    }
248
249    #[test]
250    fn style_text_preserves_normal_bytes_in_one_write() {
251        let mut writer = StyleWriter::default();
252        write_style_text(&mut writer, ".card{content:'normal / style'}").unwrap();
253        assert_eq!(writer.output, ".card{content:'normal / style'}");
254        assert_eq!(writer.writes, 1);
255    }
256
257    #[test]
258    fn style_text_escapes_mixed_case_end_tags() {
259        let mut writer = StyleWriter::default();
260        write_style_text(
261            &mut writer,
262            ".a{content:'</style>'}.b{content:'</StYlE attr>'}",
263        )
264        .unwrap();
265        assert_eq!(
266            writer.output,
267            ".a{content:'<\\/style>'}.b{content:'<\\/StYlE attr>'}"
268        );
269    }
270
271    #[test]
272    fn style_text_escape_detection_matches_writer() {
273        let cases = [
274            ("", false),
275            ("</styl", false),
276            ("< /style", false),
277            ("<\\/style", false),
278            ("</style", true),
279            ("prefix</STYLE>suffix", true),
280            ("a</StYlE attr>b", true),
281        ];
282
283        for (css, expected) in cases {
284            assert_eq!(
285                style_text_needs_escape(css),
286                expected,
287                "unexpected detection for {css:?}"
288            );
289
290            let mut writer = StyleWriter::default();
291            write_style_text(&mut writer, css).unwrap();
292            assert_eq!(
293                writer.output != css,
294                expected,
295                "writer and detector diverged for {css:?}"
296            );
297        }
298    }
299}