Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions src/html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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>", "<\\/script>"),
);
tendril.push_slice(&escape_script_end_tag(
&String::from_utf8_lossy(&data),
));
}

node.children.borrow_mut().push(text_node.clone());
Expand Down
34 changes: 34 additions & 0 deletions src/js.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
9 changes: 9 additions & 0 deletions tests/_data_/script-escape/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>Script end tag escaping</title>
<script src="script.js"></script>
</head>
<body>
</body>
</html>
5 changes: 5 additions & 0 deletions tests/_data_/script-escape/script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
var a = "</script>";
var b = "</SCRIPT>";
var c = "</script >";
var d = "</script/>";
var e = "</scripts>";
51 changes: 51 additions & 0 deletions tests/cli/local_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,57 @@ document.body.style.color = "red";
<script src="script.js"></script>
"##.to_owned() + r##"

</body></html>
"##
);

// 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 `</scripts>` stays untouched
assert_eq!(
String::from_utf8_lossy(&out.stdout),
r##"<!DOCTYPE html><html><head>
<title>Script end tag escaping</title>
<script>var a = "<\/script>";
var b = "<\/SCRIPT>";
var c = "<\/script >";
var d = "<\/script/>";
var e = "</scripts>";
</script>
<meta name="robots" content="none"></meta></head>
<body>


</body></html>
"##
);
Expand Down
95 changes: 95 additions & 0 deletions tests/js/escape_script_end_tag.rs
Original file line number Diff line number Diff line change
@@ -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 = "<script></script>";"#),
r#"s = "<script><\/script>";"#,
);
}

#[test]
fn uppercase_end_tag() {
assert_eq!(
js::escape_script_end_tag(r#"s = "</SCRIPT>";"#),
r#"s = "<\/SCRIPT>";"#,
);
}

#[test]
fn mixed_case_end_tag() {
assert_eq!(
js::escape_script_end_tag(r#"s = "</ScRiPt>";"#),
r#"s = "<\/ScRiPt>";"#,
);
}

#[test]
fn end_tag_with_trailing_whitespace() {
assert_eq!(
js::escape_script_end_tag(
"s = \"</script >\"; s = \"</script\t>\"; s = \"</script\n>\";"
),
"s = \"<\\/script >\"; s = \"<\\/script\t>\"; s = \"<\\/script\n>\";",
);
}

#[test]
fn end_tag_with_solidus() {
assert_eq!(
js::escape_script_end_tag(r#"s = "</script/>";"#),
r#"s = "<\/script/>";"#,
);
}

#[test]
fn end_tag_at_eof() {
assert_eq!(js::escape_script_end_tag("a</script"), "a<\\/script");
}

#[test]
fn multiple_end_tags() {
assert_eq!(
js::escape_script_end_tag("</script></SCRIPT>"),
"<\\/script><\\/SCRIPT>",
);
}

#[test]
fn multibyte_chars_before_end_tag() {
assert_eq!(
js::escape_script_end_tag("let s = \"\u{2715}</script>\";"),
"let s = \"\u{2715}<\\/script>\";",
);
}

#[test]
fn longer_tag_name_stays_untouched() {
assert_eq!(
js::escape_script_end_tag(r#"s = "</scripts>"; t = "</scriptx>";"#),
r#"s = "</scripts>"; t = "</scriptx>";"#,
);
}

#[test]
fn lone_less_than_and_open_tag_stay_untouched() {
assert_eq!(
js::escape_script_end_tag("a < b; s = \"<script>\"; t = \"</scr\";"),
"a < b; s = \"<script>\"; t = \"</scr\";",
);
}
}
1 change: 1 addition & 0 deletions tests/js/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
mod attr_is_event_handler;
mod escape_script_end_tag;
Loading