diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc971b8..be7895f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.9.15 (2026-07-13) + +- Security: fix a stored XSS and broken neighbor links in the exported `graph.html` (#1838, thanks @edgestack-ai). The report's neighbor "focus" links dropped an unescaped `JSON.stringify(nid)` into a double-quoted inline `onclick`. Because the stringified value carries its own quotes, the attribute was truncated on every node — so the links never worked — and a node `id`/`label` containing a double-quote broke out of the attribute and injected live handlers. AST node ids are `[a-z0-9_]`-safe, but ids/labels from documents or from titles scraped via `graphify add ` are not, so a hostile source could plant an executable handler into a report opened locally. The id is now carried in an HTML-escaped `data-nid` attribute and dispatched through a single delegated listener, closing the injection and repairing the links. + ## 0.9.14 (2026-07-12) - Fix: Visual Studio *solution folder* nodes no longer embed the absolute scan path (including the local username) in their `id` and `source_file` (#1789, thanks @fremat79). A solution folder is a virtual grouping, not a file — VS writes its name as both the display name and the "path" — but `extract_sln` resolved it to an absolute filesystem path anyway and keyed the node id off that. The CLI's id-relativization pass only remaps ids of real files in the scan set, so a virtual folder never matched and its absolute id survived into a committed `graph.json` (e.g. `id=/Users//proj/Plugins` instead of `id=plugins`). Solution folders are now detected (name == path) and keyed off the folder name only; real project files still resolve as before. (The earlier fix covered `.csproj`/`.sln` file nodes but missed the virtual folders — this completes it.) diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py index 5ba61ce2..59c0e52e 100644 --- a/graphify/exporters/html.py +++ b/graphify/exporters/html.py @@ -175,7 +175,7 @@ function showInfo(nodeId) {{ const neighborItems = neighborIds.map(nid => {{ const nb = nodesDS.get(nid); const color = nb ? nb.color.background : '#555'; - return `${{esc(nb ? nb.label : nid)}}`; + return `${{esc(nb ? nb.label : nid)}}`; }}).join(''); document.getElementById('info-content').innerHTML = `
${{esc(n.label)}}
@@ -193,6 +193,19 @@ function focusNode(nodeId) {{ showInfo(nodeId); }} +// Neighbor links use a data attribute + one delegated listener rather than an +// inline onclick. A node id/label sourced from a document or a scraped URL +// (graphify add) can contain a double-quote; dropping the stringified id +// unescaped into a quoted onclick both broke every link and allowed a hostile +// source to inject an event handler into the local report (stored XSS, #1838). +// esc() on data-nid keeps the value inside the attribute; the listener reads it +// back verbatim. Bound to document so it survives the innerHTML rebuild that +// recreates #neighbors-list on each showInfo(). +document.addEventListener('click', e => {{ + const el = e.target.closest('.neighbor-link'); + if (el && el.dataset.nid !== undefined) focusNode(el.dataset.nid); +}}); + // Track hovered node — hover detection is more reliable than click params let hoveredNodeId = null; network.on('hoverNode', params => {{ diff --git a/pyproject.toml b/pyproject.toml index b46d6fbb..1b2b818d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.14" +version = "0.9.15" description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_export.py b/tests/test_export.py index 87ed6063..645659ac 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -159,6 +159,27 @@ def test_to_html_contains_visjs(): assert "vis-network" in content +def test_to_html_neighbor_links_have_no_inline_onclick_xss(): + """#1838: neighbor links dropped an unescaped JSON.stringify(nid) into a + quoted inline onclick — which broke every link (the value's own quotes + truncated the attribute) and let a node id/label containing a double-quote + (from a document or a scraped `graphify add` URL) inject a live event handler + into the local report (stored XSS). The template must instead carry the id in + an escaped data attribute and dispatch via one delegated listener.""" + G = make_graph() + communities = cluster(G) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "graph.html" + to_html(G, communities, str(out)) + html = out.read_text() + # The vulnerable inline handler is gone entirely... + assert 'onclick="focusNode(' not in html + assert "JSON.stringify(nid)" not in html + # ...replaced by an escaped data attribute + a single delegated listener. + assert 'data-nid="${esc(nid)}"' in html + assert "closest('.neighbor-link')" in html + + def test_to_html_pins_visjs_version_with_sri(): """vis-network script tag must use a pinned versioned URL with a sha384 Subresource Integrity hash and crossorigin=anonymous. Without this,