Skip to content

Commit 4b0825e

Browse files
ElPTronclaude
andcommitted
fix(export): escape every "<" in JSON embedded in <script> blocks
An unclosed "<!--" followed by "<script" in the embedded data put the HTML tokenizer in the "script data double escaped" state, so the real </script> no longer closed the element and graph.html rendered blank. Escaping only "</" left both sequences intact; escape "<" as < instead, which JSON and JS decode back to "<". Applies to graphify/exporters/html.py and graphify/tree_html.py. callflow_html.py embeds no data in its <script> and is unaffected. Fixes #4124. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b57cf91 commit 4b0825e

4 files changed

Lines changed: 61 additions & 5 deletions

File tree

‎graphify/exporters/html.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -613,9 +613,11 @@ def to_html(
613613
n = member_counts.get(cid, len(communities.get(cid, []))) if member_counts else len(communities.get(cid, []))
614614
legend_data.append({"cid": cid, "color": color, "label": lbl, "count": n})
615615

616-
# Escape </script> sequences so embedded JSON cannot break out of the script tag
616+
# Escape every "<" so embedded JSON cannot break out of the script tag: besides
617+
# "</script", an unclosed "<!--" followed by "<script" keeps the real </script>
618+
# from closing the element (#4124). JSON/JS decode < back to "<".
617619
def _js_safe(obj) -> str:
618-
return json.dumps(obj).replace("</", "<\\/")
620+
return json.dumps(obj).replace("<", "\\u003c")
619621

620622
nodes_json = _js_safe(vis_nodes)
621623
edges_json = _js_safe(vis_edges)

‎graphify/tree_html.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -568,9 +568,10 @@ def emit_html(
568568
svg_width: int = 6000,
569569
svg_height: int = 8000,
570570
) -> str:
571-
# Escape </script> sequences so embedded JSON cannot break out of the
572-
# <script> tag, and HTML-escape values that land in <title>/<h1>.
573-
data_json = json.dumps(tree, ensure_ascii=True, separators=(",", ":")).replace("</", "<\\/")
571+
# Escape every "<" so embedded JSON cannot break out of the <script> tag
572+
# ("</script", or "<!--" followed by "<script", #4124), and HTML-escape
573+
# values that land in <title>/<h1>.
574+
data_json = json.dumps(tree, ensure_ascii=True, separators=(",", ":")).replace("<", "\\u003c")
574575
return _HTML_TEMPLATE.format(
575576
title=_html.escape(title),
576577
header=_html.escape(header),

‎tests/test_export.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1373,3 +1373,39 @@ def test_to_html_aggregated_community_nodes_runtime(tmp_path):
13731373
assert '<div class="field">Degree: 1</div>' in info_html
13741374
assert "Type: unknown" not in info_html
13751375
assert "Source: -" not in info_html
1376+
1377+
1378+
# ── Issue #4124: "<!--" + "<script" in labels swallowed the closing </script> ──
1379+
1380+
def test_to_html_escapes_script_data_sequences_in_embedded_json():
1381+
"""#4124: an unclosed `<!--` followed later by `<script` puts the HTML tokenizer
1382+
in the "script data double escaped" state, where the real `</script>` no longer
1383+
closes the data block and the whole page fails to render. Escaping only `</`
1384+
left both sequences intact; no `<` may reach the embedded JSON verbatim."""
1385+
G = build_from_json({
1386+
"nodes": [
1387+
{"id": "h1", "label": "An unclosed <!-- opener in a heading",
1388+
"file_type": "document", "source_file": "guide.md"},
1389+
{"id": "h2", "label": "Then a <script setup> heading",
1390+
"file_type": "document", "source_file": "guide.md"},
1391+
],
1392+
"edges": [{"source": "h1", "target": "h2", "relation": "contains",
1393+
"confidence": "EXTRACTED", "source_file": "guide.md"}],
1394+
})
1395+
G.graph["hyperedges"] = [{"id": "he1", "label": "<!-- group <script",
1396+
"nodes": ["h1", "h2"]}]
1397+
communities = cluster(G)
1398+
with tempfile.TemporaryDirectory() as tmp:
1399+
out = Path(tmp) / "graph.html"
1400+
to_html(G, communities, str(out), community_labels={c: "<!-- c <script" for c in communities})
1401+
content = out.read_text(encoding="utf-8")
1402+
1403+
for name in ("hyperedges", "RAW_NODES", "RAW_EDGES", "LEGEND"):
1404+
m = re.search(rf"const {name} = (.*?);\n", content)
1405+
assert m, f"{name} not found in HTML"
1406+
assert "<" not in m.group(1), f"raw '<' in embedded {name}"
1407+
1408+
# The data must still decode to the original labels.
1409+
labels = {n["id"]: n["label"] for n in _vis_nodes_from_html(content)}
1410+
assert labels["h1"] == "An unclosed <!-- opener in a heading"
1411+
assert labels["h2"] == "Then a <script setup> heading"

‎tests/test_tree_html.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from __future__ import annotations
1010

1111
import json
12+
import re
1213
import subprocess
1314
import sys
1415
from pathlib import Path
@@ -111,3 +112,19 @@ def test_tree_cli_partial_match_root_succeeds(tmp_path):
111112
r = _run(["tree", "--root", "pkg"], tmp_path)
112113
assert r.returncode == 0, r.stderr
113114
assert (tmp_path / "graphify-out" / "GRAPH_TREE.html").exists()
115+
116+
117+
def test_emit_html_escapes_script_data_sequences_in_embedded_json():
118+
"""#4124: an unclosed `<!--` followed by `<script` in the embedded JSON kept the
119+
real `</script>` from closing the data block. No `<` may reach it verbatim."""
120+
from graphify.tree_html import emit_html
121+
122+
tree = {"name": "root", "children": [
123+
{"name": "An unclosed <!-- opener"},
124+
{"name": "Then a <script setup> heading"},
125+
]}
126+
html = emit_html(tree, title="t", header="h")
127+
m = re.search(r"const initialJsonData = (.*?);\n", html)
128+
assert m, "initialJsonData not found in HTML"
129+
assert "<" not in m.group(1)
130+
assert json.loads(m.group(1)) == tree

0 commit comments

Comments
 (0)