Skip to content
/docsfor

Security

The remote is untrusted code. Your page is untrusted input to the remote. Both sides check each other.

What a remote can reach

This remote tries to read your page, your cookies and your storage, and to call a public API. Before it loads, the page sets a cookie and a localStorage key for it to find. The example runs with network="none", so the API call is blocked too.

This site has no second origin, so the probe runs as an html document: opaque, or in a Safe Content Frame. A URL remote on its own origin gets the same answers for your page, and keeps cookies and storage of its own.

host.tsx
// Default sandbox: allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox
<RemoteComponent src="https://widgets.example/probe.html" network="none" />

// No allow-same-origin: origin "null", no cookies, no storage.
<RemoteComponent src="https://widgets.example/probe.html" sandbox="allow-scripts" network="none" />

// The host fetches the page and renders it on a hashed origin.
<RemoteComponent src="https://widgets.example/probe.html" isolation="scf" network="none" />
widgets/probe.html
<!-- Tries to reach the host page and the network, and reports what happened. -->
<div style="display: grid; gap: 2px">
  <div class="muted mono" style="margin-bottom: 6px">origin: <span id="origin"></span></div>
  <div id="results" style="display: grid; gap: 2px"></div>
</div>
<script>
  document.querySelector("#origin").textContent = self.origin;
  const results = document.querySelector("#results");

  function show(label, outcome, reached) {
    const row = document.createElement("div");
    row.className = "row";
    row.style.cssText = "justify-content: space-between; flex-wrap: nowrap";
    const value = document.createElement("span");
    value.className = "mono";
    value.style.color = reached ? "var(--color-danger)" : "var(--color-text-muted)";
    value.textContent = outcome;
    row.append(label, value);
    results.append(row);
  }

  function check(label, reach) {
    try {
      if (reach()) show(label, "Reached", true);
      else show(label, "Not visible", false);
    } catch (e) {
      show(label, `Blocked (${e.name})`, false);
    }
  }

  rc.addEventListener(
    "props-changed",
    async () => {
      const { hostCookie, hostStorageKey } = rc.props;
      check("Host page DOM", () => window.parent.document.body !== null);
      check("Host URL", () => window.top.location.href.length > 0);
      check("Host cookie", () => document.cookie.includes(`${hostCookie}=`));
      check("Host localStorage", () => localStorage.getItem(hostStorageKey) !== null);
      try {
        await fetch("https://api.open-meteo.com/v1/forecast?latitude=0&longitude=0");
        show("Public API", "Reached", true);
      } catch {
        show("Public API", "Blocked by CSP", false);
      }
    },
    { once: true },
  );
</script>

Sandbox modes

ModeOriginUse it for
Own originThe publisher'sRemotes from a publisher you know. Default. The frame keeps its own cookies and storage. A remote on your own origin is refused.
OpaquenullMany remotes on one origin, like model-written widgets. No cookies or storage: pass what the remote needs as props. The html prop is always opaque.
Safe Content FrameHashed, per publisherUntrusted code. Each publisher, or each instance, gets its own site on a public-suffix domain. No hosting of your own.

The default sandbox is allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox. There is no allow-top-navigation, so a remote can't navigate your page, and no allow-modals. allow="" delegates no camera, microphone or location unless you pass it.

Content Security Policy

Isolation is set from outside the page: the sandbox, the origin and the CSP. The page can't loosen any of them. The serving layer sends connect-src for the network policy, and Allow-CSP-From: * so your page can narrow it further with network. For documents your page shows itself, the host writes the CSP into the document.

Who may embed a remote

Any page can frame a public remote and pass it any props. To limit that, send the CSP frame-ancestors header with the host origins you trust. The browser refuses to show the page anywhere else. Browsers ignore frame-ancestors in a meta tag, so it has to be a header.

response headers
Content-Security-Policy: frame-ancestors https://app.example

The serving layer writes it for you. It is one option on normalizeHtml, remoteComponentsMiddleware and the Vite plugin. Serving has the full set of headers.

server.ts
remoteComponentsMiddleware({ root: "widgets", frameAncestors: ["https://app.example"] });
normalizeHtml(html, { frameAncestors: ["https://app.example"] });
remoteComponents({ frameAncestors: ["https://app.example"] });   // Vite plugin

Threat model

Parties and trust

PartyTrustsDoes not trust
Host: the page rendering <RemoteComponent>itselfremote code, remote messages, overlay content
Remote: an HTML document in the iframe, from a publisher, a model, or the host's own html propits own publisherthe embedding page, other remotes
Publisher origin: where remotes are served
Userthe host they visited

