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 ModiA 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
Two services, one topic, either deploy order
A shared topic is only safe if the old consumer ignores what the new producer adds. One field with a default, and either side can roll back alone.
Scale on what's queued and you'll remove the pod mid-job
An autoscaler counting queued work drops to zero the moment pods claim it, so it scales into running jobs and each removal hands the work back to start over.
The model was starting and the first job didn't know
A state flag set inside the background thread instead of before launching it, so the caller that asked for the model read 'stopped' and took the slow path.