From d4992b28c4ff294227bcaedc934708f5a83f8cee Mon Sep 17 00:00:00 2001 From: chelsealong Date: Mon, 14 Sep 2026 13:11:47 +0000 Subject: [PATCH 1/3] fix(docs): escape untrusted fields in LinkedIn app graph view template (#2251) The LinkedIn Data Discovery example app (docs/apps/linkdin/) rendered crawled/uploaded data through innerHTML without escaping in five places: the company list, org chart panel, person detail panel, AI chat drawer, and graph hover tooltip. A crafted company_graph.json, org_chart_*.json, or crawled page content could execute arbitrary JavaScript in the app's origin, including via attribute breakout in href/src attributes and via prompt injection into the AI chat's streamed/markdown output. Add an escapeHtml() helper and apply it at all five sinks, and sanitize the AI chat's marked.parse() output with DOMPurify before assigning to innerHTML. --- .../templates/graph_view_template.html | 56 +++++--- tests/test_issue_2251_linkdin_xss.py | 132 ++++++++++++++++++ 2 files changed, 165 insertions(+), 23 deletions(-) create mode 100644 tests/test_issue_2251_linkdin_xss.py diff --git a/docs/apps/linkdin/templates/graph_view_template.html b/docs/apps/linkdin/templates/graph_view_template.html index 9d908d8fc..70a6cb2e7 100644 --- a/docs/apps/linkdin/templates/graph_view_template.html +++ b/docs/apps/linkdin/templates/graph_view_template.html @@ -9,6 +9,7 @@ + @@ -250,6 +251,14 @@

Organization Details

", _html(), re.S) + assert scripts, "no inline ", + "vbscript:msgbox(1)", + ], +) +def test_safe_url_blocks_dangerous_schemes(payload): + """safeUrl() must fall back instead of letting a non-http(s) scheme through. + + This is the gap the issue's collaborator explicitly flagged: entity + escaping alone does not stop a `javascript:` URL from reaching + setAttribute for an href/src sink. + """ + node_src = ( + _extract_escape_html_fn() + + "\n" + + _extract_safe_url_fn() + + f"\nconsole.log(JSON.stringify(safeUrl({payload!r})));\n" + ) + result = subprocess.run( + ["node", "-e", node_src], capture_output=True, text=True, timeout=30 + ) + assert result.returncode == 0, result.stderr + returned = result.stdout.strip() + assert payload not in returned + assert returned == '"#"' + + +@pytest.mark.skipif(not _has_node(), reason="node is required to execute the template's JS") +@pytest.mark.parametrize( + "payload", + [ + "https://www.linkedin.com/in/example", + "http://www.linkedin.com/in/example", + ], +) +def test_safe_url_allows_http_and_https(payload): + node_src = ( + _extract_escape_html_fn() + + "\n" + + _extract_safe_url_fn() + + f"\nconsole.log(JSON.stringify(safeUrl({payload!r})));\n" + ) + result = subprocess.run( + ["node", "-e", node_src], capture_output=True, text=True, timeout=30 + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == f'"{payload}"' + + # Each entry is a snippet that must be present in the fixed template, taken # from one of the five sinks the issue reported. Checking out the pre-fix # source (HEAD~1) for this file makes every one of these assertions fail, @@ -86,14 +153,14 @@ def test_escape_html_neutralizes_script_payloads(): '

${escapeHtml(companyName)}

', '
${escapeHtml(n.name)}
', '
${escapeHtml(n.title)}
', - '${escapeHtml(p.name)}', "${escapeHtml(p.title || 'Employee')}", "${escapeHtml(p.dept || 'Department not specified')}", "${escapeHtml(p.title_level || 'Level not specified')}", - '")', "contentEl.innerHTML = DOMPurify.sanitize(marked.parse(text))", @@ -124,6 +191,11 @@ def test_dompurify_is_loaded(): '>${node.name}', 'el.lastChild.innerHTML += text.replace(/\\n/g, "
")', "contentEl.innerHTML = marked.parse(text)", + # Entity-only escaping on an href/src sink does not block a `javascript:` + # scheme; these three sinks must go through safeUrl(), not escapeHtml(). + "
Date: Mon, 14 Sep 2026 13:47:59 +0000 Subject: [PATCH 3/3] fix(docs): escape follower/tenure/connection counts in LinkedIn graph view template n.followers, node.followers, p.yoe_current, and p.connection_count were interpolated into innerHTML without escapeHtml(), unlike the sibling fields in the same sinks. Since these are attacker-controlled JSON values and String.prototype.toLocaleString() returns a string unchanged, a crafted payload in any of them would bypass escaping entirely. --- docs/apps/linkdin/templates/graph_view_template.html | 8 ++++---- tests/test_issue_2251_linkdin_xss.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/apps/linkdin/templates/graph_view_template.html b/docs/apps/linkdin/templates/graph_view_template.html index 89dba054b..745bba68e 100644 --- a/docs/apps/linkdin/templates/graph_view_template.html +++ b/docs/apps/linkdin/templates/graph_view_template.html @@ -533,7 +533,7 @@

${escapeHtml(n.name)}

- ${n.followers?.toLocaleString() || '0'} followers + ${escapeHtml(n.followers?.toLocaleString() || '0')} followers
View on LinkedIn @@ -769,7 +769,7 @@

Decision Makers

- ${p.yoe_current || '?'} years at company + ${escapeHtml(p.yoe_current || '?')} years at company
@@ -777,7 +777,7 @@

Decision Makers

- ${p.connection_count || '?'} connections + ${escapeHtml(p.connection_count || '?')} connections
@@ -1147,7 +1147,7 @@

Decision Makers

${escapeHtml(node.industry || 'Industry: N/A')}
- ${node.followers?.toLocaleString() || '0'} followers + ${escapeHtml(node.followers?.toLocaleString() || '0')} followers
${escapeHtml(node.about || '')}
`; diff --git a/tests/test_issue_2251_linkdin_xss.py b/tests/test_issue_2251_linkdin_xss.py index a98df3632..fa50eb5a4 100644 --- a/tests/test_issue_2251_linkdin_xss.py +++ b/tests/test_issue_2251_linkdin_xss.py @@ -149,6 +149,7 @@ def test_safe_url_allows_http_and_https(payload): "${escapeHtml(n.industry || 'N/A')}", "${escapeHtml(n.about || 'No description available')}", "https://www.linkedin.com${escapeHtml(n.handle || '')}", + "${escapeHtml(n.followers?.toLocaleString() || '0')} followers", # 2. renderOrg (was: ${companyName}, ${n.name}, ${n.title}, ${n.profile_url}) '

${escapeHtml(companyName)}

', '
${escapeHtml(n.name)}
', @@ -161,6 +162,8 @@ def test_safe_url_allows_http_and_https(payload): "${escapeHtml(p.dept || 'Department not specified')}", "${escapeHtml(p.title_level || 'Level not specified')}", '
${escapeHtml(p.yoe_current || \'?\')} years at company', + '${escapeHtml(p.connection_count || \'?\')} connections', # 4. AI chat drawer (was: raw text += ..., marked.parse(text) unsanitized) 'el.lastChild.innerHTML += escapeHtml(text).replace(/\\n/g, "
")', "contentEl.innerHTML = DOMPurify.sanitize(marked.parse(text))", @@ -168,6 +171,7 @@ def test_safe_url_allows_http_and_https(payload): '
${escapeHtml(node.name)}
', "${escapeHtml(node.industry || 'Industry: N/A')}", '
${escapeHtml(node.about || \'\')}
', + "${escapeHtml(node.followers?.toLocaleString() || '0')} followers", ] @@ -196,6 +200,13 @@ def test_dompurify_is_loaded(): "
${n.followers?.toLocaleString() || '0'} followers", + "${node.followers?.toLocaleString() || '0'} followers", + '${p.yoe_current || \'?\'} years at company', + '${p.connection_count || \'?\'} connections', ]