diff --git a/.gitignore b/.gitignore index 01a33c1..faeb27b 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 4d22009..86779d3 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 fc419b9..76b90d5 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 d5d5b1e..4517bc2 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 ec1ad4f..a42eb51 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 91a0f18..5b365f1 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 abce9e1..ba2c11a 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