The origin check that was never there: postMessage as a data-theft primitive
How message handlers that skip event.origin validation turn an embedded widget into cross-window data theft or DOM XSS, and how I actually test them.
Cross-window messaging is one of those features where the secure default is “trust nobody” and the convenient default people actually write is “trust everybody.” The API hands you the sender’s origin on every message. Using it is optional. That optionality is the whole class.
The vulnerable shape
Here is the handler I look for, in roughly the form it ships in:
window.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (data.type === 'setTheme') {
document.body.dataset.theme = data.value;
}
if (data.type === 'render') {
widget.innerHTML = data.html;
}
});No event.origin check anywhere. Whatever window holds a handle to this one can
fire a message event at it and the handler runs. The setTheme branch is
mostly harmless. The render branch is a DOM XSS sink: any origin that can post
to this window now writes arbitrary HTML into the page, which means script in
the victim’s origin, which means their session.
The mirror image of this is the sender that broadcasts to a wildcard target:
parentFrame.postMessage(JSON.stringify({ token: authToken }), '*');That '*' says “deliver this to whatever origin currently occupies the target
window.” If an attacker controls or has navigated that frame, the token lands in
their handler. Wildcard targetOrigin is how a page leaks its own secrets
outward; missing event.origin is how a page accepts an attacker’s commands
inward. Most real bugs are one or the other; the nastiest are both, in the same
two windows.
Where the windows come from
To send a message you need a reference to the target window. In practice that reference is handed out constantly:
- An attacker page embeds the victim in an
iframe. It now holdsiframe.contentWindowand can post to the embedded widget. - The victim opens a popup (an OAuth consent screen, a payment sheet) with
window.open. The opener keeps the returned handle; the popup keepswindow.opener. Either side can post to the other. - The victim is itself embedded somewhere and talks to
window.parentorwindow.top, which an attacker controls if the attacker is the framing page.
Embeddable widgets are the richest ground because they are designed to be loaded cross-origin. The vendor cannot know who will frame them, so they often skip the origin check entirely rather than maintain an allowlist.
How I test it
I pull every addEventListener('message' and every onmessage out of the
bundle and read each handler by hand. Beautify first; minifiers rename the
event param, so I trace the argument into JSON.parse, property access, and
ultimately a sink. The questions, in order:
- Is there any
event.origin(ore.origin) comparison at all? If not, every reachable branch is callable cross-origin. - If there is a check, is it an exact-equality string compare against a fixed
origin, or a fuzzy
includes/ regex I can satisfy from a domain I control? - Does any branch reach a sink?
innerHTML, template HTML binding, dynamic script,location =, or a postback that reflects the data to a privileged API.
Then I build the smallest possible PoC: a static HTML page that frames or opens the target and posts the payload.
<iframe id="t" src="https://widget.example/embed"></iframe>
<script>
document.getElementById('t').onload = () => {
t.contentWindow.postMessage(
JSON.stringify({ type: 'render', html: '<img src=x onerror=alert(origin)>' }),
'*'
);
};
</script>If the alert shows the widget’s origin, the data crossed the trust boundary and executed in their context. For a leak instead of an injection, I flip it: my page hosts the listener, the target posts to a window I navigated, and I capture whatever it broadcasts. Either direction, the proof is the same one line a triager can run: a page they did not write, holding data or running script it should never have reached.
The lesson that outlives any single widget: in cross-window messaging the sender is anonymous by default. If your handler doesn’t check who’s talking, it is talking to everyone.