Skip to content

Embedding the Form Builder

The standalone form-builder SPA can be loaded by a host page inside an <iframe> and driven over postMessage. This page is the integrator's walkthrough — minimum viable host code, hosting setup, CSP requirements. For the wire format itself, see Form-Builder Embed Protocol (v1).

For a runnable reference host you can copy from, see packages/form-builder-app/public/embed-host.html. Run pnpm --filter @floh/form-builder-app dev and open https://localhost:7080/embed-host.html in a browser (the dev server defaults to HTTPS — accept the self-signed cert on first visit). For the HTTP opt-out, use pnpm --filter @floh/form-builder-app dev:http and http://localhost:7080/embed-host.html.


Quickstart — minimum viable host

<iframe id="form-builder" title="Form builder"></iframe>
<script type="module">
  // jsonc-parser is needed to safely strip host-owned
  // `preview.context` from the JSONC editor buffer before
  // persisting (see the `stripHostContext` helper below).
  //
  // Production hosts SHOULD bundle jsonc-parser from npm via
  // their own build pipeline so the import resolves to a
  // first-party URL covered by `script-src 'self'`:
  //
  //   npm install jsonc-parser
  //   import { modify, applyEdits } from "jsonc-parser";
  //
  // The cdn.jsdelivr.net URL below is shown so this snippet is
  // copy-pasteable into a static .html file with no build step,
  // BUT a strict `script-src 'self'` CSP will block it. To allow
  // it without bundling, add the CDN to your CSP (e.g.
  // `script-src 'self' https://cdn.jsdelivr.net;`). The exact
  // version is pinned (`@3.3.1`, NOT `@3`) so the CDN cannot
  // silently swap module bodies between page loads — the `@3`
  // form floats to the latest 3.x, which is convenient but
  // means a compromised CDN account or upstream maintainer
  // could ship a new 3.x with arbitrary code. See the CSP
  // section for the full threat-model story (in particular,
  // why CSP hashes / Subresource-Integrity DO NOT pin module
  // bodies fetched via `import`).
  import {
    applyEdits,
    findNodeAtLocation,
    modify,
    parseTree,
  } from "https://cdn.jsdelivr.net/npm/jsonc-parser@3.3.1/+esm";

  // 1. Build the iframe URL with embed=1 + the host origin.
  const hostOrigin = window.location.origin;
  const iframeSrc = new URL("https://forms.example.com/");
  iframeSrc.searchParams.set("embed", "1");
  iframeSrc.searchParams.set("hostOrigin", hostOrigin);

  const iframe = document.getElementById("form-builder");
  let sessionNonce = null;

  // 2. Listen for messages from the iframe BEFORE assigning src.
  const PROTOCOL_VERSION = 1;
  window.addEventListener("message", (event) => {
    if (event.source !== iframe.contentWindow) return;
    if (event.origin !== iframeSrc.origin) return;
    const data = event.data;
    // Envelope guard: require OWN properties for every field we
    // read. A bare `data.v !== PROTOCOL_VERSION` happily picks up
    // `Object.prototype.v` if a transitive dependency on the host
    // page polluted the prototype chain (the Lodash CVE-2019-10744
    // pattern is the famous example), and a malicious peer that
    // can pollute the host-page prototype chain BEFORE the iframe
    // boots can therefore smuggle a forged envelope through.
    // `Object.hasOwn` rejects inherited-only fields, so an envelope
    // missing every own field — even one with `Object.prototype.v
    // = 1` — falls through to the `return` and is dropped. This
    // mirrors `protocol.ts`' isEmbedEnvelope guard one-for-one.
    if (
      !data ||
      typeof data !== "object" ||
      Array.isArray(data) ||
      !Object.hasOwn(data, "v") ||
      !Object.hasOwn(data, "nonce") ||
      !Object.hasOwn(data, "kind") ||
      !Object.hasOwn(data, "payload") ||
      data.v !== PROTOCOL_VERSION ||
      typeof data.nonce !== "string" ||
      data.nonce.length === 0 ||
      typeof data.kind !== "string" ||
      data.kind.length === 0
    ) {
      return;
    }
    // Optional `id` field for ack-style correlation. The protocol
    // permits envelopes without `id` (current SPA never sends one),
    // but if `id` IS present it MUST be a non-empty string. An
    // attacker-controlled own `id: 0` / `id: ""` would otherwise
    // pass the envelope guard and reach dispatch with a falsy
    // correlator, which integrators that DO use `id` would silently
    // mishandle. Mirrors `isEmbedEnvelope`'s optional-id branch in
    // protocol.ts one-for-one.
    if (Object.hasOwn(data, "id") && (typeof data.id !== "string" || data.id.length === 0)) {
      return;
    }

    // 3. Every `ready` resets the session — initial AND post-reload.
    //    The guest mints a fresh nonce on every load (HMR refresh,
    //    iframe.src reassignment, navigation), so a host that pinned
    //    only the FIRST nonce would silently stop working after any
    //    reload. Replace the nonce on every ready, then re-send init.
    //
    //    Two checks the harness enforces and your host MUST too:
    //      a. payload.protocolVersion === PROTOCOL_VERSION — refuse to
    //         partially-handshake with a future v2 guest you can't
    //         drive correctly.
    //      b. data.nonce === data.payload.nonce — the protocol
    //         invariant is that the envelope nonce and the payload
    //         nonce match (the guest derives both from one randomUUID
    //         call). Mismatch indicates a tampered or buggy peer; do
    //         not store the payload nonce as the session nonce or
    //         every subsequent send will address the wrong session.
    if (data.kind === "ready") {
      // Own-property guard on the payload too — same threat
      // model as the envelope guard. A polluted host
      // `Object.prototype` could otherwise satisfy
      // `payload.protocolVersion === 1` and `typeof
      // payload.nonce === "string"` from inherited properties
      // alone, smuggling a malformed `ready` past the
      // narrowing guard. `Object.hasOwn` rejects inherited-
      // only fields, so a payload with NO own data falls
      // through to the `return`.
      const payload = data.payload;
      if (
        !payload ||
        typeof payload !== "object" ||
        Array.isArray(payload) ||
        !Object.hasOwn(payload, "protocolVersion") ||
        !Object.hasOwn(payload, "nonce") ||
        payload.protocolVersion !== PROTOCOL_VERSION ||
        typeof payload.nonce !== "string" ||
        payload.nonce !== data.nonce
      ) {
        return;
      }
      sessionNonce = payload.nonce;
      iframe.contentWindow.postMessage(
        {
          v: PROTOCOL_VERSION,
          nonce: sessionNonce,
          kind: "init",
          payload: { formPackage: yourInitialFormPackageJsonText },
        },
        iframeSrc.origin,
      );
      return;
    }

    if (data.nonce !== sessionNonce) return;

    // 4. Every edit emits `change` with the latest serialised form package.
    //    A `change` with `dirty: false` carries the host's OWN bytes
    //    back — it's emitted after the host applies an `init` or
    //    `context:update` so the host can refresh its issue list with
    //    re-validated `validationIssues` (e.g. a Display element with
    //    `contextScope: "/foo"` becomes valid only after `foo` is in
    //    the context). Persisting on `dirty: false` would round-trip
    //    volatile runtime context into your saved form definition.
    //    Gate persistence on `dirty: true`; use the `change` itself
    //    to keep your validation/dirty UI in sync regardless.
    //
    //    IMPORTANT — stripping host-owned `preview.context` before
    //    save: the bridge writes the host's `init.context` and every
    //    `context:update` into the buffer's `preview.context` block
    //    so the live preview can render against real data. That
    //    block is part of the bytes returned in `change.formPackage`,
    //    so a host that persists those bytes verbatim ends up with
    //    runtime workflow values baked into the saved package — and
    //    the next session will then start with stale context until
    //    the host's first `init`/`context:update` overwrites it.
    //    The host owns the runtime context already, so the safe
    //    pattern is to drop `preview.context` before persisting and
    //    re-supply it on the next `init`.
    if (data.kind === "change") {
      // Per-kind payload guard. The envelope check above only
      // proves the wrapper is well-formed; the *payload* shape
      // is per-kind and must be re-validated before reading
      // fields. A malformed `change` payload that flowed past
      // this guard could throw inside the persistence layer
      // (`.save(undefined)`) or the validation UI
      // (`.refresh(null)`), and a forged-but-envelope-valid
      // peer could trip those paths to surface ugly stack
      // traces in the host.
      //
      // Same own-property discipline as the envelope guard:
      // every field is checked with `Object.hasOwn` before
      // typeof-checking, so a polluted host
      // `Object.prototype.formPackage = "..."` (or any of the
      // other documented fields) cannot smuggle "valid" data
      // into an empty payload. `embed-host.html` runs the same
      // narrowing in `isValidPayloadForKind`.
      const payload = data.payload;
      if (
        !payload ||
        typeof payload !== "object" ||
        Array.isArray(payload) ||
        !Object.hasOwn(payload, "formPackage") ||
        !Object.hasOwn(payload, "dirty") ||
        !Object.hasOwn(payload, "valid") ||
        !Object.hasOwn(payload, "validationIssues") ||
        typeof payload.formPackage !== "string" ||
        typeof payload.dirty !== "boolean" ||
        typeof payload.valid !== "boolean" ||
        !Array.isArray(payload.validationIssues)
      ) {
        return;
      }
      // Also narrow each issue. Without this, a payload with a
      // non-object entry (BigInt, function, cyclic graph) would
      // pass the array check above and only blow up later
      // inside `yourValidationUi.refresh` when it walks the
      // entries to render. Reject the whole payload up front so
      // a single bad entry can't kill the listener for the rest
      // of the session.
      for (const issue of payload.validationIssues) {
        if (
          !issue ||
          typeof issue !== "object" ||
          Array.isArray(issue) ||
          !Object.hasOwn(issue, "path") ||
          !Object.hasOwn(issue, "message") ||
          typeof issue.path !== "string" ||
          typeof issue.message !== "string"
        ) {
          return;
        }
      }
      yourValidationUi.refresh(payload);
      // Gate persistence on TWO independent invariants:
      //
      //   1. `payload.dirty === true` — without this we'd persist
      //      every refresh, including the post-init / post-context-
      //      update echo where the bytes are the host's own.
      //
      //   2. `stripped.ok === true` — `stripHostContext` returns
      //      `ok: false` when the buffer is unparseable JSONC
      //      (mid-edit unbalanced braces, stray comma, …) OR when
      //      a `modify` / `applyEdits` call throws. In either case
      //      the helper falls back to the verbatim buffer, which
      //      still contains the host-owned `preview.context` block.
      //      Persisting that verbatim text would defeat the whole
      //      "host owns runtime context, never persist it" rule
      //      from the section above. Skip the save instead and
      //      wait for the next clean change to deliver a
      //      strippable buffer. (Most authors fix the syntax
      //      within a few keystrokes; the dirty flag persists
      //      across the gap.)
      if (payload.dirty === true) {
        const stripped = stripHostContext(payload.formPackage);
        if (stripped.ok) {
          yourPersistenceLayer.save(stripped.text);
        }
      }
    }

    // 5. Surface guest-side errors. The bridge emits `error`
    //    envelopes for malformed `init` / `context:update` you
    //    sent, validator-throws on the loaded form package, and
    //    a few other guest-side failures (see embed-protocol.md
    //    for the full code list). A host that ignores `error`
    //    silently loses these — your `init` could be rejected
    //    because of an invalid `context` and the iframe would
    //    just sit there empty with no console signal in the
    //    HOST page (the diagnostic only surfaces in the IFRAME's
    //    devtools, which a screen-share viewer might not even
    //    have open). Always wire `error`, even if you only
    //    `console.error` it during development.
    //
    //    Same own-property + per-kind narrowing discipline as
    //    the other branches: an attacker who passes the
    //    envelope guard could otherwise satisfy the typeof
    //    check from inherited prototype keys.
    if (data.kind === "error") {
      const payload = data.payload;
      if (
        !payload ||
        typeof payload !== "object" ||
        Array.isArray(payload) ||
        !Object.hasOwn(payload, "code") ||
        !Object.hasOwn(payload, "message") ||
        typeof payload.code !== "string" ||
        typeof payload.message !== "string"
      ) {
        return;
      }
      yourErrorReporter?.(payload.code, payload.message);
      console.error(`[form-builder] ${payload.code}: ${payload.message}`);
    }
  });

  // 5. Assign src last so the listener is wired before the iframe boots.
  iframe.src = iframeSrc.toString();

  // Strip the host-owned `preview.context` block from a JSONC
  // buffer before persistence. The form-builder preserves
  // comments and trailing commas verbatim (the buffer is JSONC,
  // not strict JSON), so we use `jsonc-parser`'s `modify` — the
  // same primitive the form-builder uses internally — instead of
  // `JSON.parse` + `JSON.stringify` (which would discard
  // comments and re-format the whole buffer).
  //
  // Returns a tagged result `{ ok, text }`:
  //
  //   - `ok: true, text: <stripped>` when the buffer parsed AND
  //     the strip ran successfully. The caller should persist
  //     `text`. This includes the case where `preview.context`
  //     was already absent (`text` equals the input unchanged).
  //
  //   - `ok: false, text: <verbatim>` when the buffer is
  //     unparseable JSONC, OR when `modify` / `applyEdits` threw.
  //     The verbatim text still contains the host-owned
  //     `preview.context` block (if any), so the CALLER must
  //     refuse to persist it — see the persistence branch above.
  //     We return the verbatim text instead of just `null` so
  //     callers that want to log / display it have it on hand
  //     without re-reading from the payload.
  //
  // Two failure modes are covered:
  //
  //   1. **Buffer not parseable.** Mid-edit a host can receive a
  //      `change` event for a buffer that has unbalanced braces,
  //      a stray comma, or any other transient JSONC error.
  //      `parseTree` returns `undefined` for unparseable input
  //      AND `modify` may throw on the same input. We return
  //      `ok: false` for both so the caller skips the save and
  //      waits for the next clean change.
  //
  //   2. **Path absent.** If `preview.context` doesn't exist in
  //      the buffer (the author hasn't opened a preview pane, or
  //      they manually deleted the block), the strip is a no-op
  //      and we return `ok: true` with the original text.
  function stripHostContext(jsoncText) {
    let tree;
    const parseErrors = [];
    try {
      // Pass an `errors` array — `parseTree` is permissive
      // and returns a partial tree even for buffers with
      // syntax errors (a stray comma, missing brace, …),
      // so a tree-truthy check alone would false-pass on
      // mid-edit malformed JSONC. We need
      // `parseErrors.length === 0` to be sure the persisted
      // bytes round-trip cleanly.
      //
      // `allowTrailingComma: true` matches the form-builder's
      // own JSONC parser settings (see
      // `editor-state.service.ts`'s `parseFormPackage`). Without
      // it, a buffer the form-builder considers valid — for
      // instance a uiSchema element list with a deliberately
      // trailing comma so the author can re-order entries —
      // would parse as an error here and the strip would
      // refuse to run, even though the buffer is fine.
      tree = parseTree(jsoncText, parseErrors, { allowTrailingComma: true });
    } catch (parseError) {
      console.warn(
        "[form-builder] stripHostContext could not parse buffer; skipping save",
        parseError,
      );
      return { ok: false, text: jsoncText };
    }
    if (tree === undefined || parseErrors.length > 0) {
      console.warn(
        "[form-builder] stripHostContext encountered JSONC parse errors; skipping save",
        { errorCount: parseErrors.length },
      );
      return { ok: false, text: jsoncText };
    }
    const node = findNodeAtLocation(tree, ["preview", "context"]);
    if (node === undefined) return { ok: true, text: jsoncText };
    let edits;
    try {
      edits = modify(jsoncText, ["preview", "context"], undefined, {
        formattingOptions: { tabSize: 2, insertSpaces: true },
      });
    } catch (modifyError) {
      console.warn(
        "[form-builder] stripHostContext could not modify buffer; skipping save",
        modifyError,
      );
      return { ok: false, text: jsoncText };
    }
    if (edits.length === 0) return { ok: true, text: jsoncText };
    try {
      return { ok: true, text: applyEdits(jsoncText, edits) };
    } catch (applyError) {
      console.warn(
        "[form-builder] stripHostContext could not apply edits; skipping save",
        applyError,
      );
      return { ok: false, text: jsoncText };
    }
  }
