Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ out/*
.virtual_documents
# ignore top-level yarn files, the js-applet files are relevant
yarn.lock

2 changes: 2 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

## Bug fixes

* Fixed a stored cross-site scripting (XSS) vulnerability in `VG.render()`. Graph data was injected into an executable `<script>` block, so a node caption or property value containing `</script>` could break out and run arbitrary code in the browser of anyone opening a saved visualization. Data is now delivered as an inert `<script type="application/json">` block and read back with `JSON.parse`, with `<` escaped so no `</script>` can appear literally. The `render_widget` was unaffected.

## Improvements

## Other changes
5 changes: 3 additions & 2 deletions js-applet/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
</style>
<!--
Data injection point.
Python (nvl.py) injects a <script> here setting window.__NEO4J_VIZ_DATA__
before this page is served.
Python (nvl.py) injects a <script type="application/json"
id="neo4j-viz-data"> block here with graph data before this page is
served. The standalone entrypoint reads it back via JSON.parse.
-->
</head>
<body>
Expand Down
19 changes: 9 additions & 10 deletions js-applet/src/standalone-entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script type="application/json"
* id="neo4j-viz-data"> block; we read it back with JSON.parse, which treats
* every key (including __proto__) as ordinary data rather than executable.
*/

declare global {
interface Window {
__NEO4J_VIZ_DATA__?: Partial<WidgetData>;
}
}

const data = window.__NEO4J_VIZ_DATA__;
const dataEl = document.getElementById("neo4j-viz-data");
const data: Partial<WidgetData> | undefined = dataEl?.textContent
? JSON.parse(dataEl.textContent)
: undefined;

if (!data) {
document.body.innerHTML = `
<div style="padding: 2rem; font-family: system-ui, sans-serif;">
<h1>Missing visualization data</h1>
<p>Expected <code>window.__NEO4J_VIZ_DATA__</code> to be set.</p>
<p>Expected a <code>&lt;script type="application/json" id="neo4j-viz-data"&gt;</code> data block.</p>
<p>This page should be generated by <code>neo4j_viz</code>'s <code>render()</code> method.</p>
</div>
`;
throw new Error("window.__NEO4J_VIZ_DATA__ is not defined");
throw new Error("neo4j-viz data block not found");
}

// Kernel-less model for the static HTML page: `set` updates local state and
Expand Down
4 changes: 2 additions & 2 deletions js-applet/vite.config.html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

// HTML build: produces a single self-contained index.html with all JS and
// CSS inlined. Python injects graph data at runtime via
// window.__NEO4J_VIZ_DATA__ before serving it.
// CSS inlined. Python injects graph data at runtime as an inert
// <script type="application/json" id="neo4j-viz-data"> block before serving.
export default defineConfig({
plugins: [react(), viteSingleFile()],
define: {
Expand Down
1 change: 1 addition & 0 deletions python-wrapper/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ build
dist

src/neo4j_viz/resources/*/assets
/src/neo4j_viz/resources/nvl_entrypoint/*Layout.worker*.js
out/*
19 changes: 13 additions & 6 deletions python-wrapper/src/neo4j_viz/nvl.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
# ── Template loading ─────────────────────────────────────────────────────
# The HTML template is built by Vite (vite build --config vite.config.html.ts)
# and ships as index.html in the package resources. It contains the full
# graph component with JS/CSS inlined, and reads graph data from
# window.__NEO4J_VIZ_DATA__. Python just injects a <script> setting that
# variable before the module script runs.
# graph component with JS/CSS inlined. The standalone entrypoint reads graph
# data from an inert <script type="application/json" id="neo4j-viz-data">
# block; Python injects that block (with `<` escaped so no literal </script>
# can break out into executable markup) before the module script runs.
class NVL:
_CONTAINER_ID = "neo4j-viz-container"

Expand Down Expand Up @@ -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 </script> 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"<script>window.__NEO4J_VIZ_DATA__ = {data_json};</script>"
# Inject data as inert JSON — a browser never executes a
# <script type="application/json"> block — and a unique container ID.
data_script = f'<script type="application/json" id="neo4j-viz-data">{data_json}</script>'
html = self._template
html = html.replace("</head>", f"{data_script}\n</head>", 1)
html = html.replace(NVL._CONTAINER_ID, container_id)
Expand Down
177 changes: 89 additions & 88 deletions python-wrapper/src/neo4j_viz/resources/nvl_entrypoint/index.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion python-wrapper/tests/test_legend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions python-wrapper/tests/test_render.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import re
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -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 </script> 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 = "</script><script>alert('xss')</script>"
VG = VisualizationGraph(nodes=[Node(id="1", caption=payload)], relationships=[])
out = VG.render().data

# The breakout sequence must not appear literally in the output.
assert "</script><script>alert" not in out
# Data is delivered as inert JSON, not as an executable script assignment.
assert 'type="application/json" id="neo4j-viz-data"' in out
assert "<script>window.__NEO4J_VIZ_DATA__" not in out
# The escaped JSON still parses back to the original caption exactly.
block = re.search(r'<script type="application/json" id="neo4j-viz-data">(.*?)</script>', out, re.DOTALL)
assert block is not None
assert json.loads(block.group(1))["nodes"][0]["caption"] == payload