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