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/// Write CSS into an HTML `<style>` raw-text element without allowing an
63/// authored, case-insensitive `</style` sequence to terminate the element.
64///
65/// Escaping only the slash (`<\/style`) preserves the CSS string value and
66/// keeps the ordinary path allocation-free.
67pub(crate) fn write_style_text(writer: &mut dyn ResponseWriter, css: &str) -> Result<()> {
68    let bytes = css.as_bytes();
69    let mut chunk_start = 0usize;
70    let mut index = 0usize;
71    while index + 7 <= bytes.len() {
72        if bytes[index] == b'<'
73            && bytes[index + 1] == b'/'
74            && bytes[index + 2].eq_ignore_ascii_case(&b's')
75            && bytes[index + 3].eq_ignore_ascii_case(&b't')
76            && bytes[index + 4].eq_ignore_ascii_case(&b'y')
77            && bytes[index + 5].eq_ignore_ascii_case(&b'l')
78            && bytes[index + 6].eq_ignore_ascii_case(&b'e')
79        {
80            writer.write(&css[chunk_start..index + 1])?;
81            writer.write("\\/")?;
82            chunk_start = index + 2;
83            index += 7;
84        } else {
85            index += 1;
86        }
87    }
88    writer.write(&css[chunk_start..])
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[derive(Default)]
96    struct StyleWriter {
97        output: String,
98        writes: usize,
99    }
100
101    impl ResponseWriter for StyleWriter {
102        fn write(&mut self, content: &str) -> Result<()> {
103            self.output.push_str(content);
104            self.writes += 1;
105            Ok(())
106        }
107
108        fn end(&mut self) -> Result<()> {
109            Ok(())
110        }
111    }
112
113    #[test]
114    fn no_escaping_returns_borrowed() {
115        let input = "Hello World 123";
116        let result = encode_safe(input);
117        assert!(matches!(result, Cow::Borrowed(_)));
118        assert_eq!(result, "Hello World 123");
119    }
120
121    #[test]
122    fn empty_string_returns_borrowed() {
123        let result = encode_safe("");
124        assert!(matches!(result, Cow::Borrowed(_)));
125        assert_eq!(result, "");
126    }
127
128    #[test]
129    fn escapes_ampersand() {
130        assert_eq!(encode_safe("a&b"), "a&amp;b");
131    }
132
133    #[test]
134    fn escapes_less_than() {
135        assert_eq!(encode_safe("<script>"), "&lt;script&gt;");
136    }
137
138    #[test]
139    fn escapes_greater_than() {
140        assert_eq!(encode_safe("a>b"), "a&gt;b");
141    }
142
143    #[test]
144    fn escapes_double_quote() {
145        assert_eq!(encode_safe(r#"a"b"#), "a&quot;b");
146    }
147
148    #[test]
149    fn escapes_single_quote() {
150        assert_eq!(encode_safe("a'b"), "a&#x27;b");
151    }
152
153    #[test]
154    fn escapes_forward_slash() {
155        assert_eq!(encode_safe("a/b"), "a&#x2F;b");
156    }
157
158    #[test]
159    fn escapes_all_special_chars() {
160        assert_eq!(encode_safe(r#"&<>"'/"#), "&amp;&lt;&gt;&quot;&#x27;&#x2F;");
161    }
162
163    #[test]
164    fn preserves_unicode() {
165        assert_eq!(encode_safe("こんにちは"), "こんにちは");
166        assert!(matches!(encode_safe("こんにちは"), Cow::Borrowed(_)));
167    }
168
169    #[test]
170    fn mixed_unicode_and_special() {
171        assert_eq!(encode_safe("日本語&テスト"), "日本語&amp;テスト");
172    }
173
174    #[test]
175    fn escapes_at_start() {
176        assert_eq!(encode_safe("&start"), "&amp;start");
177    }
178
179    #[test]
180    fn escapes_at_end() {
181        assert_eq!(encode_safe("end&"), "end&amp;");
182    }
183
184    #[test]
185    fn multiple_consecutive_escapes() {
186        assert_eq!(encode_safe("&&"), "&amp;&amp;");
187    }
188
189    #[test]
190    fn realistic_nonce() {
191        // CSP nonces are base64 — no special chars expected.
192        let nonce = "YWJjZGVmZ2hpamtsbW5vcA==";
193        let result = encode_safe(nonce);
194        assert!(matches!(result, Cow::Borrowed(_)));
195    }
196
197    #[test]
198    fn realistic_attribute_value() {
199        assert_eq!(
200            encode_safe("John O'Brien & Associates"),
201            "John O&#x27;Brien &amp; Associates"
202        );
203    }
204
205    #[test]
206    fn realistic_xss_attempt() {
207        assert_eq!(
208            encode_safe("<script>alert('xss')</script>"),
209            "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;&#x2F;script&gt;"
210        );
211    }
212
213    #[test]
214    fn escapes_all_known_special_chars() {
215        // Verify all six special characters are escaped correctly.
216        let test_cases = [
217            ("", ""),
218            ("hello", "hello"),
219            ("&", "&amp;"),
220            ("<", "&lt;"),
221            (">", "&gt;"),
222            ("\"", "&quot;"),
223            ("'", "&#x27;"),
224            ("/", "&#x2F;"),
225            ("a&b<c>d\"e'f/g", "a&amp;b&lt;c&gt;d&quot;e&#x27;f&#x2F;g"),
226        ];
227        for (input, expected) in test_cases {
228            assert_eq!(encode_safe(input), expected, "Failed for input: {input:?}");
229        }
230    }
231
232    #[test]
233    fn style_text_preserves_normal_bytes_in_one_write() {
234        let mut writer = StyleWriter::default();
235        write_style_text(&mut writer, ".card{content:'normal / style'}").unwrap();
236        assert_eq!(writer.output, ".card{content:'normal / style'}");
237        assert_eq!(writer.writes, 1);
238    }
239
240    #[test]
241    fn style_text_escapes_mixed_case_end_tags() {
242        let mut writer = StyleWriter::default();
243        write_style_text(
244            &mut writer,
245            ".a{content:'</style>'}.b{content:'</StYlE attr>'}",
246        )
247        .unwrap();
248        assert_eq!(
249            writer.output,
250            ".a{content:'<\\/style>'}.b{content:'<\\/StYlE attr>'}"
251        );
252    }
253}