Skip to content
Open
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
19 changes: 18 additions & 1 deletion graphify/exporters/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -147,12 +149,25 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}}

// 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) => ({{
Expand Down Expand Up @@ -209,6 +224,7 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
<div class="field">Type: ${{esc(n._file_type || 'unknown')}}</div>
<div class="field">Community: ${{esc(n._community_name)}}</div>
<div class="field">Source: ${{esc(n._source_file || '-')}}</div>
${{safeUrl(n._source_url) ? `<div class="field">Link: <a class="source-link" href="${{esc(safeUrl(n._source_url))}}" target="_blank" rel="noopener noreferrer">${{esc(n._source_url)}}</a></div>` : ''}}
<div class="field">Degree: ${{n._degree}}</div>
${{neighborIds.length ? `<div class="field" style="margin-top:8px;color:#aaa;font-size:11px">Neighbors (${{neighborIds.length}})</div><div id="neighbors-list">${{neighborItems}}</div>` : ''}}
`;
Expand Down Expand Up @@ -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,
}
Expand Down
45 changes: 45 additions & 0 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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