Meet Modi
Back to Blog
·5 min·updated

The listener that threw on someone else's message

window.addEventListener('message') also receives your extensions and the HMR client. JSON.parse on whatever arrives breaks the bridge for the whole session.

By Meet Modi
Frontend SecurityError HandlingDOMDefensive CodingTesting

A window message listener receives every postMessage delivered to that window, including from browser extensions, dev tooling and the embedding page, not only from the sender you wrote it for. Calling JSON.parse on an arbitrary payload throws, and a throw inside an event listener is uncaught: the bridge stays registered but its next message is never processed correctly, so one foreign message can break it for the rest of the session.

The code

window.addEventListener("message", (event) => {
  const data = JSON.parse(event.data);     // anything at all can arrive here
  const handlers = listeners[data.action];
  if (!handlers?.length) return queue(data);
  handlers.forEach((h) => h(data));
});

Correct for every message we send. The problem is the ones we do not.

Who else is on that channel

window.postMessage is a shared channel with no namespacing. In an ordinary browser session the traffic on it includes:

  • Browser extensions. Password managers, ad blockers, React and Redux devtools and crypto wallets all use window messaging to talk between their content script and the page.
  • The embedding page, when you are in an iframe, including its own unrelated libraries.
  • Dev tooling. Webpack's hot-reload client and various error overlays post their own messages.
  • Any third-party script on the page: analytics, chat widgets, video embeds.

Almost none of that is JSON. A wallet extension posts an object. The HMR client posts a string that is not valid JSON. JSON.parse handles neither.

Why the throw is worse than it looks

Every call site of this receive function was inside the bridge itself, all three of them, so there was no caller to catch anything. An exception thrown synchronously inside an event listener propagates to the global error handler and that is the end of it. No retry, no recovery path, and nothing in the product surfaces it.

What the user gets is a feature that silently stops working, with a console error that names a JSON parse failure and points at a file they have nothing to do with. Reloading fixes it, which is why it was never reported as anything but "it's flaky".

The fix

window.addEventListener("message", (event) => {
  if (typeof event.data !== "string") return;     // objects are not ours

  let data: unknown;
  try {
    data = JSON.parse(event.data);
  } catch {
    return;                                       // not our protocol
  }

  // A JSON scalar parses fine and has no action. Without this it would be
  // filed under pendingEventMessages["undefined"] and held forever.
  if (typeof data !== "object" || data === null) return;
  const action = (data as { action?: unknown }).action;
  if (typeof action !== "string") return;

  dispatch(action, data);
});

The scalar check is the one I would have missed. JSON.parse("42") succeeds and returns a number, which has no action property, so the original code queued it under the key "undefined" where it sat for the lifetime of the page. A slow leak rather than a crash, which is a worse failure mode because nothing ever points at it.

Origin checking, and what it is for

Shape validation is not an authenticity check. Anyone can post a well-formed message. If the bridge carries anything privileged, check event.origin against an allow-list, and check it before you parse.

if (!ALLOWED_ORIGINS.has(event.origin)) return;

Worth being precise about what each one buys you. The try/catch keeps the bridge alive when a password manager says hello. The origin allow-list is what stops a hostile frame driving your app. They are different problems and you want both.

The test that was asserting the bug

There was already a test for this. It was called "should gracefully handle malformed JSON", and it asserted that the function threw.

The name described the behaviour someone wanted. The assertion described the behaviour they found, and at some point the second won without the first being updated. A green suite had been documenting this bug as intended behaviour for however long it had been there.

Extensions are not passive observers of a page, which is easy to forget until one of them turns up in your message channel. What an ad blocker is actually doing to a page is the same uninvited participation seen from the other side. The upload picker on this product had a milder version of trusting what arrives: an accept attribute that let SVGs through.

More Posts