1 December 2025 · xss · – views
DOM XSS through postMessage, when isTrusted isn't enough
This is one of my favourite kinds of bug, because it looks safe at a glance and it isn't. A page was listening for messages from other windows, doing what looked like a security check, and then writing the message straight into the DOM. The check it did was real, it just checked the wrong thing. I found it in a product I was testing, and it let any website steal an API key from a logged-in user.
A quick word on postMessage
Browsers isolate one site from another, but sometimes two windows genuinely
need to talk, say a page and a popup it opened. postMessage is
the sanctioned way to do that across origins. One window calls
otherWindow.postMessage(data, targetOrigin), and the receiving
window handles it with a message event listener.
The catch is that anyone can send a message to your window. If you opened a popup, that popup can message you back, but so can any page that gets a handle to your window. So the receiver has one job it must not skip: check who the message actually came from before trusting it.
The vulnerable code
The listener looked roughly like this:
window.addEventListener('message', function (event) {
if (event.isTrusted === true) { // looks like a check...
var data = event.data;
document.getElementById('statusText').innerHTML = data.statusText; // sink
}
});
At first read it seems fine. There's an if, it mentions trust,
it feels defensive. That's exactly why the bug survived.
The root cause
Two mistakes, and they compound.
First, the wrong check. event.isTrusted does not mean "this
message came from someone I trust." It means "this event was generated by
the browser, not by a script calling dispatchEvent by hand." A
normal postMessage from any origin, including an
attacker's site, produces an event with isTrusted set to true.
So this check passes for everyone. The property developers actually needed
to look at is event.origin, which tells you the real origin of
the sender. That check was missing entirely.
Second, the sink. The message data goes straight into
innerHTML. Assigning attacker-controlled text to
innerHTML parses it as HTML, so any markup in it runs. Between
"trusts any origin" and "writes to innerHTML," you have a clean path from a
stranger's website to code running on this page.
The lesson I took from it: a check that mentions "trust" isn't the same as a check that verifies trust. isTrusted answers a question nobody was asking.
Exploiting it
The page ran on an authenticated session, and the same origin exposed an endpoint that returned the user's API key. That's the chain: get script running on the page, and the script can call that endpoint with the user's cookies and read the key back.
The attack page is small. It opens the vulnerable page in a popup, waits a
moment for it to load, then sends a message whose statusText is
an HTML payload:
// attacker's page
const target = window.open("https://victim-app/the-vulnerable-page");
const payload = '<img src=x onerror="/* fetch the api key, exfiltrate it */">';
setTimeout(function () {
target.postMessage({ statusText: payload }, "*");
}, 1000);
When that message lands, the vulnerable listener writes the
<img> into the page. The image fails to load on purpose,
the onerror handler fires, and now attacker JavaScript is
running in the victim's session. From there it calls the API-key endpoint
with credentials: 'include' so the user's cookies ride along,
reads the response, and sends the key off to the attacker. The victim only
had to open one link while logged in.
I've left the real endpoint and host out on purpose. The point is the shape of the chain, not a working weapon: untrusted message reaches an HTML sink, script runs in-session, and an authenticated endpoint hands over a secret.
Why it scored high
No special position needed, no man in the middle, no phishing of credentials. Any web page the victim visits while logged in can run this. It needs one click, works from any origin, and the payoff is a live API key, which is often as good as the password. That combination is what pushes a "reflected some text" bug up into the high range.
The fix
Two changes, and you want both:
window.addEventListener('message', function (event) {
if (event.origin !== 'https://trusted-origin') return; // check the real origin
document.getElementById('statusText').textContent = event.data.statusText; // safe sink
});
Check event.origin against a known allowlist so messages from
strangers are dropped. And use textContent instead of
innerHTML, so even if something unexpected gets through, it's
written as plain text and never parsed as HTML. The origin check is the real
fix; the textContent change is defence in depth, and it costs
nothing.
What to take from it
Whenever I see a message listener now, I check two things
immediately: does it validate event.origin, and where does the
data end up. If the origin check is missing and the data reaches
innerHTML, document.write, or anything that
evaluates, it's worth a very close look. isTrusted in that
listener is a small trap, because it reads like a guard and guards nothing.