1use memchr::{memchr2, memchr3};
2
3const 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'&' => "&",
24 b'<' => "<",
25 b'>' => ">",
26 b'"' => """,
27 b'\'' => "'",
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
47fn 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#[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<a href="1">'é' & b</a>"
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's"),
114 ("a \"b\" <c>", "a "b" <c>"),
115 ("é & ü", "é & ü"),
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}