The postMessage Sanitizer: A Recipe for Anyone Building iframe Tools
When I security-audited NitroIDE for the v25 release, the worst hole I found wasn't in the iframe sandbox. It was in the trust my own parent page placed in its own iframe.
NitroIDE is a browser IDE where the live preview runs in a sandboxed iframe and talks to the parent editor through postMessage — console logs, runtime errors, state-watcher updates. The parent page was taking those messages and interpolating them straight into innerHTML. Tab names went into onclick strings. Unescaped.
That meant a malicious share link — NitroIDE's ?code= links run a whole app from a URL, no signup — didn't need any sandbox trickery at all. It could send a crafted message from its own "preview" and get arbitrary HTML and JS executed in the parent page: full parent-origin XSS, delivered through a link that looks like a harmless demo. I fixed it in v25, but the fix itself is the interesting part. Here's the recipe, step by step, for anyone building iframe tools.
Step 1: check the origin — then assume the message is still hostile
Every postMessage tutorial tells you to validate event.origin. Do that. It's necessary. It's just not sufficient.
if (event.origin !== window.location.origin) return;
// Origin is trusted. The payload is not.
handlePreviewMessage(event.data);
});
The attacker doesn't need to fake the origin. If your product lets a URL load attacker-controlled code into your iframe — share links, embeds, previews of user content — the malicious payload arrives with a perfectly valid origin. The threat model isn't "strangers sending me messages." It's "me, sending myself poison." Validate the message, not just the messenger.
Step 2: never let a payload become markup
The golden rule: a message payload must never be concatenated into HTML. The old code did exactly that — building console rows with template literals around payload fields. The fix is structural, not a bigger regex: serialize to tokens, not HTML.
On the iframe side, serialize every console entry as a typed token list:
function serializeConsoleEntry(entry) {
return entry.parts.map((part) => ({
type: part.kind, // "text" | "styled" | "linebreak"
cls: part.className, // e.g. "log-error"
text: String(part.text),
}));
}
On the parent side, render tokens against a strict allowlist:
const ALLOWED_CLASSES = new Set(["log-error", "log-warn", "log-info", "log-dim"]);
function renderToken(token, row) {
const el = document.createElement(token.type === "linebreak" ? "br" : "span");
if (token.type === "styled" && ALLOWED_CLASSES.has(token.cls)) {
el.className = token.cls; // class from the allowlist only
}
el.textContent = token.text; // textContent: escaped by construction
row.appendChild(el);
}
textContent escapes by construction — there is no way for a <script> tag to survive it. Unknown token types render nothing. The vocabulary the console legitimately produces (colors, object expansion) is an explicit, reviewable list. This is the whole trick: the renderer knows fewer shapes than the attacker can invent.
Step 3: validate every field like it's hostile, because it might be
Tokens handle the console body. The message envelope needs its own checks:
- Numbers are coerced, not trusted. Line numbers and timestamps go through
Number(x)with anisFinitecheck, then get clamped to sane ranges. A string like"12 onmouseover=..."becomesNaNand the message is dropped. - Identifiers are pattern-matched. Tab IDs and state names are checked against a strict pattern (
/^[a-zA-Z0-9_-]{1,64}$/). Anything else is rejected — no escaping cleverness, no second-guessing. - Enums are allowlisted. The message
kindmust be one of the exact kinds the iframe legitimately sends ("console","error","state"). Anything else is ignored. JSON.parselives in try/catch. Always. Malformed payloads throw, and a throw in a message handler is a dropped message, not a crash.
This is boring, explicit code. That's the point. Security-critical message handling should read like a checklist, not a regex golf round.
Step 4: test it like the attacker
Before shipping, I ran the renderer through Node with two suites:
- Legitimacy suite — every console format the product actually produces (colors, object expansion, multi-line errors). All must render exactly as before. A sanitizer that breaks real output gets bypassed by its own developers within a week.
- Attack suite — four crafted payloads: a
<script>tag in a log message, anonerrorattribute in a "styled" class name, a fake token type smuggling markup, and a line number carrying an event-handler string. All four must render as inert text or be dropped entirely.
Both suites passing is the definition of done. The v25 sanitizer shipped with exactly this harness, and it's the part of the release I'm proudest of — not because it's clever, but because it's falsifiable. Anyone can re-run it.
The lesson underneath
Denylist thinking fails here. You cannot enumerate every way HTML can execute JavaScript — the list of event handlers, SVG tricks, and parser quirks grows faster than your regex. Allowlists survive because they default to doing nothing: unknown input renders as nothing, and nothing is always safe.
So if you're building iframe tools — playgrounds, embeds, previews, anything that runs someone else's code next to your UI — steal this recipe: origin check, token serialization, allowlist rendering, hostile field validation, try/catch parses, and an attack suite you run before every ship. It's about forty lines of boring code. It closes a hole that would have let a share link own my whole page.
NitroIDE is free, no signup, and your code never leaves your machine: nitroide.com
⭐ If you enjoyed this, star NitroIDE on GitHub: github.com/nitroideofficial/nitroide