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