Skip to content

fix(docs): escape untrusted fields in LinkedIn app graph view template (#2251) - #2264

Open
chelsealong wants to merge 3 commits into
unclecode:developfrom
chelsealong:fix/2251-linkdin-xss
Open

chelsealong wants to merge 3 commits into
unclecode:developfrom
chelsealong:fix/2251-linkdin-xss

Conversation

@chelsealong

Copy link
Copy Markdown

Fixes #2251.

Problem

docs/apps/linkdin/templates/graph_view_template.html (the LinkedIn Data
Discovery example app from the blog series, not shipped with the pip
package) rendered crawled/uploaded data through innerHTML without
escaping in five places:

  1. Company list (li.innerHTML) — n.name, n.industry, n.about, n.handle
  2. Org chart panel (renderOrgpane.innerHTML) — companyName, n.name,
    n.title, n.profile_url (inside an href attribute)
  3. Person detail panel (showPersonDetailsbox.innerHTML) — p.name,
    p.title, p.dept, p.title_level, p.avatar_url (inside an src
    attribute), p.id (inside an href attribute)
  4. AI chat drawer (appendMsg) — the streaming branch appended raw model
    text as HTML, and the completion branch assigned marked.parse(text)
    (unsanitized) straight to innerHTML; model output is influenced by
    crawled page content placed into the chat context, so this is reachable
    via prompt injection
  5. Graph hover tooltip (hoverNode handler) — node.name, node.industry,
    node.about

A maliciously crafted company_graph.json/org_chart_*.json (or a
user-uploaded .json file, or a crawled page whose content ends up in the
AI chat context) could execute arbitrary JavaScript in the app's origin —
either via a plain <script>/<img onerror> payload or via attribute
breakout in the href/src attributes.

Fix

  • Added an escapeHtml() helper that HTML-entity-encodes & < > " ', so it
    is safe both for text nodes and inside quoted attribute values. Applied it
    at all five sinks above.
  • Loaded DOMPurify and sanitize the AI chat's marked.parse() output before
    assigning it to innerHTML, since markdown parsers do not strip embedded
    HTML by default.
  • Entity escaping does not stop a javascript: (or other non-http) URL from
    reaching setAttribute for an href/src sink — as
    @ntohidi noted on the issue,
    profile_url and avatar_url need a scheme check on top of escaping.
    Added a safeUrl(value, fallback) helper that resolves the value as a
    URL and only lets http:/https: through, falling back to '#' (or a
    caller-supplied default) for anything else — including javascript:,
    data:, vbscript:, and malformed URLs. Applied it at the three
    href/src sinks that carry a URL-shaped field: n.profile_url,
    p.avatar_url, and p.id (used as an href).

The n.handle href is unaffected — the scheme is hardcoded
(https://www.linkedin.com${...}) and can't be overridden by the
interpolated 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 for marked and
split.js in this template). The localStorage OpenAI 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:

  • Extracts escapeHtml() from the template and executes it in Node to
    prove a <img src=x onerror=alert(1)>' " & payload is fully neutralized
    (no raw < > " ' survive).
  • Extracts safeUrl() and executes it in Node to prove javascript:,
    JaVaScRiPt:, data:, and vbscript: payloads are all rejected
    (fall back to '#', payload never appears in the output), while
    http:// and https:// URLs pass through unchanged.
  • Confirms DOMPurify is loaded.
  • Parametrized checks that each of the five original sinks now routes
    through escapeHtml()/DOMPurify.sanitize(), and that the three
    URL-shaped sinks specifically route through safeUrl() (not bare
    escapeHtml()).
  • Confirms the original unescaped patterns, and the earlier
    entity-escaping-only href/src patterns, are both gone.

Verified the tests actually exercise the vulnerable code paths:

  • Reverting the template to right before this PR
    (git checkout HEAD~1 -- docs/apps/linkdin/templates/graph_view_template.html,
    i.e. fully unescaped) → 33/36 fail.
  • Reverting only the new safeUrl() work on top of the previously-shipped
    entity-escaping-only version → 12/36 fail, all on the new scheme-check
    assertions, confirming they catch exactly the gap that was flagged.
  • Restoring the fix → 36/36 pass.
$ python3 -m pytest tests/test_issue_2251_linkdin_xss.py -v
...
36 passed in 0.24s

AI assistance disclosure

This fix was prepared with AI assistance (Claude).

🤖 Generated with Claude Code

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.
@Beverly621

Copy link
Copy Markdown

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:

  • The PR currently targets main; the contribution guide asks external PRs to target develop, and the current diff therefore includes unrelated commits already present on develop.
  • Sinks 1–3 and 5 still use innerHTML plus entity escaping rather than the DOM APIs requested in the issue discussion.
  • A few values that can also be attacker-controlled in uploaded/crawled JSON remain interpolated without escaping: n.followers, p.yoe_current, p.connection_count, and node.followers in the hover panel.

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.

@Beverly621

Copy link
Copy Markdown

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.
@chelsealong
chelsealong changed the base branch from main to develop September 14, 2026 13:48
@chelsealong

Copy link
Copy Markdown
Author

Thanks for the review, @Beverly621 — two quick fixes pushed:

  1. Retargeted the base to develop per CONTRIBUTING.md. The diff now shows only this PR's own commits (270 additions), the unrelated main-vs-develop commits are gone.
  2. You were right that n.followers, node.followers, p.yoe_current, and p.connection_count were interpolated raw in sinks 1/3/5 — sibling fields in those same sinks went through escapeHtml() but these didn't. Fixed in a3e3907, with 8 new parametrized test cases (4 asserting the escaped form is present, 4 asserting the old raw form is gone); pytest tests/test_issue_2251_linkdin_xss.py is 44/44 passing.

On the DOM-API refactor for sinks 1–3/5: entity-escaping via escapeHtml() is a complete fix for these text-only interpolations (verified above), so I'd rather keep this PR's diff scoped to the reported XSS gaps. Happy to review a follow-up PR if you'd still like to do that refactor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[docs] DOM XSS: unescaped innerHTML interpolation in docs/apps/linkdin graph view template (5 sinks)

2 participants