From cb7fe4a0008529cfa69f33cd95b67c339bd9f69f Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Thu, 14 May 2026 22:25:07 +0900 Subject: [PATCH 1/3] fix(render-html): escape script end tag case-insensitively HTML parsers terminate , , or . The previous escape only matched the exact lowercase string, which let metadata containing those variants escape the JSON island and execute as markup. Match the = LazyLock::new(|| { }); /// Escape JSON content for safe embedding in a script tag. +/// +/// The HTML parser treats script end tags case-insensitively, so any +/// ``, ``) to prevent premature script termination +/// and XSS via metadata. `` are also neutralized to avoid +/// HTML comment issues in legacy parsers. pub fn escape_json_for_script(json: &str) -> String { - // For JSON inside a script tag, we need to escape to prevent - // premature script termination. We also escape to prevent - // HTML comment issues in older browsers. - json.replace("", "<\\/script>") - .replace("", "-\\->") + let bytes = json.as_bytes(); + let mut out = String::with_capacity(json.len()); + let mut copy_from = 0; + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if b == b'<' && matches_ascii_ci(bytes, i + 1, b"/script") { + // Preserve the original casing of the matched substring and only + // insert the inert backslash before the slash. + out.push_str(&json[copy_from..i]); + out.push_str("<\\"); + out.push_str(&json[i + 1..i + "") { + out.push_str(&json[copy_from..i]); + out.push_str("-\\->"); + i += "-->".len(); + copy_from = i; + } else { + i += 1; + } + } + out.push_str(&json[copy_from..]); + out +} + +/// Returns true when `bytes[start..]` starts with `needle` ignoring ASCII case. +fn matches_ascii_ci(bytes: &[u8], start: usize, needle: &[u8]) -> bool { + bytes + .get(start..start + needle.len()) + .is_some_and(|slice| slice.eq_ignore_ascii_case(needle)) } /// Build the complete HTML document. @@ -189,6 +226,32 @@ mod tests { assert!(!escaped.contains("")); } + #[test] + fn test_escape_json_for_script_neutralizes_uppercase_end_tag() { + // HTML parsers treat identically to ; the escape + // must catch it to prevent XSS via metadata that contains uppercase + // or mixed-case script end tags. + let input = r#"{"name": ""}"#; + let escaped = escape_json_for_script(input); + assert_eq!(escaped, r#"{"name": "<\/SCRIPT>"}"#); + assert!(!escaped.to_ascii_lowercase().contains("")); + } + + #[test] + fn test_escape_json_for_script_neutralizes_mixed_case_end_tag() { + let input = r#"{"name": ""}"#; + let escaped = escape_json_for_script(input); + assert_eq!(escaped, r#"{"name": "<\/Script>"}"#); + } + + #[test] + fn test_escape_json_for_script_neutralizes_end_tag_with_attribute() { + // Browsers still terminate the script when appears. + let input = r#"{"name": ""}"#; + let escaped = escape_json_for_script(input); + assert_eq!(escaped, r#"{"name": "<\/script foo>"}"#); + } + #[test] fn test_escape_json_preserves_content() { let input = r#"{"key": "value", "number": 42}"#; @@ -196,6 +259,20 @@ mod tests { assert_eq!(escaped, input); } + #[test] + fn test_escape_json_preserves_unicode_content() { + let input = r#"{"name": "ใƒ†ใ‚นใƒˆ", "emoji": "๐Ÿš€"}"#; + let escaped = escape_json_for_script(input); + assert_eq!(escaped, input); + } + + #[test] + fn test_escape_json_neutralizes_html_comments() { + let input = r#"{"a": ""}"#; + let escaped = escape_json_for_script(input); + assert_eq!(escaped, r#"{"a": "<\!--", "b": "-\->"}"#); + } + #[test] fn test_escape_xml_text_for_html() { assert_eq!(escape_xml_text("