diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py
index 62ba5b2a8..f531dc0ab 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) ? `` : ''}}
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 d957b8795..3c5db437d 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