skip to content

A page runs `window.addEventListener('message', e => applyConfig(e.data))` to accept settings from an iframe it embeds. Why is that handler a security bug, and what must it check?

level: middleimportance: must knowfreq 70%

answer

  1. the message bus is shared
  2. anyone with a handle can post
  3. e.origin is the trust signal
  4. exact match, never substring
  5. also compare e.source

basics

~20 s

The window message event is a shared bus: any window holding a reference to yours — every embedded frame, your opener, a popup you opened — can post to it, so this handler applies configuration from strangers. Check event.origin against an exact allowlist, compare event.source to the expected window, and validate the payload's shape.

solid answer

~40 s

A `message` listener on `window` receives messages from *every* context that can reach that window, not just the frame you had in mind — third-party ad or analytics iframes you embed, the page that opened you, any popup you opened. Handing `e.data` straight to `applyConfig` lets any of them drive your page. Three checks belong in the handler. First, `e.origin !== 'https://widget.example.com'` and return — an exact string comparison, never `includes` or `endsWith`, because `https://widget.example.com.attacker.test` passes a substring test. `e.origin` is stamped by the browser and cannot be forged by page script, so it is the real trust signal. Second, when identity matters, compare `e.source === iframe.contentWindow`, since several frames can share one origin. Third, validate the shape of `e.data` before use, and never route it into `innerHTML` or a dynamic script.

code

javascript · 19 lines
javascript
const WIDGET_ORIGIN = 'https://widget.example.com';
const iframe = document.getElementById('widget');

function onMessage(event) {
  if (event.origin !== WIDGET_ORIGIN) return;          // exact origin, no substring match
  if (event.source !== iframe.contentWindow) return;   // the window we created, not just the site

  const msg = event.data;
  if (typeof msg !== 'object' || msg === null) return; // shape before content
  if (msg.type !== 'setConfig') return;

  applyConfig({
    theme: msg.theme === 'dark' ? 'dark' : 'light',    // whitelist, do not pass through
    rows: Number.isInteger(msg.rows) ? msg.rows : 10
  });
}

window.addEventListener('message', onMessage);
// on teardown: window.removeEventListener('message', onMessage);

go deeper

for a junior

Remember that a window message listener hears from every frame and opener, not just the one you meant, and that the first line of any handler is an exact comparison of event.origin against the origin you expect.

for a middle

Explain why event.origin is trustworthy — the browser stamps it, page script cannot set it — and demonstrate the substring-matching bug with a concrete attacker-registrable domain.

for a senior

Show layered validation on a real handler: exact origin, source identity against the frame you created, a versioned message envelope, and a rule about which sinks a received string may reach.

for a principal

Own the cross-window contract as an API surface: publish the message schema and its versioning rules, decide which teams may add window message listeners at all, and require the port-handover pattern for anything long-lived.

