Skip to main content

sofie_engine/
escape.rs

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