</script>

Hosting

The form-builder SPA is a static Angular bundle. Host it however you host any static SPA — S3 + CloudFront, GitHub Pages, your own nginx, Floh's own server. Once the bundle is deployed, the integration contract is just the URL and the postMessage protocol.

For local dev:

pnpm --filter @floh/form-builder-app dev
# → https://localhost:7080/?embed=1&hostOrigin=https%3A%2F%2Flocalhost%3A7072
# (or, with the explicit HTTP opt-out:)
pnpm --filter @floh/form-builder-app dev:http
# → http://localhost:7080/?embed=1&hostOrigin=http%3A%2F%2Flocalhost%3A7072

Content-Security-Policy

The host page MUST allow the iframe's origin in its frame-src directive. A strict baseline:

Content-Security-Policy: frame-src https://forms.example.com;

Inline <script type="module"> and script-src 'self'. The copy-paste quickstart embeds the bridge JS as an INLINE module script. A strict script-src 'self' CSP will block that inline script — 'self' only allows scripts loaded from the host's own origin via <script src="…">, not anything between <script>...</script> tags on the page. Three ways to ship the bridge under a 'self' policy:

  1. Move the bridge to a same-origin file (preferred). Save the quickstart's <script type="module"> body to e.g. /static/embed-bridge.mjs on your host, and reference it with <script type="module" src="/static/embed-bridge.mjs">. The fetch is now same-origin and 'self' allows it.

  2. Allow inline modules via a CSP nonce. Generate a per-request cryptographic nonce, include it in both the <script type="module" nonce="…"> tag AND the CSP header:

