A security review flagged your message listener: it reads event.data and acts on it without establishing who sent it, which means any page that can obtain a reference to your window can drive that code path.
Triage: Establish What the Listener Accepts
- Find every
addEventListener('message', …)in the codebase, including inside vendor bundles you inject. - For each, check whether
event.originis compared beforeevent.datais read. Reading first and validating later is the same as not validating. - Check whether the comparison uses equality or a substring test.
startsWithandincludesare both bypassable. - From the console of an unrelated page, post a message to your frame and observe whether the handler runs.
Root Cause: Delivery Is Unauthenticated by Design
postMessage is a transport, not a channel. Any script holding a reference to a window — a parent, an opener, a frame it created — may post to it, and the browser delivers the message without asserting anything about the sender beyond stamping event.origin and event.source on the event. Those two fields are the entire authentication mechanism, and they only work if the receiving code consults them.
Two shortcuts account for most real failures. The first is checking the origin with a substring test, which is bypassable in more ways than it looks: origin.includes('vendor.com') accepts https://vendor.com.attacker.example and https://evil-vendor.com alike, and startsWith('https://vendor') accepts https://vendor-attacker.example. Only exact equality against a known string is safe.
The second is checking the origin and not the source. An origin check establishes where the message came from; it does not establish which frame, and if any other frame on the page is loaded from the same origin — a second instance of the same widget, or a page the vendor navigated to — it passes the origin check while being a window you never intended to trust. Comparing event.source against the specific contentWindow you created closes that gap, and it is one line.
Resolution: A Guarded Listener
// bridge.js — the whole security surface of the embed lives here.
const EXPECTED_ORIGIN = 'https://widget.vendor.example'; // exact, no trailing slash
const ALLOWED_TYPES = new Set(['resize', 'ready', 'submit']);
export function attachBridge(iframe, handlers) {
function onMessage(event) {
// 1 — exact origin. Substring tests are bypassable; equality is not.
if (event.origin !== EXPECTED_ORIGIN) return;
// 2 — the specific window we created, not merely one from that origin.
if (event.source !== iframe.contentWindow) return;
// 3 — shape. Anything unrecognised is dropped, not partially handled.
const msg = event.data;
if (!msg || typeof msg !== 'object') return;
if (!ALLOWED_TYPES.has(msg.type)) return;
// 4 — values. A well-formed message can still carry a hostile one.
if (msg.type === 'resize') {
const h = Number(msg.height);
if (!Number.isFinite(h) || h < 0 || h > 2000) return;
handlers.resize(h);
return;
}
handlers[msg.type]?.(msg);
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}
The outbound half needs the same discipline in mirror image: always name a specific targetOrigin rather than '*', because a wildcard delivers your payload to whatever document currently occupies the frame — which may not be the vendor you embedded if they redirected.
iframe.contentWindow.postMessage({ type: 'config', theme }, EXPECTED_ORIGIN);
Verification: Post From Somewhere Else
From the console of an unrelated origin, obtain a reference to your window and post a well-formed message. The handler must not run. Then post a message from the correct origin but from a second frame loaded from that origin — it must also not run, which is the check that distinguishes an origin-only guard from a complete one.
Vendor Listeners You Did Not Write
Auditing your own listeners is the easy half. A vendor SDK you inject frequently registers its own, with its own validation or none, and it is running in your document with access to everything yours can reach.
You cannot fix their code, but you can bound it. A vendor whose messaging is untrustworthy is an argument for moving the vendor into a sandboxed frame, where its listener lives in a separate origin and can be reached only through a channel you mediate. That converts an unauditable listener in your realm into an auditable one at a boundary you control.
Where that is not practical, at least enumerate them. Wrapping addEventListener early in the page to log every message registration tells you how many exist and which scripts registered them, which is more than most teams know — and the number is usually higher than expected, because tag managers, chat widgets and payment SDKs all register their own.
Why the Source Check Is the One People Skip
Of the four checks, the origin comparison is well known and the source comparison is not, which is worth dwelling on because the gap it leaves is not theoretical.
Consider a page embedding two instances of the same vendor widget — a common arrangement for a comparison view, or a page with a widget in the body and another in a modal. Both frames are loaded from the same origin, so both pass an origin-only check, and a message from one is indistinguishable from a message from the other. A resize instruction meant for the modal resizes the inline instance; a submission from one is attributed to the other.
The more serious version involves a frame that has navigated. A vendor frame that redirects to a different page on the same origin still satisfies the origin check while running entirely different code, and a page on a vendor’s domain that accepts user content — a preview, a sandbox, a documentation playground — becomes a way to reach your listener through a frame you legitimately embedded.
Comparing event.source against the exact contentWindow you created closes both. It costs one line and one reference held in a closure, and it turns “a message from this origin” into “a message from this frame”, which is what you actually meant.
Common Pitfalls
-
Substring origin checks.
includes,startsWithand regular expressions with unanchored patterns are all bypassable by a hostile hostname. -
Origin without source. Passes for any frame from that origin, including a second instance of the same widget.
-
Using
'*'astargetOrigin. Delivers your payload to whatever document currently occupies the frame. -
Validating after acting. Reading
event.datainto application state before the checks run means the checks are decoration. -
Holding the frame reference loosely. The source check needs the exact
contentWindowfor the element currently in the document. Re-reading it from a stale element reference after a re-render compares against a window that no longer exists.
Frequently Asked Questions
Is MessageChannel a better option?
For a long-lived conversation with one frame, yes. A MessageChannel gives each side a private port, and a port can only be used by whoever holds it — so once the handshake is complete there is no listener on window for an arbitrary sender to reach, which removes the whole class of problem rather than guarding against it.
The handshake itself still uses postMessage and still needs the origin and source checks, so this is a reduction in surface rather than an elimination. It is worth the extra step for a bridge that carries anything sensitive, and unnecessary for a widget that only ever reports its height.
Can we validate the origin server-side instead?
No — event.origin is a browser-supplied property that exists only in the receiving context, and there is no server involved in a postMessage at all. Anything the client forwards to a server about who sent a message is an assertion the client made, with the same trust properties as any other client assertion.
If the decision genuinely needs to be authoritative, the message should not be the carrier. Have the frame communicate with your server directly over a channel with real authentication, and use postMessage only for the presentation concerns — resizing, focus, visibility — where a hostile message costs a layout glitch rather than anything meaningful.
What should happen to a rejected message?
Drop it silently in production and log it in development. Responding to a rejected message — even with an error — confirms to the sender that a listener exists and that their message reached it, which is information worth withholding from someone probing.
Counting rejections is useful, though. A sudden rise in messages from an unexpected origin is a signal, and a steady low-level rate is usually a browser extension rather than an attack. Aggregate the count by origin without recording the payloads, which keeps the signal without creating a log of arbitrary content someone else controls.