From d1fbcf67418ed3066c6d7744cb07c4e1f0752b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 14 Aug 2026 13:24:54 +0200 Subject: [PATCH] Fix js injection issue in VG.render method --- .gitignore | 1 + changelog.md | 2 + js-applet/index.html | 5 +- js-applet/src/standalone-entrypoint.ts | 19 +- js-applet/vite.config.html.ts | 4 +- python-wrapper/.gitignore | 1 + python-wrapper/src/neo4j_viz/nvl.py | 19 +- .../resources/nvl_entrypoint/index.html | 177 +++++++++--------- python-wrapper/tests/test_legend.py | 2 +- python-wrapper/tests/test_render.py | 23 +++ 10 files changed, 144 insertions(+), 109 deletions(-) diff --git a/.gitignore b/.gitignore index 01a33c19..faeb27b3 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ out/* .virtual_documents # ignore top-level yarn files, the js-applet files are relevant yarn.lock + diff --git a/changelog.md b/changelog.md index 4d22009b..86779d3e 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,8 @@ ## Bug fixes +* Fixed a stored cross-site scripting (XSS) vulnerability in `VG.render()`. Graph data was injected into an executable `` could break out and run arbitrary code in the browser of anyone opening a saved visualization. Data is now delivered as an inert `` can appear literally. The `render_widget` was unaffected. + ## Improvements ## Other changes diff --git a/js-applet/index.html b/js-applet/index.html index fc419b97..76b90d53 100644 --- a/js-applet/index.html +++ b/js-applet/index.html @@ -12,8 +12,9 @@ diff --git a/js-applet/src/standalone-entrypoint.ts b/js-applet/src/standalone-entrypoint.ts index d5d5b1e4..4517bc20 100644 --- a/js-applet/src/standalone-entrypoint.ts +++ b/js-applet/src/standalone-entrypoint.ts @@ -3,26 +3,25 @@ import { createLocalModel } from "./local-model"; /** * Standalone entrypoint for static HTML rendering (non-Jupyter). - * Data is injected by Python via window.__NEO4J_VIZ_DATA__. + * Data is injected by Python as an inert +# can break out into executable markup) before the module script runs. class NVL: _CONTAINER_ID = "neo4j-viz-container" @@ -50,11 +51,17 @@ def render( "options": render_options.to_widget_options().to_json(), "legend": (legend or Legend()).to_json(), } - data_json = json.dumps(data_dict) + # Escape `<` so no literal can appear inside the block and + # break out into executable markup (stored XSS). `<` is a valid JSON + # escape, so JSON.parse restores the original text exactly. (`&` and + # `>` are inert inside a script element's raw-text content, so they + # need no escaping here.) + data_json = json.dumps(data_dict).replace("<", "\\u003c") container_id = f"neo4j-viz-{uuid.uuid4().hex[:12]}" - # Inject data and unique container ID into the built template. - data_script = f"" + # Inject data as inert JSON — a browser never executes a + # ' html = self._template html = html.replace("", f"{data_script}\n", 1) html = html.replace(NVL._CONTAINER_ID, container_id) diff --git a/python-wrapper/src/neo4j_viz/resources/nvl_entrypoint/index.html b/python-wrapper/src/neo4j_viz/resources/nvl_entrypoint/index.html index ec1ad4f6..a42eb514 100644 --- a/python-wrapper/src/neo4j_viz/resources/nvl_entrypoint/index.html +++ b/python-wrapper/src/neo4j_viz/resources/nvl_entrypoint/index.html @@ -12,10 +12,11 @@ - + `,new Error("neo4j-viz data block not found");const Ipr=Mpr(S3),O3=document.getElementById("neo4j-viz-container");if(!O3)throw new Error("Container element #neo4j-viz-container not found");O3.style.width=S3.width??"100%";O3.style.height=S3.height??"100vh";Ppr.render({model:Ipr,el:O3}); diff --git a/python-wrapper/tests/test_legend.py b/python-wrapper/tests/test_legend.py index 91a0f180..5b365f14 100644 --- a/python-wrapper/tests/test_legend.py +++ b/python-wrapper/tests/test_legend.py @@ -187,7 +187,7 @@ def test_render_injects_legend_into_html() -> None: html = VG.render().data - assert "window.__NEO4J_VIZ_DATA__" in html + assert 'type="application/json" id="neo4j-viz-data"' in html assert '"legend"' in html assert "Movie" in html diff --git a/python-wrapper/tests/test_render.py b/python-wrapper/tests/test_render.py index abce9e11..ba2c11a3 100644 --- a/python-wrapper/tests/test_render.py +++ b/python-wrapper/tests/test_render.py @@ -1,3 +1,4 @@ +import json import re from pathlib import Path from typing import Any @@ -171,3 +172,25 @@ def test_render_with_wrong_layout_options() -> None: match="Unexpected `ForceDirectedLayoutOptions` parameter 'direction' with provided input 'left'", ): VG.render(layout=Layout.FORCE_DIRECTED, layout_options={"direction": "left"}) + + +def test_render_escapes_script_breakout() -> None: + # Regression test for F-01: a caption containing must not break + # out of the data block and run as executable markup. Single quotes are + # used inside the injected script because json.dumps escapes " to \", which + # after a breakout would be a JS syntax error and silently do nothing — + # the test must exercise a payload that would actually execute if unescaped. + + payload = "" + VG = VisualizationGraph(nodes=[Node(id="1", caption=payload)], relationships=[]) + out = VG.render().data + + # The breakout sequence must not appear literally in the output. + assert "', out, re.DOTALL) + assert block is not None + assert json.loads(block.group(1))["nodes"][0]["caption"] == payload