Skip to content

Commit c9be562

Browse files
authored
Merge pull request #163 from mhiro2/harden/render-html-embedding-boundary
fix(render-html): Harden HTML embedding boundary and metadata escaping
2 parents 621440f + d61f422 commit c9be562

7 files changed

Lines changed: 179 additions & 35 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/relune-app/src/usecases/diff.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ fn render_with_schema(
118118
let svg = render_svg_with_overlay(&positioned, svg_options, request.overlay.as_ref())?;
119119

120120
match request.output_format {
121-
OutputFormat::Svg => Ok(svg),
121+
OutputFormat::Svg => Ok(svg.into_string()),
122122
OutputFormat::Html => {
123123
let html_theme = match request.options.theme {
124124
RenderTheme::Light => HtmlTheme::Light,

crates/relune-app/src/usecases/render.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ fn render_svg_output(
198198
compact: false,
199199
show_tooltips: true,
200200
};
201-
Ok(render_svg_with_overlay(positioned, options, overlay)?)
201+
Ok(render_svg_with_overlay(positioned, options, overlay)?.into_string())
202202
}
203203

204204
/// Render to HTML format.

crates/relune-render-html/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ rust-version.workspace = true
99
base64.workspace = true
1010
relune-core = { path = "../relune-core" }
1111
relune-layout = { path = "../relune-layout" }
12+
relune-render-svg = { path = "../relune-render-svg" }
1213
relune-render-theme = { path = "../relune-render-theme" }
1314
serde.workspace = true
1415
serde_json.workspace = true

crates/relune-render-html/src/html.rs

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,53 @@ static FAVICON_DATA_URI: LazyLock<String> = LazyLock::new(|| {
2121
});
2222

2323
/// Escape JSON content for safe embedding in a script tag.
24+
///
25+
/// The HTML parser treats script end tags case-insensitively, so any
26+
/// `</script` sequence must be neutralized regardless of casing (e.g.
27+
/// `</SCRIPT>`, `</Script foo>`) to prevent premature script termination
28+
/// and XSS via metadata. `<!--` and `-->` are also neutralized to avoid
29+
/// HTML comment issues in legacy parsers.
2430
pub fn escape_json_for_script(json: &str) -> String {
25-
// For JSON inside a script tag, we need to escape </script> to prevent
26-
// premature script termination. We also escape <!-- and --> to prevent
27-
// HTML comment issues in older browsers.
28-
json.replace("</script>", "<\\/script>")
29-
.replace("<!--", "<\\!--")
30-
.replace("-->", "-\\->")
31+
let bytes = json.as_bytes();
32+
let mut out = String::with_capacity(json.len());
33+
let mut copy_from = 0;
34+
let mut i = 0;
35+
while i < bytes.len() {
36+
let b = bytes[i];
37+
if b == b'<' && matches_ascii_ci(bytes, i + 1, b"/script") {
38+
// Preserve the original casing of the matched substring and only
39+
// insert the inert backslash before the slash.
40+
out.push_str(&json[copy_from..i]);
41+
out.push_str("<\\");
42+
out.push_str(&json[i + 1..i + "</script".len()]);
43+
i += "</script".len();
44+
copy_from = i;
45+
} else if b == b'<' && bytes.get(i + 1..i + 4) == Some(b"!--") {
46+
// Emit a JSON `\uXXXX` escape for `<` so the HTML parser never sees
47+
// `<!--`, while `JSON.parse` still recovers the original character.
48+
out.push_str(&json[copy_from..i]);
49+
out.push_str("\\u003C!--");
50+
i += "<!--".len();
51+
copy_from = i;
52+
} else if b == b'-' && bytes.get(i + 1..i + 3) == Some(b"->") {
53+
// Same approach for `-->`: escape `>` so the runtime string is unchanged.
54+
out.push_str(&json[copy_from..i]);
55+
out.push_str("--\\u003E");
56+
i += "-->".len();
57+
copy_from = i;
58+
} else {
59+
i += 1;
60+
}
61+
}
62+
out.push_str(&json[copy_from..]);
63+
out
64+
}
65+
66+
/// Returns true when `bytes[start..]` starts with `needle` ignoring ASCII case.
67+
fn matches_ascii_ci(bytes: &[u8], start: usize, needle: &[u8]) -> bool {
68+
bytes
69+
.get(start..start + needle.len())
70+
.is_some_and(|slice| slice.eq_ignore_ascii_case(needle))
3171
}
3272

3373
/// Build the complete HTML document.
@@ -189,13 +229,59 @@ mod tests {
189229
assert!(!escaped.contains("</script>"));
190230
}
191231

232+
#[test]
233+
fn test_escape_json_for_script_neutralizes_uppercase_end_tag() {
234+
// HTML parsers treat </SCRIPT> identically to </script>; the escape
235+
// must catch it to prevent XSS via metadata that contains uppercase
236+
// or mixed-case script end tags.
237+
let input = r#"{"name": "</SCRIPT>"}"#;
238+
let escaped = escape_json_for_script(input);
239+
assert_eq!(escaped, r#"{"name": "<\/SCRIPT>"}"#);
240+
assert!(!escaped.to_ascii_lowercase().contains("</script>"));
241+
}
242+
243+
#[test]
244+
fn test_escape_json_for_script_neutralizes_mixed_case_end_tag() {
245+
let input = r#"{"name": "</Script>"}"#;
246+
let escaped = escape_json_for_script(input);
247+
assert_eq!(escaped, r#"{"name": "<\/Script>"}"#);
248+
}
249+
250+
#[test]
251+
fn test_escape_json_for_script_neutralizes_end_tag_with_attribute() {
252+
// Browsers still terminate the script when </script foo> appears.
253+
let input = r#"{"name": "</script foo>"}"#;
254+
let escaped = escape_json_for_script(input);
255+
assert_eq!(escaped, r#"{"name": "<\/script foo>"}"#);
256+
}
257+
192258
#[test]
193259
fn test_escape_json_preserves_content() {
194260
let input = r#"{"key": "value", "number": 42}"#;
195261
let escaped = escape_json_for_script(input);
196262
assert_eq!(escaped, input);
197263
}
198264

265+
#[test]
266+
fn test_escape_json_preserves_unicode_content() {
267+
let input = r#"{"name": "テスト", "emoji": "🚀"}"#;
268+
let escaped = escape_json_for_script(input);
269+
assert_eq!(escaped, input);
270+
}
271+
272+
#[test]
273+
fn test_escape_json_neutralizes_html_comments() {
274+
let input = r#"{"a": "<!--", "b": "-->"}"#;
275+
let escaped = escape_json_for_script(input);
276+
// The `<` / `>` forms are valid JSON escapes that hide the raw
277+
// `<` / `>` from the HTML parser; `JSON.parse` decodes them back losslessly.
278+
assert_eq!(escaped, r#"{"a": "\u003C!--", "b": "--\u003E"}"#);
279+
// Output must still be parseable by JSON consumers (e.g. the embedded viewer).
280+
let parsed: serde_json::Value = serde_json::from_str(&escaped).expect("valid JSON");
281+
assert_eq!(parsed["a"], "<!--");
282+
assert_eq!(parsed["b"], "-->");
283+
}
284+
199285
#[test]
200286
fn test_escape_xml_text_for_html() {
201287
assert_eq!(escape_xml_text("<script>"), "&lt;script&gt;");

crates/relune-render-html/src/lib.rs

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod options;
1212

1313
pub use error::HtmlRenderError;
1414
pub use options::{HtmlRenderOptions, Theme};
15+
pub use relune_render_svg::SvgArtifact;
1516

1617
use relune_layout::LayoutGraph;
1718

@@ -20,15 +21,15 @@ use relune_layout::LayoutGraph;
2021
/// # Arguments
2122
///
2223
/// * `graph` - The layout graph containing node/edge/group information
23-
/// * `svg` - Pre-rendered SVG content to embed
24+
/// * `svg` - Pre-rendered SVG produced by [`relune_render_svg`]
2425
/// * `options` - HTML rendering options
2526
///
2627
/// # Returns
2728
///
2829
/// A complete, self-contained HTML document string.
2930
pub fn render_html(
3031
graph: &LayoutGraph,
31-
svg: &str,
32+
svg: &SvgArtifact,
3233
options: &HtmlRenderOptions,
3334
) -> Result<String, HtmlRenderError> {
3435
render_html_with_overlay(graph, svg, options, None)
@@ -40,15 +41,15 @@ pub fn render_html(
4041
/// so that client-side scripts can display lint warnings, diff status, etc.
4142
pub fn render_html_with_overlay(
4243
graph: &LayoutGraph,
43-
svg: &str,
44+
svg: &SvgArtifact,
4445
options: &HtmlRenderOptions,
4546
overlay: Option<&relune_layout::DiagramOverlay>,
4647
) -> Result<String, HtmlRenderError> {
4748
let metadata = metadata::build_metadata_with_overlay(graph, overlay);
4849
let metadata_json = serde_json::to_string(&metadata)?;
4950
let escaped_metadata = html::escape_json_for_script(&metadata_json);
5051

51-
let html_document = html::build_html_document(svg, &escaped_metadata, options);
52+
let html_document = html::build_html_document(svg.as_str(), &escaped_metadata, options);
5253

5354
Ok(html_document)
5455
}
@@ -151,12 +152,15 @@ mod tests {
151152
}
152153
}
153154

154-
fn create_test_svg() -> &'static str {
155-
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 400">
155+
fn create_test_svg() -> SvgArtifact {
156+
SvgArtifact::from_trusted(
157+
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 400">
156158
<g class="node" data-id="users"><rect x="56" y="56" width="260" height="94"/></g>
157159
<g class="node" data-id="posts"><rect x="56" y="230" width="260" height="112"/></g>
158160
<g class="edge"><line x1="316" y1="286" x2="56" y2="103"/></g>
159161
</svg>"#
162+
.to_string(),
163+
)
160164
}
161165

162166
#[test]
@@ -165,7 +169,7 @@ mod tests {
165169
let svg = create_test_svg();
166170
let options = HtmlRenderOptions::default();
167171

168-
let result = render_html(&graph, svg, &options).unwrap();
172+
let result = render_html(&graph, &svg, &options).unwrap();
169173

170174
assert!(result.contains("<!DOCTYPE html>"));
171175
assert!(result.contains("<html"));
@@ -180,7 +184,7 @@ mod tests {
180184
let svg = create_test_svg();
181185
let options = HtmlRenderOptions::default();
182186

183-
let result = render_html(&graph, svg, &options).unwrap();
187+
let result = render_html(&graph, &svg, &options).unwrap();
184188

185189
assert!(result.contains(r#"<svg xmlns="http://www.w3.org/2000/svg""#));
186190
assert!(result.contains(r#"data-id="users""#));
@@ -193,7 +197,7 @@ mod tests {
193197
let svg = create_test_svg();
194198
let options = HtmlRenderOptions::default();
195199

196-
let result = render_html(&graph, svg, &options).unwrap();
200+
let result = render_html(&graph, &svg, &options).unwrap();
197201

198202
assert!(result.contains(r#"id="relune-metadata""#));
199203
assert!(result.contains(r#""tables""#));
@@ -211,7 +215,7 @@ mod tests {
211215
..Default::default()
212216
};
213217

214-
let result = render_html(&graph, svg, &options).unwrap();
218+
let result = render_html(&graph, &svg, &options).unwrap();
215219

216220
assert!(result.contains("<title>My Schema ERD</title>"));
217221
assert!(result.contains("<h1>My Schema ERD</h1>"));
@@ -226,7 +230,7 @@ mod tests {
226230
..Default::default()
227231
};
228232

229-
let result = render_html(&graph, svg, &options).unwrap();
233+
let result = render_html(&graph, &svg, &options).unwrap();
230234

231235
assert!(result.contains("--bg-color: #0c0f1a"));
232236
assert!(result.contains("--text-color: #e2e8f0"));
@@ -241,7 +245,7 @@ mod tests {
241245
..Default::default()
242246
};
243247

244-
let result = render_html(&graph, svg, &options).unwrap();
248+
let result = render_html(&graph, &svg, &options).unwrap();
245249

246250
assert!(result.contains("--bg-color: #f7f8fc"));
247251
assert!(result.contains("--text-color: #1e293b"));
@@ -253,7 +257,7 @@ mod tests {
253257
let svg = create_test_svg();
254258
let options = HtmlRenderOptions::default();
255259

256-
let result = render_html(&graph, svg, &options).unwrap();
260+
let result = render_html(&graph, &svg, &options).unwrap();
257261

258262
assert!(result.contains("updateTransform"));
259263
assert!(result.contains("addEventListener"));
@@ -265,7 +269,7 @@ mod tests {
265269
let svg = create_test_svg();
266270
let options = HtmlRenderOptions::default();
267271

268-
let result = render_html(&graph, svg, &options).unwrap();
272+
let result = render_html(&graph, &svg, &options).unwrap();
269273

270274
// Should not reference external HTTP resources
271275
assert!(result.contains("<link"));
@@ -279,7 +283,7 @@ mod tests {
279283
let svg = create_test_svg();
280284
let options = HtmlRenderOptions::default();
281285

282-
let result = render_html(&graph, svg, &options).unwrap();
286+
let result = render_html(&graph, &svg, &options).unwrap();
283287

284288
// Check metadata contains expected structure
285289
assert!(result.contains(r#""id":"users""#));
@@ -350,12 +354,15 @@ mod tests {
350354
node_index: std::collections::BTreeMap::new(),
351355
reverse_index: std::collections::BTreeMap::new(),
352356
};
353-
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 400">
357+
let svg = SvgArtifact::from_trusted(
358+
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 400">
354359
<g class="table-node node node-kind-view" data-table-id="active_users" data-id="active_users" data-node-kind="view"></g>
355360
<g class="table-node node node-kind-enum" data-table-id="status" data-id="status" data-node-kind="enum"></g>
356-
</svg>"#;
361+
</svg>"#
362+
.to_string(),
363+
);
357364

358-
let result = render_html(&graph, svg, &HtmlRenderOptions::default()).unwrap();
365+
let result = render_html(&graph, &svg, &HtmlRenderOptions::default()).unwrap();
359366

360367
assert!(result.contains(r#""kind":"view""#));
361368
assert!(result.contains(r#""kind":"enum""#));

0 commit comments

Comments
 (0)