Back to Hub
WEB SECURITY •

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.

window.addEventListener("message", (event) => {
  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:

// iframe side: produce tokens, never markup
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:

// parent side: render from an allowlist, never innerHTML
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:

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:

  1. 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.
  2. Attack suite — four crafted payloads: a <script> tag in a log message, an onerror attribute 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