4 min readPOSTMESSAGE · CLIENT-SIDE · WEB

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:

js
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:

js
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 holds iframe.contentWindow and 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 keeps window.opener. Either side can post to the other.
  • The victim is itself embedded somewhere and talks to window.parent or window.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:

  1. Is there any event.origin (or e.origin) comparison at all? If not, every reachable branch is callable cross-origin.
  2. 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?
  3. 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.

html
<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.