Accepted

ADR 0039 — Iframe trust resolution for plugin block renderers

How a plugin's blast radius stays inside its declared capabilities

A plugin can contribute a block type whose renderer produces HTML for published pages. That capability must not become a path for plugin code to act as the editor's admin session or run script in a visitor's browser. The planned plugin capability prompt will only mean something if the deployer's consent set is the plugin's blast radius — a code path that reached the admin session or the visitor's browser would make the prompt theatre. This record settles the trust model that closes that surface.

The model

Plugin block renderers run on the server, inside a per-plugin Dynamic Worker Loader sub-isolate, and return a plain HTML string. The host sanitises that output before it is spliced into the page. The plugin's renderer never executes inside the visitor's browser or inside the customise-preview iframe; the iframe only ever receives the sanitised, server-rendered HTML.

Sanitisation is the plugin-output boundary

The host runs the same strict sanitiser used for user content over every plugin block's output before rendering it. The sanitiser uses an allowlist parser, not regex tag-stripping, and removes:

  • script tags of any form — inline, src, or typed.
  • event-handler attributes — the entire on* family, such as onclick, onload, and onerror, on any tag.
  • javascript:, data:, and vbscript: URL schemes in href, src, action, formaction, srcdoc, and xlink:href, including entity- and whitespace-obfuscated variants.
  • iframe tags — a plugin cannot emit a frame that could re-introduce a same-origin path.
  • object, embed, and applet tags — script-execution vehicles.

The consequence is the load-bearing simplification: a plugin block renderer's output is server-rendered HTML and CSS only. A plugin that needs interactive behaviour does it through an admin page (which runs inside the admin SPA under the deployer's install consent), not through the block path.

Per-plugin sandbox

The block renderer runs in the plugin's own Dynamic Worker Loader sub-isolate with only its capability-mapped bindings — the same sandbox the host gives themes: no host bindings reach the isolate, and no outbound fetch is possible unless the manifest declares it. A malicious plugin can produce malicious HTML; the sanitiser is the boundary that closes it. The planned capability prompt for the render-block capability is designed to state plainly that the plugin renders HTML visitors see, that scripts, event handlers, and frames are removed, and that the plugin cannot run JavaScript in visitors' browsers through it — so the deployer's consent will be truthful.

Why a sibling origin was rejected

Serving the preview from a sibling origin would structurally isolate the iframe from the admin API, but it loses on deployment experience. The deployer attaches one custom domain through the Settings page; a sibling origin would need a wildcard certificate, a second domain attachment, or dedicated plumbing — extra manual steps the deploy-in-minutes commitment excludes. It would also widen the preview-token validator's accepted audience and would have to be applied to document preview too, doubling the domain story. A strict Content-Security-Policy is treated as defence-in-depth on top of the sandbox, not a substitute for it.

Consequences

  • A plugin's blast radius is its declared capability set; no plugin code path reaches the editor's admin session, and no plugin code runs in a visitor's browser through the block path.
  • The planned capability prompt will mean what it says — the sanitiser and the sandbox close the surfaces the prompt does not grant.
  • Plugins that render embedded media cannot emit raw iframe markup; trusted embedding is the host's concern, with the plugin supplying only URL data wrapped by a host-rendered frame.
  • A single deploy domain is preserved; no second hostname and no wildcard certificate are required.

Read the theme packaging record