fix(html): stop a node label from breaking out of the embedded script block (#4124)

Node data is embedded as JSON inside a <script> element, but the serializers
escaped only `</`. An unclosed `<!--` in one label followed by a later `<script`
drove the HTML tokenizer into script-data-double-escaped state, where the real
</script> no longer closed the tag and graph.html rendered blank. Escape every
`<` as < in both exporters/html.py and tree_html.py; labels still render
because `<` round-trips through JSON.parse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
safishamsiandClaude Opus 4.8 committed 2026-10-10 21:20:29 +01:00
1 parent 93de0d4aea
commit 177d2071e5
3 files changed
+81 -5

No files matched your search

+6 -2
View File
@@ -613,9 +613,13 @@ 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 `<` as < so embedded JSON cannot break out of the <script>
# element. Escaping only `</` is not enough: an unclosed `<!--` then a later
# `<script` in a label drive the HTML tokenizer into script-data-double-escaped
# state, where the real `</script>` no longer closes the tag and the page goes
# blank (#4124). `<` round-trips through JSON.parse, so labels render unchanged.
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)
+4 -3
View File
@@ -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 `<` as < so embedded JSON cannot break out of the <script>
# element (escaping only `</` leaves the `<!--` + `<script` breakout open, #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),
+71
View File
@@ -0,0 +1,71 @@
"""A node label can never break out of the embedded <script> block (#4124).
Node data is serialized to JSON and embedded inside a <script> element. Escaping
only `</` is not enough: an unclosed `<!--` in one label followed by a later
`<script` drives the HTML tokenizer into script-data-double-escaped state, where
the real `</script>` no longer closes the tag — the rest of the page is swallowed
and graph.html renders blank. Escaping every `<` as \\u003c closes all three
breakout sequences (`<!--`, `<script`, `</script`) while labels still render,
since `<` round-trips through JSON.parse.
"""
from __future__ import annotations
import json
import networkx as nx
from graphify.exporters.html import to_html
from graphify.tree_html import emit_html
DANGEROUS = [
"foo <!-- bar components the panel", # unclosed comment opener
"code:vue (<script setup>)", # script opener
"end </script> tail", # explicit closer
]
def _no_breakout(html: str) -> None:
# No label-originated raw breakout sequence survives in the document.
assert "<!--" not in html
assert "<script setup" not in html
assert "</script> tail" not in html
# The escaped form is what carries the label through instead.
assert "\\u003c" in html
def test_to_html_labels_cannot_break_out_of_script(tmp_path) -> None:
G = nx.Graph()
for i, lbl in enumerate(DANGEROUS):
G.add_node(f"n{i}", label=lbl, file_type="code", source_file=f"f{i}.py")
G.add_edge("n0", "n1", relation="imports")
out = tmp_path / "graph.html"
assert to_html(G, {0: list(G.nodes)}, str(out)) is True
html = out.read_text(encoding="utf-8")
_no_breakout(html)
# Exactly the page's own <script> elements survive — no label minted an extra one.
assert html.count("<script") == html.count("</script")
def test_tree_html_labels_cannot_break_out_of_script() -> None:
tree = {
"name": "root",
"children": [{"name": lbl, "children": []} for lbl in DANGEROUS],
}
html = emit_html(tree, title="t", header="h")
_no_breakout(html)
def test_clean_labels_round_trip_unchanged(tmp_path) -> None:
"""A label with no `<` is untouched, and a `<`-bearing label still decodes
back to the original text."""
G = nx.Graph()
G.add_node("a", label="plain_label", file_type="code", source_file="a.py")
G.add_node("b", label="Vector<T>", file_type="code", source_file="b.ts")
G.add_edge("a", "b", relation="imports")
out = tmp_path / "graph.html"
to_html(G, {0: ["a", "b"]}, str(out))
html = out.read_text(encoding="utf-8")
assert "plain_label" in html
# the escaped generic type decodes back to the original
assert json.loads('"Vector\\u003cT>"') == "Vector<T>"
assert "Vector\\u003cT>" in html