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

  1. Find every addEventListener('message', …) in the codebase, including inside vendor bundles you inject.
  2. For each, check whether event.origin is compared before event.data is read. Reading first and validating later is the same as not validating.
  3. Check whether the comparison uses equality or a substring test. startsWith and includes are both bypassable.
  4. From the console of an unrelated page, post a message to your frame and observe whether the handler runs.
A message listener is a public endpoint A listener on your window receives messages from three kinds of sender: the frame you embedded, any other frame in the page including ones a vendor created, and any window that holds a reference to yours such as an opener or a popup. The browser does not distinguish them; only the origin and source checks do. the frame you embedded the intended sender — one of several any other frame on the page including frames a vendor created without telling you any window holding a reference an opener, a popup, or a frame that navigated elsewhere The listener is reachable by all three. Nothing about registering it limits who may call.

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.

Four checks, in order, failing closed A message must pass four checks before its payload is acted on. The origin must equal a known string exactly, not merely contain it. The source must be the specific window reference you created. The payload must have a recognised message type and only known fields. And the values must be within expected ranges, because a well-formed message can still carry hostile values. 1 · origin === EXPECTED_ORIGIN never includes or startsWith 2 · source === frame.contentWindow defeats a same-origin frame 3 · shape known type, known keys reject anything else 4 · values in range, of the right kind shape is not safety Order matters: never read the payload before the first two have passed. And fail closed at every step — an unrecognised message is dropped, never partially handled.

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.

Three probes that establish the guard works Three test messages. One from an unrelated origin must be rejected by the origin check. One from the correct origin but a different frame must be rejected by the source check. One from the correct frame with an unknown message type must be rejected by the shape check. Only a message passing all three should reach a handler. wrong origin rejected at check 1 handler must not run right origin, other frame rejected at check 2 the subtle one right frame, unknown type rejected at check 3 fail closed Testing only the happy path leaves all three gaps open and looks like success.

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, startsWith and 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 '*' as targetOrigin. Delivers your payload to whatever document currently occupies the frame.

  • Validating after acting. Reading event.data into application state before the checks run means the checks are decoration.

  • Holding the frame reference loosely. The source check needs the exact contentWindow for 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.


Up: Cross-Domain Communication via postMessage