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
6 changes: 4 additions & 2 deletions graphify/exporters/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -613,9 +613,11 @@ def to_html(
n = member_counts.get(cid, len(communities.get(cid, []))) if member_counts else len(communities.get(cid, []))
legend_data.append({"cid": cid, "color": color, "label": lbl, "count": n})

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

nodes_json = _js_safe(vis_nodes)
edges_json = _js_safe(vis_edges)
Expand Down
7 changes: 4 additions & 3 deletions graphify/tree_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,9 +568,10 @@ def emit_html(
svg_width: int = 6000,
svg_height: int = 8000,
) -> str:
# Escape </script> sequences so embedded JSON cannot break out of the
# <script> tag, and HTML-escape values that land in <title>/<h1>.
data_json = json.dumps(tree, ensure_ascii=True, separators=(",", ":")).replace("</", "<\\/")
# Escape every "<" so embedded JSON cannot break out of the <script> tag
# ("</script", or "<!--" followed by "<script", #4124), and HTML-escape
# values that land in <title>/<h1>.
data_json = json.dumps(tree, ensure_ascii=True, separators=(",", ":")).replace("<", "\\u003c")
return _HTML_TEMPLATE.format(
title=_html.escape(title),
header=_html.escape(header),
Expand Down
36 changes: 36 additions & 0 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -1373,3 +1373,39 @@ def test_to_html_aggregated_community_nodes_runtime(tmp_path):
assert '<div class="field">Degree: 1</div>' in info_html
assert "Type: unknown" not in info_html
assert "Source: -" not in info_html


# ── Issue #4124: "<!--" + "<script" in labels swallowed the closing </script> ──

def test_to_html_escapes_script_data_sequences_in_embedded_json():
"""#4124: an unclosed `<!--` followed later by `<script` puts the HTML tokenizer
in the "script data double escaped" state, where the real `</script>` no longer
closes the data block and the whole page fails to render. Escaping only `</`
left both sequences intact; no `<` may reach the embedded JSON verbatim."""
G = build_from_json({
"nodes": [
{"id": "h1", "label": "An unclosed <!-- opener in a heading",
"file_type": "document", "source_file": "guide.md"},
{"id": "h2", "label": "Then a <script setup> heading",
"file_type": "document", "source_file": "guide.md"},
],
"edges": [{"source": "h1", "target": "h2", "relation": "contains",
"confidence": "EXTRACTED", "source_file": "guide.md"}],
})
G.graph["hyperedges"] = [{"id": "he1", "label": "<!-- group <script",
"nodes": ["h1", "h2"]}]
communities = cluster(G)
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "graph.html"
to_html(G, communities, str(out), community_labels={c: "<!-- c <script" for c in communities})
content = out.read_text(encoding="utf-8")

for name in ("hyperedges", "RAW_NODES", "RAW_EDGES", "LEGEND"):
m = re.search(rf"const {name} = (.*?);\n", content)
assert m, f"{name} not found in HTML"
assert "<" not in m.group(1), f"raw '<' in embedded {name}"

# The data must still decode to the original labels.
labels = {n["id"]: n["label"] for n in _vis_nodes_from_html(content)}
assert labels["h1"] == "An unclosed <!-- opener in a heading"
assert labels["h2"] == "Then a <script setup> heading"
17 changes: 17 additions & 0 deletions tests/test_tree_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import json
import re
import subprocess
import sys
from pathlib import Path
Expand Down Expand Up @@ -111,3 +112,19 @@ def test_tree_cli_partial_match_root_succeeds(tmp_path):
r = _run(["tree", "--root", "pkg"], tmp_path)
assert r.returncode == 0, r.stderr
assert (tmp_path / "graphify-out" / "GRAPH_TREE.html").exists()


def test_emit_html_escapes_script_data_sequences_in_embedded_json():
"""#4124: an unclosed `<!--` followed by `<script` in the embedded JSON kept the
real `</script>` from closing the data block. No `<` may reach it verbatim."""
from graphify.tree_html import emit_html

tree = {"name": "root", "children": [
{"name": "An unclosed <!-- opener"},
{"name": "Then a <script setup> heading"},
]}
html = emit_html(tree, title="t", header="h")
m = re.search(r"const initialJsonData = (.*?);\n", html)
assert m, "initialJsonData not found in HTML"
assert "<" not in m.group(1)
assert json.loads(m.group(1)) == tree
Loading