From 09939de40cee9182c175b8cb2e01d1515e6fcc63 Mon Sep 17 00:00:00 2001 From: Thiago Comitre Date: Tue, 1 Sep 2026 07:43:37 +1000 Subject: [PATCH] Render source_url as a link in the HTML viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nodes can carry a `source_url` — the ingest paths set it for URL-sourced content — but the HTML viewer never rendered it. A node whose whole point is "this came from over there" gave the reader no way to get there; the field was reachable only by reading graph.json directly. The node detail panel now shows a `Link:` row when a node has a source_url, alongside the existing `Source:` row. Security: source_url is ingested content and is not trustworthy. The existing esc() helper is HTML-escaping only, which is fine for text but not sufficient for an href — `javascript:alert(1)` contains no HTML metacharacters and would survive esc() intact as a live link. This adds a safeUrl() scheme allowlist: only http: and https: are linkified, everything else renders nothing. No base is passed to URL(), so relative values are rejected too and a source_url can never resolve against the viewer's own origin. Anchors carry rel="noopener noreferrer". Four tests added covering the payload field, the absent-value case, the presence of the scheme allowlist, and that a javascript: source_url never reaches the document as an href. --- graphify/exporters/html.py | 19 +++++++++++++++- tests/test_export.py | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py index 62ba5b2a86..f531dc0ab5 100644 --- a/graphify/exporters/html.py +++ b/graphify/exporters/html.py @@ -46,6 +46,8 @@ def _html_styles() -> str: #info-content { font-size: 13px; color: #ccc; line-height: 1.6; } #info-content .field { margin-bottom: 5px; } #info-content .field b { color: #e0e0e0; } + #info-content .field a.source-link { color: #6fb3ff; text-decoration: none; word-break: break-all; } + #info-content .field a.source-link:hover { text-decoration: underline; } #info-content .empty { color: #555; font-style: italic; } .neighbor-link { display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid #333; } .neighbor-link:hover { background: #2a2a4e; } @@ -147,12 +149,25 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str: return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); }} +// URL allowlist — esc() is enough for text but NOT for an href. source_url is +// ingested content, and `javascript:alert(1)` contains no HTML metacharacters, +// so it would survive esc() intact as a live link. Only http(s) is linkified. +// No base is passed to URL(), so relative values are rejected too and a +// source_url can never resolve against the viewer's own origin. +function safeUrl(u) {{ + if (!u) return ''; + try {{ + const parsed = new URL(String(u)); + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') ? parsed.href : ''; + }} catch (e) {{ return ''; }} +}} + // Build vis datasets const nodesDS = new vis.DataSet(RAW_NODES.map(n => ({{ id: n.id, label: n.label, color: n.color, size: n.size, font: n.font, title: n.title, _community: n.community, _community_name: n.community_name, - _source_file: n.source_file, _file_type: n.file_type, _degree: n.degree, + _source_file: n.source_file, _source_url: n.source_url, _file_type: n.file_type, _degree: n.degree, }}))); const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{ @@ -209,6 +224,7 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
Type: ${{esc(n._file_type || 'unknown')}}
Community: ${{esc(n._community_name)}}
Source: ${{esc(n._source_file || '-')}}
+ ${{safeUrl(n._source_url) ? `
Link: ${{esc(n._source_url)}}
` : ''}}
Degree: ${{n._degree}}
${{neighborIds.length ? `
Neighbors (${{neighborIds.length}})
${{neighborItems}}
` : ''}} `; @@ -523,6 +539,7 @@ def to_html( "community": cid, "community_name": sanitize_label((community_labels or {}).get(cid, f"Community {cid}")), "source_file": sanitize_label(str(data.get("source_file") or "")), + "source_url": str(data.get("source_url") or ""), "file_type": data.get("file_type", ""), "degree": deg, } diff --git a/tests/test_export.py b/tests/test_export.py index d957b87957..3c5db437df 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -1087,3 +1087,48 @@ def test_hyperedge_convex_hull_js_is_geometrically_sound(): 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 _html_with_source_url(url): + """Render a one-node graph whose node carries the given source_url.""" + import networkx as nx + G = nx.Graph() + G.add_node("n1", label="Doc Node", file_type="concept", + source_file="notes.md", source_url=url) + G.add_node("n2", label="Other", file_type="concept", source_file="other.md") + G.add_edge("n1", "n2", relation="references", confidence="EXTRACTED") + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "graph.html" + to_html(G, {0: ["n1", "n2"]}, str(out)) + return out.read_text() + + +def test_to_html_emits_source_url_in_node_payload(): + html = _html_with_source_url("https://example.com/page") + assert '"source_url": "https://example.com/page"' in html + + +def test_to_html_omits_source_url_when_absent(): + """A node without source_url still renders, with an empty payload value.""" + html = _html_with_source_url("") + assert '"source_url": ""' in html + + +def test_to_html_ships_url_scheme_allowlist(): + """The viewer must gate hrefs on scheme: esc() alone would let + javascript: through, since it contains no HTML metacharacters.""" + html = _html_with_source_url("https://example.com/page") + assert "function safeUrl" in html + assert "parsed.protocol === 'http:'" in html + assert "parsed.protocol === 'https:'" in html + # the anchor is built through safeUrl, never from the raw value + assert 'href="${esc(safeUrl(n._source_url))}"' in html + assert 'rel="noopener noreferrer"' in html + + +def test_to_html_does_not_emit_raw_javascript_href(): + """A hostile source_url reaches the payload as data, but must never be + written into the document as an href by the generator.""" + html = _html_with_source_url("javascript:alert(1)") + assert 'href="javascript:' not in html + assert "href='javascript:" not in html