diff --git a/src/html.rs b/src/html.rs index 91a488d8..0e6868ce 100644 --- a/src/html.rs +++ b/src/html.rs @@ -14,7 +14,7 @@ use std::default::Default; use crate::core::{parse_content_type, MonolithOptions}; use crate::css::embed_css; -use crate::js::attr_is_event_handler; +use crate::js::{attr_is_event_handler, escape_script_end_tag}; use crate::session::Session; use crate::url::{ clean_url, create_data_url, is_url_and_has_protocol, resolve_url, Url, EMPTY_IMAGE_DATA_URL, @@ -765,10 +765,9 @@ pub fn retrieve_and_embed_asset( if let NodeData::Text { ref contents } = text_node.data { let mut tendril = contents.borrow_mut(); tendril.clear(); - tendril.push_slice( - &String::from_utf8_lossy(&data) - .replace("", "<\\/script>"), - ); + tendril.push_slice(&escape_script_end_tag( + &String::from_utf8_lossy(&data), + )); } node.children.borrow_mut().push(text_node.clone()); diff --git a/src/js.rs b/src/js.rs index a6b463a9..136e5067 100644 --- a/src/js.rs +++ b/src/js.rs @@ -100,3 +100,37 @@ pub fn attr_is_event_handler(attr_name: &str) -> bool { .iter() .any(|a| attr_name.eq_ignore_ascii_case(a)) } + +// Escapes anything an HTML parser could read as a closing SCRIPT tag, so that +// inlined code can't break out of the SCRIPT element it's being embedded into. +// The tag name matches ASCII case-insensitively and has to be followed by +// whitespace, a solidus, or ">" to count as an end tag (WHATWG HTML 13.2.5.22): +// https://html.spec.whatwg.org/#script-data-end-tag-name-state +pub fn escape_script_end_tag(code: &str) -> String { + let bytes: &[u8] = code.as_bytes(); + let mut result: String = String::with_capacity(code.len()); + let mut copied: usize = 0; + let mut i: usize = 0; + + while i + 8 <= bytes.len() { + if bytes[i] == b'<' + && bytes[i + 1] == b'/' + && bytes[i + 2..i + 8].eq_ignore_ascii_case(b"script") + && (i + 8 == bytes.len() + || matches!( + bytes[i + 8], + b'\t' | b'\n' | b'\x0c' | b'\r' | b' ' | b'/' | b'>' + )) + { + result.push_str(&code[copied..=i]); + result.push('\\'); + copied = i + 1; + i += 2; + } else { + i += 1; + } + } + + result.push_str(&code[copied..]); + result +} diff --git a/tests/_data_/script-escape/index.html b/tests/_data_/script-escape/index.html new file mode 100644 index 00000000..2dc7613d --- /dev/null +++ b/tests/_data_/script-escape/index.html @@ -0,0 +1,9 @@ + + + + Script end tag escaping + + + + + diff --git a/tests/_data_/script-escape/script.js b/tests/_data_/script-escape/script.js new file mode 100644 index 00000000..d5a5fdf4 --- /dev/null +++ b/tests/_data_/script-escape/script.js @@ -0,0 +1,5 @@ +var a = ""; +var b = ""; +var c = ""; +var d = ""; +var e = ""; diff --git a/tests/cli/local_files.rs b/tests/cli/local_files.rs index 06d29272..24403dfd 100644 --- a/tests/cli/local_files.rs +++ b/tests/cli/local_files.rs @@ -409,6 +409,57 @@ document.body.style.color = "red"; "##.to_owned() + r##" + +"## + ); + + // Exit code should be 0 + out.assert().code(0); + } + + #[test] + fn escape_script_end_tag_variants_in_local_asset() { + let mut cmd = Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap(); + let cwd_normalized: String = env::current_dir() + .unwrap() + .to_str() + .unwrap() + .replace("\\", "/"); + let file_url_protocol: &str = if cfg!(windows) { "file:///" } else { "file://" }; + let out = cmd + .arg("-M") + .arg("tests/_data_/script-escape/index.html") + .output() + .unwrap(); + + // STDERR should contain list of retrieved file URLs + assert_eq!( + String::from_utf8_lossy(&out.stderr), + format!( + r#"{file}{cwd}/tests/_data_/script-escape/index.html +{file}{cwd}/tests/_data_/script-escape/script.js +"#, + file = file_url_protocol, + cwd = cwd_normalized, + ) + ); + + // STDOUT should contain every closing SCRIPT tag variant escaped, + // while the longer tag name in `` stays untouched + assert_eq!( + String::from_utf8_lossy(&out.stdout), + r##" + Script end tag escaping + + + + + "## ); diff --git a/tests/js/escape_script_end_tag.rs b/tests/js/escape_script_end_tag.rs new file mode 100644 index 00000000..1161b8fd --- /dev/null +++ b/tests/js/escape_script_end_tag.rs @@ -0,0 +1,95 @@ +// ██████╗ █████╗ ███████╗███████╗██╗███╗ ██╗ ██████╗ +// ██╔══██╗██╔══██╗██╔════╝██╔════╝██║████╗ ██║██╔════╝ +// ██████╔╝███████║███████╗███████╗██║██╔██╗ ██║██║ ███╗ +// ██╔═══╝ ██╔══██║╚════██║╚════██║██║██║╚██╗██║██║ ██║ +// ██║ ██║ ██║███████║███████║██║██║ ╚████║╚██████╔╝ +// ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝╚═╝ ╚═══╝ ╚═════╝ + +#[cfg(test)] +mod passing { + use monolith::js; + + #[test] + fn plain_code() { + assert_eq!(js::escape_script_end_tag("var a = 1;"), "var a = 1;"); + } + + #[test] + fn lowercase_end_tag() { + assert_eq!( + js::escape_script_end_tag(r#"s = "";"#), + r#"s = "";"#), + r#"s = "<\/SCRIPT>";"#, + ); + } + + #[test] + fn mixed_case_end_tag() { + assert_eq!( + js::escape_script_end_tag(r#"s = "";"#), + r#"s = "<\/ScRiPt>";"#, + ); + } + + #[test] + fn end_tag_with_trailing_whitespace() { + assert_eq!( + js::escape_script_end_tag( + "s = \"\"; s = \"\"; s = \"\";" + ), + "s = \"<\\/script >\"; s = \"<\\/script\t>\"; s = \"<\\/script\n>\";", + ); + } + + #[test] + fn end_tag_with_solidus() { + assert_eq!( + js::escape_script_end_tag(r#"s = "";"#), + r#"s = "<\/script/>";"#, + ); + } + + #[test] + fn end_tag_at_eof() { + assert_eq!(js::escape_script_end_tag("a"), + "<\\/script><\\/SCRIPT>", + ); + } + + #[test] + fn multibyte_chars_before_end_tag() { + assert_eq!( + js::escape_script_end_tag("let s = \"\u{2715}\";"), + "let s = \"\u{2715}<\\/script>\";", + ); + } + + #[test] + fn longer_tag_name_stays_untouched() { + assert_eq!( + js::escape_script_end_tag(r#"s = ""; t = "";"#), + r#"s = ""; t = "";"#, + ); + } + + #[test] + fn lone_less_than_and_open_tag_stay_untouched() { + assert_eq!( + js::escape_script_end_tag("a < b; s = \"