## The message event is a shared bus, not a private channel There is one `message` event target per window: the window itself. Every party that holds a `WindowProxy` for your page can call `postMessage` on it, and every one of your listeners will run. In an ordinary page, that set is larger than people expect: - every `<iframe>` you embed can post to `window.parent` and `window.top` — including advertising, analytics, chat-widget, consent and payment frames you did not write; - if your page was opened by another page, `window.opener` gives that page a handle to you; - any popup you opened can post back through its own `window.opener`; - a page that embeds *you* can post down through `iframe.contentWindow`. So `e => applyConfig(e.data)` is not "the config listener". It is an unauthenticated entry point into your page's state, callable by every one of those parties. If `applyConfig` can set an API endpoint, a redirect target, a feature flag or a rendered string, that is the vulnerability. ## Check one: the origin `MessageEvent.origin` is the serialized origin of the document that sent the message, written by the browser as part of dispatching the event. Page script cannot set or spoof it — that is what makes it the anchor of the whole model. Compare it with strict equality against a fixed allowlist: ```js const ALLOWED = new Set(['https://widget.example.com']); window.addEventListener('message', (e) => { if (!ALLOWED.has(e.origin)) return; // ... }); ``` An origin is scheme + host + port. Two subtleties trip people up: an origin string carries no trailing slash and no path, so `'https://widget.example.com/'` never matches; and a document with a sandboxed, `data:` or `blob:`-derived opaque origin reports `"null"` as the literal string, which must not be allowlisted. ## Substring matching is the classic bug The single most common defect in real code is a loose comparison: ```js if (e.origin.includes('example.com')) { /* ... */ } // matches https://example.com.evil.test if (e.origin.endsWith('example.com')) { /* ... */ } // matches https://notexample.com if (e.origin.startsWith('https://example')) { /* ... */ } // matches https://example-evil.test ``` Each of these is trivially satisfiable by an attacker who controls any domain, because domain registration is not restricted by your string. If you must support several origins, enumerate them, or parse with `new URL(e.origin)` and compare `host` exactly against an allowlist — never do arithmetic on the raw string. ## Check two: the source Origin alone answers "which site sent this", not "which window". If the trusted origin runs more than one frame in your page, or if a hostile page on that same origin was opened in a popup that now holds a handle to you, origin checking passes for all of them. When the distinction matters, compare identity: ```js if (e.source !== iframe.contentWindow) return; ``` `e.source` is a `WindowProxy` (or `null` when the sender is gone, or for messages arriving through a `MessagePort`). It is also the correct handle for replying: `e.source.postMessage(reply, e.origin)` addresses exactly the window that spoke to you, rather than assuming the peer is `window.parent`. ## Check three: the shape of the data Even a message from the right origin and the right window is data crossing a trust boundary, and the peer may have been compromised or may simply be an older version of itself. Treat it like any external input: require an envelope (`typeof e.data === 'object' && e.data !== null && e.data.type === 'setConfig'`), whitelist the fields you read, and coerce types rather than trusting them. Do not switch on a message shape you have never published. Where the value goes matters as much as what it is: a string from a message must not be assigned to `innerHTML`, used to build a `<script>` URL, passed to `eval`, or assigned to `location` without validating the scheme — those are the sinks that turn a trusted-origin message into script execution. ## Hygiene around the handler Register one narrow handler rather than several broad ones, return early and loudly on anything unexpected, and remove the listener when the feature unmounts so a stale handler cannot act on a page that has moved on. If the conversation is long-lived, use the first validated message to hand over a `MessagePort` and move the traffic off the shared window bus entirely — after that, only the entangled peer can reach that port, and per-message origin checks are no longer the thing standing between you and a hostile frame.

  • Why is `if (e.origin.endsWith('example.com'))` unsafe?
    Because an attacker can register a domain that satisfies it. `endsWith('example.com')` matches `https://notexample.com`, and `includes('example.com')` matches `https://example.com.attacker.test`. String containment has no relationship to the domain hierarchy. Compare the full origin with strict equality against an allowlist, or parse it with `new URL()` and match the host exactly.
  • When is checking `event.origin` not enough, so you also need `event.source`?
    When more than one window can legitimately be on the allowed origin — several frames from the same widget host, or a popup on that origin that someone else opened. Origin says which site spoke; `event.source` says which window. Comparing it to the specific `iframe.contentWindow` you created pins the conversation to your frame.
  • What is `event.origin` for a message that arrives through a `MessagePort`?
    The empty string, and `event.source` is `null`. A port is not a broadcast target: it is entangled with exactly one peer, so there is no per-message origin to report. The trust decision was made once, when you handed the port over to a verified origin — checking `e.origin` on port messages will just reject everything.

saying these in an interview costs you the question

  • Assuming only the embedded iframe can post to your window
  • Matching the origin with includes, startsWith or endsWith
  • Treating event.data as trusted because the origin checked out
  • Thinking a page can forge event.origin
  • Passing message strings straight into innerHTML

context