diff --git a/Cargo.lock b/Cargo.lock index 190501c..0248d03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2253,6 +2253,7 @@ dependencies = [ "insta", "relune-core", "relune-layout", + "relune-render-svg", "relune-render-theme", "serde", "serde_json", diff --git a/crates/relune-app/src/usecases/diff.rs b/crates/relune-app/src/usecases/diff.rs index 214b46c..bda581f 100644 --- a/crates/relune-app/src/usecases/diff.rs +++ b/crates/relune-app/src/usecases/diff.rs @@ -118,7 +118,7 @@ fn render_with_schema( let svg = render_svg_with_overlay(&positioned, svg_options, request.overlay.as_ref())?; match request.output_format { - OutputFormat::Svg => Ok(svg), + OutputFormat::Svg => Ok(svg.into_string()), OutputFormat::Html => { let html_theme = match request.options.theme { RenderTheme::Light => HtmlTheme::Light, diff --git a/crates/relune-app/src/usecases/render.rs b/crates/relune-app/src/usecases/render.rs index d28cac0..5e1139e 100644 --- a/crates/relune-app/src/usecases/render.rs +++ b/crates/relune-app/src/usecases/render.rs @@ -198,7 +198,7 @@ fn render_svg_output( compact: false, show_tooltips: true, }; - Ok(render_svg_with_overlay(positioned, options, overlay)?) + Ok(render_svg_with_overlay(positioned, options, overlay)?.into_string()) } /// Render to HTML format. diff --git a/crates/relune-render-html/Cargo.toml b/crates/relune-render-html/Cargo.toml index 8d6e88c..ddbc257 100644 --- a/crates/relune-render-html/Cargo.toml +++ b/crates/relune-render-html/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true base64.workspace = true relune-core = { path = "../relune-core" } relune-layout = { path = "../relune-layout" } +relune-render-svg = { path = "../relune-render-svg" } relune-render-theme = { path = "../relune-render-theme" } serde.workspace = true serde_json.workspace = true diff --git a/crates/relune-render-html/src/html.rs b/crates/relune-render-html/src/html.rs index 812ffa2..4c7c72c 100644 --- a/crates/relune-render-html/src/html.rs +++ b/crates/relune-render-html/src/html.rs @@ -21,13 +21,53 @@ static FAVICON_DATA_URI: LazyLock = 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 + "") { + // Same approach for `-->`: escape `>` so the runtime string is unchanged. + out.push_str(&json[copy_from..i]); + out.push_str("--\\u003E"); + 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 +229,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 +262,26 @@ 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); + // The `<` / `>` forms are valid JSON escapes that hide the raw + // `<` / `>` from the HTML parser; `JSON.parse` decodes them back losslessly. + assert_eq!(escaped, r#"{"a": "\u003C!--", "b": "--\u003E"}"#); + // Output must still be parseable by JSON consumers (e.g. the embedded viewer). + let parsed: serde_json::Value = serde_json::from_str(&escaped).expect("valid JSON"); + assert_eq!(parsed["a"], ""); + } + #[test] fn test_escape_xml_text_for_html() { assert_eq!(escape_xml_text("