Skip to main content

contextual_encoder/
html.rs

1//! HTML / XML contextual output encoders.
2//!
3//! provides four encoding contexts with different safety guarantees:
4//!
5//! - [`for_html`] — safe for both text content and quoted attributes (most conservative)
6//! - [`for_html_content`] — safe for text content only (does not encode quotes)
7//! - [`for_html_attribute`] — safe for quoted attributes only (does not encode `>`)
8//! - [`for_html_unquoted_attribute`] — safe for unquoted attribute values (most aggressive)
9//!
10//! all encoders replace invalid XML characters (C0/C1 controls, DEL, unicode
11//! non-characters) with a replacement character (space or dash depending on
12//! context).
13//!
14//! # security notes
15//!
16//! - these encoders produce output safe for embedding in the specified context.
17//!   they do not sanitize HTML — encoding is not a substitute for input validation.
18//! - never use `for_html_content` output in an attribute context.
19//! - never use `for_html_attribute` output in a text content context where `>` matters.
20//! - `for_html` is the safe default when the exact context is unknown.
21//! - tag names, attribute names, and event handler names must be validated
22//!   separately — encoding cannot make arbitrary names safe.
23
24use std::fmt;
25
26use crate::engine::{
27    encode_loop, is_unicode_noncharacter, write_markup, InvalidCharPolicy, MarkupConfig,
28};
29
30const HTML_FULL: MarkupConfig = MarkupConfig {
31    encode_gt: true,
32    encode_quotes: true,
33    invalid: InvalidCharPolicy::HtmlSpace,
34};
35
36const HTML_CONTENT: MarkupConfig = MarkupConfig {
37    encode_gt: true,
38    encode_quotes: false,
39    invalid: InvalidCharPolicy::HtmlSpace,
40};
41
42const HTML_ATTRIBUTE: MarkupConfig = MarkupConfig {
43    encode_gt: false,
44    encode_quotes: true,
45    invalid: InvalidCharPolicy::HtmlSpace,
46};
47
48/// encodes `input` for safe embedding in HTML text content and quoted attributes.
49///
50/// this is the most conservative HTML encoder — it encodes characters needed
51/// for both text content and attribute contexts. use [`for_html_content`] or
52/// [`for_html_attribute`] for more minimal encoding when the exact context is
53/// known.
54///
55/// # encoded characters
56///
57/// | input | output |
58/// |-------|--------|
59/// | `&`   | `&`  |
60/// | `<`   | `&lt;`   |
61/// | `>`   | `&gt;`   |
62/// | `"`   | `&#34;`  |
63/// | `'`   | `&#39;`  |
64///
65/// invalid XML characters are replaced with a space.
66///
67/// # examples
68///
69/// ```
70/// use contextual_encoder::for_html;
71///
72/// assert_eq!(for_html("<script>alert('xss')</script>"),
73///            "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;");
74/// assert_eq!(for_html("safe text"), "safe text");
75/// ```
76pub fn for_html(input: &str) -> String {
77    let mut out = String::with_capacity(input.len());
78    write_html(&mut out, input).expect("writing to string cannot fail");
79    out
80}
81
82/// writes the HTML-encoded form of `input` to `out`.
83///
84/// see [`for_html`] for encoding rules.
85pub fn write_html<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
86    write_markup(out, input, &HTML_FULL)
87}
88
89/// encodes `input` for safe embedding in HTML text content.
90///
91/// this encoder does **not** encode quote characters and is therefore
92/// **not safe for attribute values**. use [`for_html`] or
93/// [`for_html_attribute`] for attribute contexts.
94///
95/// # encoded characters
96///
97/// | input | output |
98/// |-------|--------|
99/// | `&`   | `&amp;` |
100/// | `<`   | `&lt;`  |
101/// | `>`   | `&gt;`  |
102///
103/// invalid XML characters are replaced with a space.
104///
105/// # examples
106///
107/// ```
108/// use contextual_encoder::for_html_content;
109///
110/// assert_eq!(for_html_content("1 < 2 & 3 > 0"), "1 &lt; 2 &amp; 3 &gt; 0");
111/// // quotes are NOT encoded — do not use in attributes
112/// assert_eq!(for_html_content(r#"she said "hi""#), r#"she said "hi""#);
113/// ```
114pub fn for_html_content(input: &str) -> String {
115    let mut out = String::with_capacity(input.len());
116    write_html_content(&mut out, input).expect("writing to string cannot fail");
117    out
118}
119
120/// writes the HTML-content-encoded form of `input` to `out`.
121///
122/// see [`for_html_content`] for encoding rules.
123pub fn write_html_content<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
124    write_markup(out, input, &HTML_CONTENT)
125}
126
127/// encodes `input` for safe embedding in a quoted HTML attribute value.
128///
129/// this encoder does **not** encode `>` (harmless inside quoted attributes)
130/// and is slightly more minimal than [`for_html`]. it encodes both `"` and
131/// `'` so the output is safe regardless of which quote delimiter is used.
132///
133/// **not safe for unquoted attributes** — use [`for_html_unquoted_attribute`]
134/// for that context.
135///
136/// # encoded characters
137///
138/// | input | output |
139/// |-------|--------|
140/// | `&`   | `&amp;` |
141/// | `<`   | `&lt;`  |
142/// | `"`   | `&#34;` |
143/// | `'`   | `&#39;` |
144///
145/// invalid XML characters are replaced with a space.
146///
147/// # examples
148///
149/// ```
150/// use contextual_encoder::for_html_attribute;
151///
152/// // safe for both quote styles
153/// assert_eq!(
154///     for_html_attribute(r#"it's a "test""#),
155///     "it&#39;s a &#34;test&#34;"
156/// );
157/// // > is not encoded
158/// assert_eq!(for_html_attribute("a > b"), "a > b");
159/// ```
160pub fn for_html_attribute(input: &str) -> String {
161    let mut out = String::with_capacity(input.len());
162    write_html_attribute(&mut out, input).expect("writing to string cannot fail");
163    out
164}
165
166/// writes the HTML-attribute-encoded form of `input` to `out`.
167///
168/// see [`for_html_attribute`] for encoding rules.
169pub fn write_html_attribute<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
170    write_markup(out, input, &HTML_ATTRIBUTE)
171}
172
173/// encodes `input` for safe embedding in an unquoted HTML attribute value.
174///
175/// this is the most aggressive HTML encoder, encoding whitespace, quote
176/// characters, grave accents, and many punctuation characters that could
177/// terminate an unquoted attribute value.
178///
179/// **prefer quoted attributes** whenever possible. unquoted attributes are
180/// fragile and this encoder exists only for cases where quoting is not an
181/// option.
182///
183/// # caveat: grave accent
184///
185/// the grave accent (`` ` ``, U+0060) is encoded as `&#96;` because
186/// unpatched internet explorer treats it as an attribute delimiter.
187/// however, numeric character references decode back to the original
188/// character, so this encoding cannot fully protect against the IE bug
189/// in all injection scenarios. the safest mitigation is to avoid
190/// unquoted attributes entirely.
191///
192/// # encoded characters (partial list)
193///
194/// | input  | output    |
195/// |--------|-----------|
196/// | tab    | `&#9;`    |
197/// | LF     | `&#10;`   |
198/// | FF     | `&#12;`   |
199/// | CR     | `&#13;`   |
200/// | space  | `&#32;`   |
201/// | `&`    | `&amp;`   |
202/// | `<`    | `&lt;`    |
203/// | `>`    | `&gt;`    |
204/// | `"`    | `&#34;`   |
205/// | `'`    | `&#39;`   |
206/// | `/`    | `&#47;`   |
207/// | `=`    | `&#61;`   |
208/// | `` ` ``| `&#96;`   |
209///
210/// C0/C1 control characters, DEL, and unicode non-characters are replaced
211/// with `-`. NEL (U+0085) is encoded as `&#133;`. line separator (U+2028)
212/// and paragraph separator (U+2029) are encoded as `&#8232;` and `&#8233;`.
213///
214/// # examples
215///
216/// ```
217/// use contextual_encoder::for_html_unquoted_attribute;
218///
219/// assert_eq!(for_html_unquoted_attribute("hello world"), "hello&#32;world");
220/// assert_eq!(for_html_unquoted_attribute("a=b"), "a&#61;b");
221/// ```
222pub fn for_html_unquoted_attribute(input: &str) -> String {
223    let mut out = String::with_capacity(input.len());
224    write_html_unquoted_attribute(&mut out, input).expect("writing to string cannot fail");
225    out
226}
227
228/// writes the unquoted-HTML-attribute-encoded form of `input` to `out`.
229///
230/// see [`for_html_unquoted_attribute`] for encoding rules.
231pub fn write_html_unquoted_attribute<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
232    encode_loop(
233        out,
234        input,
235        needs_html_unquoted_attribute_encoding,
236        write_html_unquoted_attribute_encoded,
237    )
238}
239
240fn needs_html_unquoted_attribute_encoding(c: char) -> bool {
241    let cp = c as u32;
242
243    // specific ASCII characters that need encoding
244    if matches!(
245        c,
246        '\t' | '\n' | '\x0C' | '\r' | ' ' | '&' | '<' | '>' | '"' | '\'' | '/' | '=' | '`'
247    ) {
248        return true;
249    }
250
251    // C0 controls not matched above
252    if cp <= 0x1F {
253        return true;
254    }
255
256    // DEL
257    if cp == 0x7F {
258        return true;
259    }
260
261    // C1 controls (includes NEL U+0085)
262    if (0x80..=0x9F).contains(&cp) {
263        return true;
264    }
265
266    // line / paragraph separators
267    if cp == 0x2028 || cp == 0x2029 {
268        return true;
269    }
270
271    // unicode non-characters
272    if is_unicode_noncharacter(cp) {
273        return true;
274    }
275
276    false
277}
278
279fn write_html_unquoted_attribute_encoded<W: fmt::Write>(
280    out: &mut W,
281    c: char,
282    _next: Option<char>,
283) -> fmt::Result {
284    match c {
285        '\t' => out.write_str("&#9;"),
286        '\n' => out.write_str("&#10;"),
287        '\x0C' => out.write_str("&#12;"),
288        '\r' => out.write_str("&#13;"),
289        ' ' => out.write_str("&#32;"),
290        '&' => out.write_str("&amp;"),
291        '<' => out.write_str("&lt;"),
292        '>' => out.write_str("&gt;"),
293        '"' => out.write_str("&#34;"),
294        '\'' => out.write_str("&#39;"),
295        '/' => out.write_str("&#47;"),
296        '=' => out.write_str("&#61;"),
297        '`' => out.write_str("&#96;"),
298        '\u{0085}' => out.write_str("&#133;"),
299        '\u{2028}' => out.write_str("&#8232;"),
300        '\u{2029}' => out.write_str("&#8233;"),
301        // remaining: C0/C1 controls, DEL, non-characters → dash
302        _ => out.write_char('-'),
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    // -- for_html --
311
312    #[test]
313    fn html_no_encoding_needed() {
314        assert_eq!(for_html("hello world"), "hello world");
315        assert_eq!(for_html(""), "");
316        assert_eq!(for_html("abc123"), "abc123");
317    }
318
319    #[test]
320    fn html_encodes_ampersand() {
321        assert_eq!(for_html("a&b"), "a&amp;b");
322    }
323
324    #[test]
325    fn html_encodes_angle_brackets() {
326        assert_eq!(for_html("<div>"), "&lt;div&gt;");
327    }
328
329    #[test]
330    fn html_encodes_quotes() {
331        assert_eq!(for_html(r#"a"b'c"#), "a&#34;b&#39;c");
332    }
333
334    #[test]
335    fn html_replaces_controls_with_space() {
336        assert_eq!(for_html("a\x01b"), "a b");
337        assert_eq!(for_html("a\x7Fb"), "a b");
338    }
339
340    #[test]
341    fn html_preserves_tab_lf_cr() {
342        assert_eq!(for_html("a\tb\nc\rd"), "a\tb\nc\rd");
343    }
344
345    #[test]
346    fn html_writer_variant() {
347        let mut out = String::new();
348        write_html(&mut out, "<b>").unwrap();
349        assert_eq!(out, "&lt;b&gt;");
350    }
351
352    // -- for_html_content --
353
354    #[test]
355    fn html_content_does_not_encode_quotes() {
356        assert_eq!(for_html_content(r#"a"b'c"#), r#"a"b'c"#);
357    }
358
359    #[test]
360    fn html_content_encodes_angle_brackets_and_amp() {
361        assert_eq!(for_html_content("a<b&c>d"), "a&lt;b&amp;c&gt;d");
362    }
363
364    // -- for_html_attribute --
365
366    #[test]
367    fn html_attribute_does_not_encode_gt() {
368        assert_eq!(for_html_attribute("a>b"), "a>b");
369    }
370
371    #[test]
372    fn html_attribute_encodes_quotes_and_amp_and_lt() {
373        assert_eq!(
374            for_html_attribute(r#"a"b'c&d<e"#),
375            "a&#34;b&#39;c&amp;d&lt;e"
376        );
377    }
378
379    // -- for_html_unquoted_attribute --
380
381    #[test]
382    fn unquoted_attr_encodes_whitespace() {
383        assert_eq!(
384            for_html_unquoted_attribute("a b\tc\nd"),
385            "a&#32;b&#9;c&#10;d"
386        );
387    }
388
389    #[test]
390    fn unquoted_attr_encodes_grave_accent() {
391        assert_eq!(for_html_unquoted_attribute("a`b"), "a&#96;b");
392    }
393
394    #[test]
395    fn unquoted_attr_encodes_equals_and_slash() {
396        assert_eq!(for_html_unquoted_attribute("a=b/c"), "a&#61;b&#47;c");
397    }
398
399    #[test]
400    fn unquoted_attr_replaces_controls_with_dash() {
401        assert_eq!(for_html_unquoted_attribute("a\x01b"), "a-b");
402        assert_eq!(for_html_unquoted_attribute("a\x7Fb"), "a-b");
403    }
404
405    #[test]
406    fn unquoted_attr_encodes_nel() {
407        assert_eq!(for_html_unquoted_attribute("a\u{0085}b"), "a&#133;b");
408    }
409
410    #[test]
411    fn unquoted_attr_encodes_line_separators() {
412        assert_eq!(
413            for_html_unquoted_attribute("a\u{2028}b\u{2029}c"),
414            "a&#8232;b&#8233;c"
415        );
416    }
417
418    #[test]
419    fn unquoted_attr_passes_through_safe_chars() {
420        let safe = "ABCxyz019!#$%()*+,-.[]\\^_}";
421        assert_eq!(for_html_unquoted_attribute(safe), safe);
422    }
423
424    #[test]
425    fn unquoted_attr_passes_through_non_ascii() {
426        assert_eq!(for_html_unquoted_attribute("café"), "café");
427        assert_eq!(for_html_unquoted_attribute("日本語"), "日本語");
428    }
429}