Challenge 3: Identify a Stored XSS Vector — Possible Solution
====================================================================
THE VULNERABLE CODE
---------------------
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
const messageDiv = document.createElement("div");
messageDiv.innerHTML = data.text; // <-- the vulnerability
chatWindow.appendChild(messageDiv);
});
HOW THIS BECOMES STORED XSS
------------------------------
The XSS course's core lesson was that browsers don't distinguish "text
the developer wrote" from "text a user supplied" once both end up inside
an HTML-parsing context — innerHTML always parses its argument AS HTML,
never as plain text, regardless of where that string originally came
from.
Here's the concrete attack path:
1. A malicious (or compromised) client sends a chat message where
"text" is something like:
2. That message travels over the WebSocket exactly like any other chat
message — nothing about the transport (WebSocket vs a normal HTTP
form post) makes this payload any different from ordinary text data.
3. The room's server-side broadcast logic (Chapter 6) forwards this
message to every OTHER client currently in the room, exactly as it
would forward any legitimate chat message — the server has no reason
to treat this text differently unless it's specifically validating or
sanitizing message content (which Challenge 2's validateChatMessage
only checks length/type, not HTML content).
4. Every recipient's client runs the vulnerable code above:
messageDiv.innerHTML = data.text parses the malicious string AS HTML,
creating a real
element with a broken src — which immediately
fires its onerror handler, executing the attacker's JavaScript in
the context of every other user's browser session, with access to
their cookies, DOM, and whatever else that origin's JavaScript can
normally do.
Why this is specifically "stored" (rather than merely "reflected") XSS:
the malicious payload isn't tied to one single request/response — once
broadcast, EVERY client who is or later joins that room and receives
this message (or a replayed/logged version of it) re-executes the
payload, the same defining trait of stored XSS the XSS course covered
for database-backed content, just persisted in "whoever's still
connected to this room" instead of a database row.
THE FIX
--------
Replace innerHTML with textContent when rendering plain chat text:
messageDiv.textContent = data.text;
textContent inserts the string as literal text — a browser will display
the literal characters "
" on screen rather than
parsing and executing them as an HTML element. If the chat feature
genuinely needs to support some rich formatting (bold, links, etc.), the
safe approach is running the untrusted text through a proper sanitizer
library (such as DOMPurify, as the XSS course covered) that strips
dangerous elements/attributes while preserving an allowlisted set of
safe formatting — never rendering raw, unsanitized user input with
innerHTML.