Content-Security-Policy:
  frame-src https://forms.example.com;
  script-src 'self' 'nonce-{random-base64-32}';

The nonce must be unguessable (CSP requires ≥128 bits of entropy) and rotated per response, so this requires a server-rendered host page.

  1. Allow inline modules via a CSP hash. Compute the sha256 (or sha384 / sha512) of the script body and list it in script-src:
Content-Security-Policy:
  frame-src https://forms.example.com;
  script-src 'self' 'sha256-...';

The hash covers ONLY the inline body — its imports are still subject to the rest of the policy (see "jsonc-parser import" below). And every change to the inline body requires regenerating the hash and redeploying the CSP, so in practice this option is only manageable for a frozen integration script.

jsonc-parser import. The quickstart imports jsonc-parser from https://cdn.jsdelivr.net for copy-paste convenience. A strict script-src 'self' CSP will block that import. Two ways out:

  1. Bundle from npm (preferred for production). Install jsonc-parser as a dependency of your host app and import it from your bundle. The import then resolves to a same-origin URL covered by script-src 'self':
// After `npm install jsonc-parser` and a build step:
import { modify, applyEdits } from "jsonc-parser";
  1. Allow the CDN explicitly with a version-pinned URL. If your host has no build step, add the CDN host to script-src AND use a fully-pinned version in the import URL so the CDN can't silently swap module bodies between page loads:
