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.
// 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" /><!-- 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
| Mode | Origin | Use it for |
|---|---|---|
| Own origin | The publisher's | Remotes from a publisher you know. Default. The frame keeps its own cookies and storage. A remote on your own origin is refused. |
| Opaque | null | Many 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 Frame | Hashed, per publisher | Untrusted 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.
Content-Security-Policy: frame-ancestors https://app.exampleThe serving layer writes it for you. It is one option on normalizeHtml, remoteComponentsMiddleware and the Vite plugin. Serving has the full set of headers.
remoteComponentsMiddleware({ root: "widgets", frameAncestors: ["https://app.example"] });
normalizeHtml(html, { frameAncestors: ["https://app.example"] });
remoteComponents({ frameAncestors: ["https://app.example"] }); // Vite pluginThreat model
Parties and trust
| Party | Trusts | Does not trust |
|---|---|---|
Host: the page rendering <RemoteComponent> | itself | remote code, remote messages, overlay content |
Remote: an HTML document in the iframe, from a publisher, a model, or the host's own html prop | its own publisher | the embedding page, other remotes |
| Publisher origin: where remotes are served | ||
| User | the 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 includesallow-same-origin. That combination lets the frame remove its own sandbox. Override only withdangerouslyAllowSameOrigin, for tests.- Documents from the
htmlprop are shown throughsrcdoc, which would inherit the host's origin withallow-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-heighton the slot) and unmount.
Host page to remote
- The runtime validates every message structurally. It accepts the handshake only from
window.parentwith the nonce from its own URL fragment (""forsrcdocand Safe Content Frame documents, which see no fragment). - Publishers restrict embedding with
Content-Security-Policy: frame-ancestors <allowed hosts>(theframeAncestorsoption 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-facedescriptors are sanitised: onlyurl("http(s)…"),url("data:font/…")andlocal("…")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.postMessageis used only for therc/initializerequest, which carries no secrets, and its result. - The result is posted with
targetOriginset to the remote's exact origin, so if the frame was navigated elsewhere the port is not delivered. - The host accepts
rc/initializeonly ifevent.source === iframe.contentWindow,event.originequals the expected origin, and the nonce matches. The runtime accepts the result only fromwindow.parent, with its session as the JSON-RPCidand 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 everysrcdocdocument):event.originis"null"and the result must usetargetOrigin "*". The source-window check and the nonce, when there is one, still apply. But if the sandboxed document navigates itself to another page betweenrc/initializeand 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'sprops.onClick, its componenttype) 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"andscf.salt: "instance", eachinstanceIdgets 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
instanceIdand 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 remotefetchor connect to anyhttps:orwss:endpoint (CSPconnect-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 forhtmland Safe Content Frame documents. For URL remotes it is<iframe csp>, which only Chromium enforces (CSP Embedded Enforcement; the serving layer sendsAllow-CSP-From: *). On other engines a URL remote's network is what its own server sends. Use the serving layer'snetworkoption, Safe Content Frame, or thehtmlprop 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. Plainhttp: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
htmlstring (doctype, runtime, CSP<meta>) and shows it opaque. The content is untrusted like any remote. - A
srcdocdocument inherits the host page's own CSP. A host whose CSP forbids inline scripts blocks the inlined runtime there, and the remote fails withload. Such hosts useisolation="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-ancestorsper publisher, and a strict CSP for the remote documents:default-src 'self', withconnect-srcfrom 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.