gp_sec
Back to all findings

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.

Back to all findings