fix(docs): escape untrusted fields in LinkedIn app graph view template (#2251) - #2264
chelsealong wants to merge 3 commits into
Conversation
unclecode#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.
…src sinks escapeHtml() only entity-encodes HTML metacharacters, so a javascript: payload in profile_url, avatar_url, or id passed straight through into href/src attributes and still executed on click. Add a safeUrl() helper that only allows http(s) (or scheme-relative) URLs through, falling back to '#', and use it at all three href/src sinks per the collaborator's request on unclecode#2251.
|
Hi @chelsealong — thanks for putting this together. I had just received the go-ahead from @ntohidi to work on #2251, so I would prefer to collaborate rather than open a duplicate PR. Would you be open to me preparing a supplemental commit for this PR? While reviewing the current branch, I noticed a few areas I could help cover:
I can prepare the DOM-API refactor and regression coverage, then provide a commit for cherry-pick or open a small PR against your branch. If you would rather incorporate these directly, I am also happy to help review and test. |
|
I implemented the proposed follow-ups in chelsealong#1. It is based directly on this PR branch and only changes the LinkedIn template plus its #2251 regression test. The focused suite passes all 36 tests. After merging it, please also retarget #2264 from main to develop so the upstream diff contains only the intended changes. |
… 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.
|
Thanks for the review, @Beverly621 — two quick fixes pushed:
On the DOM-API refactor for sinks 1–3/5: entity-escaping via |
Fixes #2251.
Problem
docs/apps/linkdin/templates/graph_view_template.html(the LinkedIn DataDiscovery example app from the blog series, not shipped with the pip
package) rendered crawled/uploaded data through
innerHTMLwithoutescaping in five places:
li.innerHTML) —n.name,n.industry,n.about,n.handlerenderOrg→pane.innerHTML) —companyName,n.name,n.title,n.profile_url(inside anhrefattribute)showPersonDetails→box.innerHTML) —p.name,p.title,p.dept,p.title_level,p.avatar_url(inside ansrcattribute),
p.id(inside anhrefattribute)appendMsg) — the streaming branch appended raw modeltext as HTML, and the completion branch assigned
marked.parse(text)(unsanitized) straight to
innerHTML; model output is influenced bycrawled page content placed into the chat context, so this is reachable
via prompt injection
hoverNodehandler) —node.name,node.industry,node.aboutA maliciously crafted
company_graph.json/org_chart_*.json(or auser-uploaded
.jsonfile, or a crawled page whose content ends up in theAI chat context) could execute arbitrary JavaScript in the app's origin —
either via a plain
<script>/<img onerror>payload or via attributebreakout in the
href/srcattributes.Fix
escapeHtml()helper that HTML-entity-encodes& < > " ', so itis safe both for text nodes and inside quoted attribute values. Applied it
at all five sinks above.
marked.parse()output beforeassigning it to
innerHTML, since markdown parsers do not strip embeddedHTML by default.
javascript:(or other non-http) URL fromreaching
setAttributefor anhref/srcsink — as@ntohidi noted on the issue,
profile_urlandavatar_urlneed a scheme check on top of escaping.Added a
safeUrl(value, fallback)helper that resolves the value as aURL and only lets
http:/https:through, falling back to'#'(or acaller-supplied default) for anything else — including
javascript:,data:,vbscript:, and malformed URLs. Applied it at the threehref/src sinks that carry a URL-shaped field:
n.profile_url,p.avatar_url, andp.id(used as an href).The
n.handlehref is unaffected — the scheme is hardcoded(
https://www.linkedin.com${...}) and can't be overridden by theinterpolated suffix.
This is a minimal, single-file change (plus its test) — no dependency
manifests, lockfiles, or CI config were touched (DOMPurify is loaded from a
CDN
<script>tag, the same pattern already used formarkedandsplit.jsin this template). ThelocalStorageOpenAI API key exposure@ntohidi flagged is a separate weakness and, per their comment, is
intentionally left out of this PR to be tracked on its own.
Test plan
tests/test_issue_2251_linkdin_xss.py:escapeHtml()from the template and executes it in Node toprove a
<img src=x onerror=alert(1)>' " &payload is fully neutralized(no raw
< > " 'survive).safeUrl()and executes it in Node to provejavascript:,JaVaScRiPt:,data:, andvbscript:payloads are all rejected(fall back to
'#', payload never appears in the output), whilehttp://andhttps://URLs pass through unchanged.through
escapeHtml()/DOMPurify.sanitize(), and that the threeURL-shaped sinks specifically route through
safeUrl()(not bareescapeHtml()).entity-escaping-only href/src patterns, are both gone.
Verified the tests actually exercise the vulnerable code paths:
(
git checkout HEAD~1 -- docs/apps/linkdin/templates/graph_view_template.html,i.e. fully unescaped) → 33/36 fail.
safeUrl()work on top of the previously-shippedentity-escaping-only version → 12/36 fail, all on the new scheme-check
assertions, confirming they catch exactly the gap that was flagged.
AI assistance disclosure
This fix was prepared with AI assistance (Claude).
🤖 Generated with Claude Code