window.postMessage
and corresponding window.onmessage
listeners enable secure communication between cross-origin windows, including IFRAMEs. The postMessage
API is ubiquitous, used by popular JS widgets like media players (YouTube, Spotify), meeting schedulers (Calendly), chat popups, and forms[1].
Code in the main window listens for messages from these widgets and relays data to analytics dashboards, MAPs, and so on. To make the “secure” part a reality, it’s standard to check the origin
property of every message to ensure it came from a known child window:[2]
window.addEventListener("message", function(e){
if( e.origin !== "https://video.example.ly" ) return;
// do lots of special stuff with e.data, like sending a Munchkin
// "clickLink" event
});
Browsers guarantee the origin
of MessageEvents sent via postMessage
will be the document.location.origin
of the sending window. Can’t get around it: if an IFRAME at https://pages.example.com/some/lp.html
uses window.parent.postMessage
, the parent will always see the origin
value "https://pages.example.com"
.
But that’s not the only way to send a MessageEvent
I’ve been choosing my words carefully. 😉
All the above is 100% true if you use postMessage
. But if you can directly access a window
object — as can non-worker code running on the same origin as the target window
— you can also do this:
const me = new MessageEvent("message", {
origin: "https://video.example.ly",
data: { whatever: "you want" }
});
window.dispatchEvent(me);
The listening window
will see the same origin
as when events truly come from the IFRAME. Which means anything the listener typically does with the e.data
, you’ve just silently fooled it into doing with your data.
Yikes? Okay, soft yikes. There are factors that limit the severity of this vulnerability, so while it’s something you should fix (and it’s easy!), no need to panic.
Limiting factors
(1) Malicious code must be able to access the window
object that’s listening for messages. An IFRAME on a different site can’t do this, you’ll get:
Blocked a frame with origin "https://malicious.example.net" from accessing a cross-origin frame.
(2) CSP policies may only allow 3rd-party JS once it’s been thoroughly audited. (But let’s be honest, in the marketing world where anyone can drop code into GTM or Tealium, this isn’t happening.)
(3) If code can access the window, countless other attacks can be mounted as well.[3] (Running untrusted code in anything but a cross-origin worker or sandboxed IFRAME is always risky, though roughly everybody does it!)
What distinguishes this vulnerability is its silence: it’s not overwriting functions or intercepting network requests. It’s just quietly penetrating something you thought was a security measure, in a by-design-but-barely-documented way.
The fix
Check the isTrusted
property in addition to origin
:
window.addEventListener("message", function(e){
if( !e.isTrusted || e.origin !== "https://video.example.ly" ) return;
// do lots of special stuff with e.data, like sending a Munchkin
// "clickLink" event
});
isTrusted
is false
for MessageEvents that were manually constructed and dispatched (unlike the MessageEvents automatically constructed by postMessage
).
The side effect, by definition, is you can’t roll + send your own MessageEvents anymore. Not sure why that would ever be critical, but I’m open to suggestions in the comments.
Notes
[1] While Marketo forms technically use postMessage
under the hood — they submit via a Marketo-hosted IFRAME for reliable transport — they’re pretty off-topic for this discussion. The visible Marketo form is not an IFRAME, it’s in the top-level DOM (which is good!).
[2] Technically, this code checks for an origin
, not for a unique window. You might have more than one window on the same origin
, each sending different messages, in which case you’d check the source
property too. But source
can also be forged in a manually constructed MessageEvent! So there’s no situation where source
alone would fix this vulnerability.
[3] Even though code in global scope can be wildly destructive, that doesn’t mean it’d be easy to exploit the same vulnerability that might be opened here, let alone exploit it silently.
For example, imagine the event listener is added in a <script type="module">
that’s always loaded before your tag manager loads a potential exploit. The malicious code can’t simply remove that listener, nor stop it from firing, nor access its code at runtime. If there’s a nonce in module scope that’s required by a remote service, the malicious code can’t read that nonce. (Of course there’s always overwriting fetch
, not saying you can’t get there but it’s not as easy.)