Web Platform · Web Security Model

Sandboxed Frames and the postMessage Trust Boundary

Which capabilities the sandbox attribute strips and which tokens restore them, why allow-scripts with allow-same-origin defeats the frame, and how to validate a message without racing a navigation.

Embedding a third-party document in a frame is the most common way to put untrusted markup inside a trusted page, and it is also the way two origins exchange data when the same-origin policy forbids direct access. Both jobs run through the same two mechanisms: the sandbox attribute, which decides what the framed document may do, and postMessage, which decides what it may say. Neither is safe by default in the way people expect, and the failure modes are structural rather than exotic.

The default sandbox is a long list of removals

<iframe sandbox> with no tokens applies every restriction the attribute knows. The framed document runs in an opaque origin — not the origin of its URL — so it is cross-origin to everything, including its own server. It cannot submit forms, open popups, run plugins, navigate the top-level page, read or write storage, use pointer lock, or trigger downloads. Its scripts may execute only if allow-scripts is present, and even then they cannot reach localStorage or set cookies with any effect.

That default is the strongest single control available for untrusted content. A frame with sandbox="allow-scripts" can render, run its own JavaScript in isolation, and talk to its parent through postMessage; it cannot do anything that persists or that reaches outside the frame except by network requests its own code makes. For a document from a different origin, allow-scripts alone is a reasonable baseline; adding tokens is a decision to grant specific capabilities and should be justified one at a time.

The tokens worth knowing are allow-scripts, allow-same-origin, allow-forms, allow-popups, allow-top-navigation-by-user-activation, allow-modals, allow-downloads, allow-presentation, and allow-storage-access-by-user-activation. Each corresponds to a capability check the browser performs, not to a policy the frame can override. allow-top-navigation-by-user-activation is the narrow form of top navigation: the frame may navigate the whole page only from a click or similar activation inside the frame, which preserves the escape hatch for legitimate “open in full page” links while removing silent top-level redirects.

The sandbox flags are inherited by child frames of a sandboxed frame, and they cannot be cleared by the framed document. A page sandboxed without allow-same-origin cannot relax its own origin. This is what distinguishes sandbox from a CSP sandbox directive that a nested document might otherwise escape through a redirect: the flags travel with the browsing context.

Why allow-scripts plus allow-same-origin is usually a mistake

The combination sandbox="allow-scripts allow-same-origin" on a frame whose src is same-origin with the embedder produces a document that is same-origin with its parent and allowed to run script. Such a document can reach parent.document, find the <iframe> element that contains it, and remove the sandbox attribute outright, after which it can reload itself unsandboxed. The sandbox was meant to constrain code the embedder does not trust; the same-origin token hands that code the ability to unconstrain itself.

The combination is legitimate only when the framed content is trusted to the same degree as the embedder and the sandbox is being used purely to block top navigation or form submission — for example, isolating a same-origin preview pane. It is never legitimate for user-supplied HTML, which is the context where it most often appears, because a same-origin frame serving user content means the application is already serving user content from its own origin, at which point the sandbox cannot repair that.

When same-origin access is genuinely needed for user content, the correct structure is a separate origin for that content: a subdomain or a distinct domain that serves only the untrusted material, with cookies scoped away from it. Then allow-same-origin is not required at all, because the frame is cross-origin by construction and communicates through messages. The broader argument for origin separation is in how the same-origin policy computes origins.

postMessage transfers a capability, it does not call a method

window.postMessage(message, targetOrigin, transfer) delivers a structured clone of message to the window identified by the receiver, provided that window’s origin matches targetOrigin at the moment of delivery. The message is not a function call: there is no return value, no error if the receiver ignores it, and no identity beyond event.origin and event.source on the receiving side.

// Sender: never use '*' for anything the receiving page should not see.
iframe.contentWindow.postMessage(
  { type: 'resize-request', height: 480 },
  'https://widgets.example.com'
);

// Receiver: validate origin and source before touching the payload.
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://app.example.com') return;
  if (event.source !== window.parent) return;
  if (!event.data || event.data.type !== 'resize-request') return;
  resize(event.data.height);
});

