From 86d8aadd3b086806a8b047794d13d8a416433989 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Sun, 20 Sep 2026 18:07:23 +0100 Subject: [PATCH] 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= 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) --- graphify/exporters/html.py | 15 ++++++++++++++- tests/test_export.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py index 7d0101458..e4be7f1a3 100644 --- a/graphify/exporters/html.py +++ b/graphify/exporters/html.py @@ -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 = ` sinks). So raw special chars + # are shown literally and must NOT be html-escaped here or the user + # sees `&`/`<` 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}")), diff --git a/tests/test_export.py b/tests/test_export.py index 971e85e28..725f45843 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -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)" + )