Content-Security-Policy:
  frame-src https://forms.example.com;
  script-src 'self' https://cdn.jsdelivr.net;
// Pin the EXACT version, not the major-version range. The
// `@3` form in the quickstart resolves to "latest 3.x" on
// every reload — convenient, but a compromised CDN account
// (or upstream maintainer) could ship a new 3.x with
// arbitrary code. `@3.3.1` (or whatever version you tested
// with) freezes the body.
import { modify, applyEdits } from "https://cdn.jsdelivr.net/npm/jsonc-parser@3.3.1/+esm";

Important caveat on Subresource Integrity (SRI) and CSP hashes. Both mechanisms target a different layer than what the import statement actually fetches: - SRI (integrity="sha384-…" on a <script> tag) only applies to the script tag's own src attribute. An ES module loaded via the static import inside that script is a separate fetch the browser performs without consulting SRI; CSP script-src 'self' 'sha384-…' likewise covers the inline body, not what the body imports. Putting an SRI hash on <script type="module"> does NOT pin the body of the module its import statements pull in. Pinning the import body itself requires either a build step (option 1 above — the bundler inlines the dependency and SRI on the bundled file then covers everything) or browser-level Import Maps with integrity, which is not yet broadly supported. - CSP script-src 'self' 'sha384-…' allows the inline module to load but does NOT verify the body of any script it imports. Once cdn.jsdelivr.net is in script-src, the fetched module body is implicitly trusted by the policy.