targetOrigin is the security-critical argument on the sending side. With '*', the browser delivers the message to whatever document currently occupies the target window, regardless of origin. The window’s contents can change between the moment a reference is captured and the moment the message is delivered — a redirect, a navigation by the frame itself, or a navigation by the opener. A token, a user identifier, or a URL with a signed parameter sent with '*' is readable by whoever is there. A concrete origin makes delivery conditional: if the window has navigated elsewhere, the message is silently dropped, which is the correct outcome.

transfer moves ArrayBuffer objects and MessagePort objects rather than cloning them. The transferable case matters because a MessagePort extracted from a MessageChannel is a capability: once both sides have exchanged ports during a handshake, subsequent traffic flows over the port and no longer carries an origin, because the port is only reachable by the two parties that hold it. That is a legitimate design — it removes repeated origin checks — but the handshake itself must be validated carefully, since a port sent carelessly is a permanent channel to whoever received it.

Validating a message without racing the frame

The receiving handler must check three things before using the payload: event.origin against an exact allowlist, event.source against the specific window it expects, and the structure of event.data.

An exact comparison is mandatory. event.origin.startsWith('https://app.example.com') accepts https://app.example.com.attacker.net. Parsing event.origin and comparing only the hostname accepts a different scheme. event.origin === 'null' should never be in an allowlist; it is what a sandboxed or data: document sends, and any attacker can produce it.

The source check is the part usually omitted. Origin alone says “some document from a trusted origin sent this”, not “the frame I created sent this”. A page that embeds one widget and also opens a popup to the same origin would accept a message from either. It also fails when the trusted origin hosts multiple applications: a message from an unrelated page on the same origin passes an origin-only check. Comparing event.source with a stored contentWindow reference narrows delivery to the expected window, with the caveat that the reference is a WindowProxy that survives navigation of the frame — so the check confirms the slot, not the document currently in it, and the origin check supplies the rest.

The race is the reverse problem. A parent that sends a message as soon as it assigns iframe.src may send before the framed document has installed its listener, and the message is lost with no error. Framed documents that need an initial configuration should announce readiness — a {type: 'ready'} message from the frame to its parent — and the parent should reply to event.source after validating event.origin. The parent must also handle the frame reloading: a reload installs a new document with a fresh listener, so the handshake has to be repeatable rather than one-shot.

Payload handling is where a validated message becomes an injection. A message that carries HTML and is inserted with innerHTML, or that carries a URL assigned to location or src without scheme validation, gives the sender the same power as script injection into the receiver. Structured clone preserves object shapes but does not sanitise strings, and a cloned object can contain a URL object whose protocol is javascript:. Validation should be a schema check with an allowlist of message types and bounded field values, not a truthiness test, and any HTML that the receiver does build from a message should pass through a reviewed policy as described in Trusted Types and the typing of DOM XSS sinks.

The embedding side needs its own policy

postMessage and sandbox govern what a frame can do once embedded. They do not govern whether the page can be embedded at all. That is the job of frame-ancestors in a CSP on the framed response, or the older X-Frame-Options header, which supports DENY and SAMEORIGIN and has no allowlist. frame-ancestors accepts origins, so a widget can permit exactly the partner sites that pay for it and refuse everything else, which also blocks clickjacking: without it, an attacker can position an invisible frame over their own page and harvest clicks on the victim’s authenticated interface.

Frames opened with target="_blank" need rel="noopener" for the analogous reason on the window level: without it the opened document gets a window.opener reference and can navigate the opener to a lookalike page. Browsers now apply noopener by default to target="_blank", but the attribute remains necessary for older behaviour and for clarity in code review.

The layered arrangement is: the framed response declares who may frame it; the embedder declares what the frame may do with sandbox; the two sides exchange data only through messages with exact origin and source checks; and anything that must persist for the framed application lives on its own origin, not the embedder’s.

References

  1. HTML Standard — The iframe element — WHATWG
  2. HTML Standard — Cross-document messaging — WHATWG
  3. MDN — Window.postMessage — MDN Web Docs