Skip to main content

sofie_engine/
escape.rs

1use memchr::{memchr2, memchr3};
2
3/// An allowlist: `javascript:`, `data:` and any scheme invented later fall to `#`.
4const SAFE_SCHEMES: [&str; 4] = ["http", "https", "mailto", "tel"];
5
6const UNSAFE_URL_REPLACEMENT: &str = "#";
7
8pub fn push_escaped(out: &mut String, text: &str) {
9    let bytes = text.as_bytes();
10    let markup = memchr3(b'&', b'<', b'>', bytes);
11    let Some(first) = markup.into_iter().chain(memchr2(b'"', b'\'', bytes)).min() else {
12        out.push_str(text);
13        return;
14    };
15    out.push_str(text.get(..first).unwrap_or_default());
16    push_escaped_bytewise(out, text.get(first..).unwrap_or_default());
17}
18
19fn push_escaped_bytewise(out: &mut String, text: &str) {
20    let mut chunk_start = 0;
21    for (index, byte) in text.bytes().enumerate() {
22        let replacement = match byte {
23            b'&' => "&amp;",
24            b'<' => "&lt;",
25            b'>' => "&gt;",
26            b'"' => "&quot;",
27            b'\'' => "&#39;",
28            _ => continue,
29        };
30        out.push_str(text.get(chunk_start..index).unwrap_or_default());
31        out.push_str(replacement);
32        chunk_start = index + 1;
33    }
34    out.push_str(text.get(chunk_start..).unwrap_or_default());
35}
36
37pub(crate) fn push_url(out: &mut String, url: &str, is_authored: bool) {
38    if !is_safe_url(url) {
39        out.push_str(UNSAFE_URL_REPLACEMENT);
40    } else if is_authored {
41        out.push_str(url);
42    } else {
43        push_escaped(out, url);
44    }
45}
46
47/// Browsers ignore control characters and spaces in a scheme (`java\tscript:`).
48fn is_safe_url(url: &str) -> bool {
49    let Some(end) = url.find([':', '/', '?', '#']) else {
50        return true;
51    };
52    if !url.get(end..).is_some_and(|rest| rest.starts_with(':')) {
53        return true;
54    }
55    let scheme = url
56        .get(..end)
57        .unwrap_or_default()
58        .bytes()
59        .filter(|byte| *byte > b' ')
60        .map(|byte| byte.to_ascii_lowercase());
61    SAFE_SCHEMES
62        .iter()
63        .any(|safe| scheme.clone().eq(safe.bytes()))
64}
65
66/// `<`, `>`, `&`, U+2028 and U+2029 are `\u`-escaped, so no text can close the element.
67#[cfg(any(feature = "islands", feature = "clients"))]
68pub(crate) fn push_script_json(html: &mut String, props: &serde_json::Value) {
69    let json = props.to_string();
70    let mut run_start = 0;
71    for (index, character) in json.char_indices() {
72        let replacement = match character {
73            '<' => "\\u003c",
74            '>' => "\\u003e",
75            '&' => "\\u0026",
76            '\u{2028}' => "\\u2028",
77            '\u{2029}' => "\\u2029",
78            _ => continue,
79        };
80        html.push_str(json.get(run_start..index).unwrap_or_default());
81        html.push_str(replacement);
82        run_start = index + character.len_utf8();
83    }
84    html.push_str(json.get(run_start..).unwrap_or_default());
85}
86
87#[cfg(test)]
88mod tests {
89    use super::{is_safe_url, push_escaped};
90
91    #[cfg(any(feature = "islands", feature = "clients"))]
92    #[test]
93    fn script_json_escapes_what_could_end_the_element_and_keeps_the_rest() {
94        let mut html = String::from("x");
95        super::push_script_json(&mut html, &serde_json::json!("é</script>\u{2028}ü&"));
96        assert_eq!(html, "x\"é\\u003c/script\\u003e\\u2028ü\\u0026\"");
97    }
98
99    #[test]
100    fn escapes_the_five_html_characters() {
101        let mut out = String::from("x");
102        push_escaped(&mut out, "<a href=\"1\">'é' & b</a>");
103        assert_eq!(
104            out,
105            "x&lt;a href=&quot;1&quot;&gt;&#39;é&#39; &amp; b&lt;/a&gt;"
106        );
107    }
108
109    #[test]
110    fn escapes_from_the_first_special_character_of_either_kind() {
111        for (text, escaped) in [
112            ("plain text, nothing to do", "plain text, nothing to do"),
113            ("it's", "it&#39;s"),
114            ("a \"b\" <c>", "a &quot;b&quot; &lt;c&gt;"),
115            ("é & ü", "é &amp; ü"),
116            ("", ""),
117        ] {
118            let mut out = String::new();
119            push_escaped(&mut out, text);
120            assert_eq!(out, escaped, "{text:?}");
121        }
122    }
123
124    #[test]
125    fn allows_relative_and_web_urls() {
126        for url in [
127            "/orders/1",
128            "orders?a=b:c",
129            "#top",
130            "https://x.test",
131            "MAILTO:a@b.test",
132            "",
133        ] {
134            assert!(is_safe_url(url), "{url}");
135        }
136    }
137
138    #[test]
139    fn rejects_script_and_data_urls_in_any_disguise() {
140        for url in [
141            "javascript:alert(1)",
142            " JaVaScRiPt:x",
143            "java\tscript:x",
144            "\u{1}javascript:x",
145            "data:text/html,x",
146            "vbscript:x",
147        ] {
148            assert!(!is_safe_url(url), "{url:?}");
149        }
150    }
151}