# Security
URL: /remote-components/docs/security

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

> For AI agents: a documentation index is available at [llms.txt](/llms.txt). Use `.md` for canonical markdown pages; `.mdx` is kept as a backwards-compatible alias on supported URL paths.

## 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.

\[interactive preview omitted]

```
// 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

```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

| 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](/remote-components/docs/network), 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.example
```

The serving layer writes it for you. It is one option on `normalizeHtml`, `remoteComponentsMiddleware` and the Vite plugin. [Serving](/remote-components/docs/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 plugin
```

## Threat 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](#network-exfiltration)).
- 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.