fix(export): complete the vis-network crash fix and pin the tooltip XSS boundary (#3699, #3686 follow-ups)

#3699: the Fermat-spiral seed positions referenced `i` inside RAW_NODES.map(n => ...)
which has no index param, so the emitted JS threw 'ReferenceError: i is not defined'
and graph.html failed to render at every graph size. Bind the index: map((n, i) => ...).

#3686: the tooltip `title` sinks were correctly un-escaped (vis-network renders a
string title via Popup.setText -> innerText, verified in the pinned 9.1.6 bundle:
its only non-Element branch is innerText and it has zero innerHTML=<var> sinks).
Documented that invariant at the sink as the #1838 stored-XSS boundary.

Adds two guards to test_export.py: a vis-network version pin assertion (a bump must
force re-verifying the innerText tooltip path) and a spiral-seed indexed-map guard
(catches the free-`i` regression, which the substring-only export tests missed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsiandClaude Opus 4.8 committed 2026-09-20 18:07:23 +01:00
1 parent 124c263488
commit 86d8aadd3b
2 files changed
+51 -1

No files matched your search

+14 -1
View File
@@ -148,9 +148,13 @@ function esc(s) {{
}}
// Build vis datasets
const nodesDS = new vis.DataSet(RAW_NODES.map(n => ({{
const nodesDS = new vis.DataSet(RAW_NODES.map((n, i) => ({{
id: n.id, label: n.label, color: n.color, size: n.size,
font: n.font, title: n.title,
// Fermat/golden-angle spiral seed positions (#3699): spreading nodes out
// before physics runs keeps avoidOverlap from computing near-zero-distance
// repulsion that blows the BarnesHut recursion into a stack overflow on large
// graphs. `i` is the map index — physics still settles small graphs identically.
x: 30 * Math.sqrt(i) * Math.cos(i * 2.4),
y: 30 * Math.sqrt(i) * Math.sin(i * 2.4),
_community: n.community, _community_name: n.community_name,
@@ -521,6 +525,15 @@ def to_html(
"color": {"background": color, "border": color, "highlight": {"background": "#ffffff", "border": color}},
"size": round(size, 1),
"font": {"size": font_size, "color": "#ffffff"},
# Tooltip `title` is a STRING, which vis-network renders via
# Popup.setText -> `frame.innerText = t` (verified in the pinned
# 9.1.6 bundle: the only non-Element branch is innerText, and the
# bundle has zero `innerHTML = <var>` sinks). So raw special chars
# are shown literally and must NOT be html-escaped here or the user
# sees `&amp;`/`&lt;` in the tooltip (#3664/#3686). This is the
# #1838 stored-XSS boundary: never pass an HTMLElement as `title`,
# and do not change the vis-network pin below without re-checking
# Popup rendering (the test_export version-pin guard enforces this).
"title": label,
"community": cid,
"community_name": sanitize_label((community_labels or {}).get(cid, f"Community {cid}")),
+37
View File
@@ -1160,3 +1160,40 @@ console.log(bad);
proc = subprocess.run([node, str(js)], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, proc.stderr
assert proc.stdout.strip() == "0", f"geometry violations: {proc.stdout.strip()}"
def test_to_html_pins_vis_network_version_for_tooltip_xss_boundary():
"""Tooltip `title` is passed to vis-network as a STRING and rendered via
Popup.setText -> innerText (verified in the 9.1.6 bundle), so tooltips are
intentionally NOT html-escaped (#3664/#3686). That safety rests on the pin:
if vis-network is bumped, the innerText rendering path (the #1838 stored-XSS
boundary) must be re-verified. This guard fails CI on a silent bump."""
G = make_graph()
communities = cluster(G)
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "graph.html"
to_html(G, communities, str(out))
content = out.read_text()
assert "vis-network@9.1.6" in content, (
"vis-network pin changed — re-verify Popup.setText renders a string "
"title via innerText (not innerHTML) before updating this pin, or the "
"un-escaped tooltip (#3686) reopens the #1838 stored-XSS boundary"
)
def test_to_html_spiral_seed_uses_a_real_map_index():
"""#3699: the Fermat-spiral seed positions reference `i`, so the node map
MUST bind an index (`RAW_NODES.map((n, i) => ...)`). Without it the emitted
JS throws `ReferenceError: i is not defined` and graph.html fails to render
at every graph size. Guards against the free-`i` regression."""
G = make_graph()
communities = cluster(G)
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "graph.html"
to_html(G, communities, str(out))
content = out.read_text()
if "Math.sqrt(i)" in content: # spiral seed present
assert "RAW_NODES.map((n, i)" in content, (
"spiral seed references `i` but the node map has no index param "
"-> ReferenceError: i is not defined (#3699)"
)