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