The realistic threat-model story for option 2 is therefore: "I trust the CDN to serve the version I pinned." If you need stronger guarantees, use option 1.

The form-builder SPA itself does not require any specific CSP from the host — all communication is via postMessage, which CSP does not gate.


Persistence

The form-builder does not persist drafts to localStorage in embed mode. The host page owns the persistence decision: every change event from the guest carries the latest serialised formPackage, and the host can debounce, batch with other workflow saves, persist on blur, ship over the wire, etc. The guest has no "save" affordance in embed mode — saving is the host's responsibility.

change.formPackage is the JSONC editor buffer text, not a JSON string in the strict sense. JSONC is a superset of JSON: it accepts // and /* … */ comments and trailing commas, both of which the form-builder preserves verbatim so authors can leave inline notes inside the package. Hosts that only need the structured value (e.g. to derive a workflow input schema) must parse with jsonc-parser or any JSONC-aware parser — bare JSON.parse will throw on any author-supplied comment. Hosts that persist the bytes verbatim (the common case) do not need to parse at all.

For the same reason there is no protocol-level "saved" ACK from host to guest. The dirty flag in ChangePayload reflects guest-side change detection only and stays true until the next init.


Workflow context

Both the initial init and subsequent context:update messages carry an optional context dictionary. The form-builder's preview renderer reads this from preview.context and resolves {{ctx.<path>}} tokens against it.