The remote is untrusted code. The host is untrusted input from the remote's point of view, since any page can frame a public remote URL.

Assets

  • Host page DOM, cookies, storage, credentials, and its users' data.
  • The remote's own origin storage and cookies, when it has an origin.
  • Data the host gives a remote (props), which the remote can send to the network by default (network).
  • The channel. Anyone holding a port can speak the protocol.

Remote to host page

  • The remote runs in an <iframe sandbox> on a different origin, ideally a different site. The same-origin policy prevents DOM, cookie and storage access to the host.
  • <RemoteComponent> refuses a remote whose origin equals the host's origin when the sandbox includes allow-same-origin. That combination lets the frame remove its own sandbox. Override only with dangerouslyAllowSameOrigin, for tests.
  • Documents from the html prop are shown through srcdoc, which would inherit the host's origin with allow-same-origin. So the host always strips that token for them (opaque origin), or renders them through Safe Content Frame (isolation="scf", hashed origin).
  • Default sandbox: allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox. Absent: allow-top-navigation* (the remote cannot navigate the host), allow-modals, allow-pointer-lock, allow-downloads, allow-storage-access-by-user-activation.
  • allow="" (Permissions Policy): no camera, microphone, geolocation and so on unless the host opts in per instance.
  • referrerpolicy="strict-origin": the remote learns the host origin, not the full page URL.
  • Overlays: the host renders them; a remote can only ask. Overlay content must come from an allowed origin (default: the requester's origin). An overlay cannot cover the host invisibly. It is a normal positioned element the host controls (z-index, backdrop, inert). Remaining risk: UI spoofing inside overlays, such as a fake login form in a dialog. Hosts that embed untrusted publishers label overlays with the publisher's identity, or turn overlays off (overlayOrigins: []).
  • Resource abuse (CPU, memory, infinite layout growth) is not prevented by the protocol. Chromium puts cross-site iframes in separate processes (site isolation), so a busy remote does not block the host's main thread. Hosts can cap size with CSS (max-height on the slot) and unmount.

Host page to remote

  • The runtime validates every message structurally. It accepts the handshake only from window.parent with the nonce from its own URL fragment ("" for srcdoc and Safe Content Frame documents, which see no fragment).
  • Publishers restrict embedding with Content-Security-Policy: frame-ancestors <allowed hosts> (the frameAncestors option of the serving layer, the middleware and the Vite plugin). The browser enforces it before the document loads, which a check inside the page could not. Without it, any page can frame the remote and call it with arbitrary props. Remotes must treat props as untrusted input, like an API.
  • The page sees the host only through rc.props, rc.overlay() and two payload-free events. Protocol messages never reach page code.
  • Theme values are applied with style.setProperty, so there is no rule injection. @font-face descriptors are sanitised: only url("http(s)…"), url("data:font/…") and local("…") sources, and keyword-only descriptors.

The channel

  • After the handshake all traffic uses a dedicated MessagePort. Other frames and extensions cannot inject into or read it. window.postMessage is used only for the rc/initialize request, which carries no secrets, and its result.
  • The result is posted with targetOrigin set to the remote's exact origin, so if the frame was navigated elsewhere the port is not delivered.
  • The host accepts rc/initialize only if event.source === iframe.contentWindow, event.origin equals the expected origin, and the nonce matches. The runtime accepts the result only from window.parent, with its session as the JSON-RPC id and its nonce.
  • The nonce is 128-bit random, in the URL fragment. It is never sent to servers and never appears in Referer or logs. It binds a handshake to a specific load attempt.
  • Opaque-origin mode (a sandbox without allow-same-origin, and every srcdoc document): event.origin is "null" and the result must use targetOrigin "*". The source-window check and the nonce, when there is one, still apply. But if the sandboxed document navigates itself to another page between rc/initialize and its result, that page could receive the port. This is a residual risk. Prefer own-origin mode or Safe Content Frame.
  • Every request has a response. Unknown methods get unsupported. Closing rejects pending calls. Messages that fail validation are dropped.

Function calls

  • A remote can call only functions the host passed as props, resolved by path against the current props. A function the host removed cannot be called (stale-function). Only paths listed in the encoding the host sent are served: functions inside a prop that failed to encode (a React element's props.onClick, its component type) or behind a non-enumerable property stay unreachable. The remote applies the same rule to the overlay props it sends.
  • Arguments and return values are data only, with functions rejected, so a remote cannot smuggle callable capabilities to the host and the host cannot smuggle them to the remote.
  • Host callbacks must validate their arguments. They run with host privileges.

Data a host keeps for a remote

  • There is no host state channel. A host that keeps data for a widget passes it in as a prop and takes changes through a callback prop. The callback validates and authorizes like any API.
  • With isolation="scf" and scf.salt: "instance", each instanceId gets its own hashed origin, so its origin storage is separate from other instances of the same publisher. Instances that share an id share an origin and its storage.
  • That storage is host-trusted. The host chooses which document Safe Content Frame renders under an instance's origin, so it can render its own document with the same instanceId and read what the widget stored there (localStorage, IndexedDB, cookies). The host origin is part of the hash and browsers partition storage per top-level site, so a host can only reach storage created by widgets on its own site, never another host's.
  • A publisher that must keep data from the host, such as its own tokens, runs in URL mode on its own origin, where the host cannot place documents. Signing documents with a publisher key that the shim verifies before rendering would close this for Safe Content Frame too. That is not built.

Cookies and credentials

  • The remote's origin is separate, so host cookies are never sent to it and never readable by it. Remote requests carry the remote origin's cookies: third-party cookies, subject to browser policies, and partitioned per top-level site with partitioned storage or CHIPS.
  • Opaque-origin remotes have no cookies or storage at all. A host that wants to keep data for them passes it through props.

Network exfiltration

  • The default policy "public" lets a remote fetch or connect to any https: or wss: endpoint (CSP connect-src https: wss:). A remote can send its props anywhere on the public internet. An earlier version required user approval for data plus network. The current design trades that for remotes that work with public APIs, the way a third-party script embed does. Give a remote only data you would give its publisher.
  • No host credentials, ever. The host relays nothing and attaches nothing. The remote's requests carry its own origin's cookies (third-party, partitioned) or none (opaque). Anything that needs the user's authority is a host callback the host authorizes.
  • The off switch is network="none", or an allowlist, on the provider or a <RemoteComponent>. The host's CSP <meta> enforces it for html and Safe Content Frame documents. For URL remotes it is <iframe csp>, which only Chromium enforces (CSP Embedded Enforcement; the serving layer sends Allow-CSP-From: *). On other engines a URL remote's network is what its own server sends. Use the serving layer's network option, Safe Content Frame, or the html prop for untrusted code there.
  • Private networks: CSP cannot exclude address ranges, so https: also matches loopback, RFC 1918, link-local and cloud metadata addresses, and the host's own origin. No credentials are attached. Chromium's Private Network Access blocks public-origin and opaque frames from private addresses without a preflight the target must answer. Plain http: is not allowed at all. Hosts on networks where that is not enough use "none" or an allowlist. A server-side proxy that enforces ranges is out of scope, since the design has no relay.
  • A page narrows its own network with <meta http-equiv="Content-Security-Policy" content="connect-src …">. The browser enforces every policy at once, so a page cannot widen the host's.

Host-rendered documents

  • The host normalizes the html string (doctype, runtime, CSP <meta>) and shows it opaque. The content is untrusted like any remote.
  • A srcdoc document inherits the host page's own CSP. A host whose CSP forbids inline scripts blocks the inlined runtime there, and the remote fails with load. Such hosts use isolation="scf".

Per-publisher origins in production

Serve each publisher from its own origin on a dedicated domain that is on the Public Suffix List, like Google's safe content frames on *.googleusercontent.com:

https://<hash(publisher-id)>.rc-usercontent.example/<component>/
  • Different publishers get different sites, so they cannot share cookies or storage, and browsers isolate them in separate processes.
  • The hash is a stable, opaque label: base32(sha256(publisherId))[0:26].
  • The host's own domain never serves remote code.
  • Instances of one publisher share an origin, so they share a process, the HTTP cache and their own storage.
  • Send frame-ancestors per publisher, and a strict CSP for the remote documents: default-src 'self', with connect-src from the network policy.
  • Use a wildcard TLS certificate, and set no cookies on the parent domain.

Out of scope

  • Side channels between same-process frames (Spectre) where site isolation is off, as in some mobile browsers. A separate site is the mitigation.
  • A malicious host lying to a remote about props. A remote must not treat props as authenticated.
  • Denial of service by a remote: busy loops, huge heights.
  • Exfiltration of data the host chose to give a remote with network on. The host decides by what it passes and by network.
  • Phishing UI inside the remote's own area.
  • A host reading what its own widgets stored in Safe Content Frame instance storage.