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 for character in props.to_string().chars() {
70 match character {
71 '<' => html.push_str("\\u003c"),
72 '>' => html.push_str("\\u003e"),
73 '&' => html.push_str("\\u0026"),
74 '\u{2028}' => html.push_str("\\u2028"),
75 '\u{2029}' => html.push_str("\\u2029"),
76 other => html.push(other),
77 }
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::{is_safe_url, push_escaped};
84
85 #[test]
86 fn escapes_the_five_html_characters() {
87 let mut out = String::from("x");
88 push_escaped(&mut out, "<a href=\"1\">'é' & b</a>");
89 assert_eq!(
90 out,
91 "x<a href="1">'é' & b</a>"
92 );
93 }
94
95 #[test]
96 fn escapes_from_the_first_special_character_of_either_kind() {
97 for (text, escaped) in [
98 ("plain text, nothing to do", "plain text, nothing to do"),
99 ("it's", "it's"),
100 ("a \"b\" <c>", "a "b" <c>"),
101 ("é & ü", "é & ü"),
102 ("", ""),
103 ] {
104 let mut out = String::new();
105 push_escaped(&mut out, text);
106 assert_eq!(out, escaped, "{text:?}");
107 }
108 }
109
110 #[test]
111 fn allows_relative_and_web_urls() {
112 for url in [
113 "/orders/1",
114 "orders?a=b:c",
115 "#top",
116 "https://x.test",
117 "MAILTO:a@b.test",
118 "",
119 ] {
120 assert!(is_safe_url(url), "{url}");
121 }
122 }
123
124 #[test]
125 fn rejects_script_and_data_urls_in_any_disguise() {
126 for url in [
127 "javascript:alert(1)",
128 " JaVaScRiPt:x",
129 "java\tscript:x",
130 "\u{1}javascript:x",
131 "data:text/html,x",
132 "vbscript:x",
133 ] {
134 assert!(!is_safe_url(url), "{url:?}");
135 }
136 }
137}