A typical Floh-style mapping:

{
  context: {
    submitter: { firstName, lastName, email, displayName },
    targetUser: { firstName, lastName, email, displayName },
    workflow: { id, name, version },
    // …prior step outputs as the workflow author defines them
  }
}

The dictionary is replaced wholesale on each context:update — there is no merge semantic. To add a single key, the host re-sends the full dictionary with the new key included.


Versioning

EMBED_PROTOCOL_VERSION is exported as a literal 1. Bumping it implies a breaking change to the envelope or to one of the existing payloads. Adding a new kind to one of the allow-lists is not breaking — old peers silently drop unknown kinds.

Hosts SHOULD inspect the protocolVersion on the inbound ready payload and refuse to send any further messages if the value does not match what the host was coded against.


Security model

Concern Mitigation
Cross-origin message theft hostOrigin URL parameter pins the origin the guest accepts; guest validates event.origin on every inbound.
Replay across iframe loads Per-render nonce minted by the guest. Reload mints a fresh nonce; stale messages are dropped.
Cross-iframe confusion Guest validates event.source === window.parent. Host SHOULD validate event.source === iframe.contentWindow.
Unknown / hostile kinds Disjoint host↔guest allow-lists; both sides drop unknown / wrong-direction kinds before dispatch.
Malformed payloads Each kind has its own payload narrower; guest emits a typed error on shape mismatch instead of silently failing the op.
Host iframe injection CSP frame-src allow-list pins the iframe origin to your form-builder deployment.

Troubleshooting

"My iframe was working but stopped responding after a reload."

  • The guest mints a fresh session nonce on every load. A host that only stores the FIRST ready's nonce will reject every subsequent message. Treat each ready as a session reset — replace the stored nonce and re-send init so the new session boots into the expected state.

"My iframe loads but the host never sees ready."

  • Confirm ?embed=1 is present in the URL.
  • Confirm the hostOrigin query parameter is also present and resolves to the same origin your host page is served from (https: is required except for localhost / 127.0.0.1 / [::1]). Without it the bridge falls into no-origin and never posts ready.
  • Confirm the host's message listener is wired before assigning iframe.src.
  • The iframe should print [fbapp.embed] dropped inbound message at console.debug for every message it refuses, with a reason field that distinguishes the failing gate (source-mismatch, origin-mismatch, envelope-malformed, nonce-mismatch, kind-not-allowed). If you see one of these repeatedly, your host is sending messages that fail the named check; if you see none at all, the host probably isn't posting to the iframe yet (verify iframe.contentWindow.postMessage is being called and targetOrigin is correct).
  • The iframe also exposes the bridge's internal state on console.debug only — there is intentionally no window-level global to call from devtools, since exposing a debug hook would drag the bridge into the production bundle for standalone users. To inspect the state machine, set a breakpoint inside EmbedBridgeService.start() or watch the console.debug drop log described above. Possible non-active states are disabled (no ?embed=1), no-origin (hostOrigin URL param missing/invalid), and no-parent (the SPA is loaded as a top-level tab, not iframed).

"The host posts init but the iframe never updates."

  • Verify the host echoes the nonce from ready exactly. The bridge silently drops mismatched nonces.
  • Verify the host sends postMessage with targetOrigin set to the iframe's actual origin (iframe.src). The host MUST NOT use "*": the browser will deliver an unrestricted-target message to whatever document is currently loaded in the frame at delivery time, so if the iframe has been navigated to or substituted with a different document (an attacker's page, a redirect, a transient about:blank during refresh) the payload — including the session nonce and any context the host was about to apply — leaks to that document. Browsers do NOT block delivery for "*"; pinning the expected origin is the only defence.

The guest cannot detect a wildcard targetOrigin after the fact — MessageEvent only exposes the sender's origin, never the address the sender used. The same envelope arrives identically whether the host sent to "*" or to the iframe origin; the difference is who else might have received a copy.

"The iframe renders the editor but I'm seeing the standalone header inside it."

  • The ?embed=1 query parameter was not parsed. URL-encoding bugs in the host are the usual cause; double-check the encoded value arrived intact.