Expand description
The preview frame: a document with an opaque origin, for content nobody reviewed.
A generated module rendered in the application’s own page runs with the
page’s origin: its cookies, its storage, its API. Content nobody reviewed
(@getforma/core’s mountMarkup with tier: 'unreviewed') renders in a
frame instead, whose document PageResponse::frame writes. The
document is one root, <div id="forma-frame">, and core’s zero-build
runtime as one inline script; it names no URL, so loading it fetches
nothing. The runtime (@getforma/core’s frame runtime) then mounts the
module the host sends it into that root, with tier: 'unreviewed'.
§What the frame’s policy closes, and what it does not
Its enforced Content-Security-Policy is the strict policy with nine
directives written in their place (the one exception to the rule that a
page’s policy is never rewritten), in the enforced header because
a browser ignores sandbox in Report-Only and in meta:
| Directive | What it closes |
|---|---|
sandbox allow-scripts (no allow-same-origin) | An opaque origin: no cookies, storage or service worker of the application’s, no same-origin window; no forms, popups, top navigation or downloads |
frame-ancestors <app origin> | Framing by any other site |
script-src 'sha256-<runtime>' | Every script but the inline runtime: none it creates, none it imports (import()), none from anywhere |
connect-src 'none' | fetch, XHR, WebSocket, EventSource, sendBeacon, <a ping> |
img-src data:, style-src 'nonce-N', font-src 'none', worker-src 'none', form-action 'none' | Images, stylesheets, url() in style attributes, fonts and workers from any origin, the application’s included: in an opaque-origin document CSP’s 'self' still names the application’s origin, so markup alone would otherwise send it requests with its SameSite=None cookies |
The strict policy’s default-src 'none' closes nested frames, media and
prefetches, and object-src and base-uri 'none' stay. Trusted Types are
enforced in every build. The frame names no reporting endpoint: an unreviewed
document’s reports do not belong to the application’s reporting service.
What stays open, the residuals:
- WebRTC. A script in the frame can open an
RTCPeerConnectionwhose STUN or TURN servers are any host: UDP packets and DNS lookups leave the browser, and no CSP directive closes that in Chrome 155 (webrtc 'block'is reported as unrecognised). It needs script: the runtime, or code escaping it; markup alone cannot. - The frame navigating itself. Neither the policy nor the sandbox
stops a frame navigating its own document (by script, or by markup
alone through
<meta http-equiv="refresh">). The host’sframe-srcis the control: with the frame’s exact path, every navigation to another URL is refused, save the frame’s own path with another query (CSP’s path matching ignores the query), which reaches the frame route, with the application’sSameSite=Nonecookies, before the host’s teardown. The frame route ignores its query, so what that carries lands in the application’s access log and nowhere else. With anything wider than the exact path, a navigation it admits reaches that URL, data in its query, and on the application’s origin carries those cookies. Name the exact path (below), and keep the frame route’s query out of anything but the access log. - What the module shows. It can show the user who previews it anything, inside the frame.
Closed by the runtime, not by the policy: link hints and srcdoc. No
CSP directive governs <link rel="preconnect"> and its kin, and in WebKit
a module’s <link rel="preconnect" href="…">, its href written or bound,
opened a connection to the host it named from inside this frame, with
connect-src 'none' and the sandbox in place; an <iframe srcdoc> holding
such a <link> did too. @getforma/core 2.1.0’s frame runtime mounts the
module with tier: 'unreviewed', which parses it once in place, removes
every <link> and every srcdoc attribute (and refuses
data-bind:srcdoc), checks the live subtree after each write, and refuses
the render if one reaches the document (FORMA_E_UNREVIEWED_LINK,
FORMA_E_UNREVIEWED_SRCDOC). Only core 2.1.0 and later ship the frame
runtime, so every frame has it. Since FORMA-276 the same strip and live
check also remove every on* handler attribute and every URL attribute
whose value has a script scheme (FORMA_E_UNREVIEWED_HANDLER,
FORMA_E_UNREVIEWED_URL), and since FORMA-277 every SVG animation value
with one: to, from, by or any ;-separated item of values on
<set> or an <animate…> element (also FORMA_E_UNREVIEWED_URL). This
policy refuses these too, so the frame does not depend on the policy alone
for them.
Checked in Chrome 155 (forma#22 and its review): markup alone (<img>, a
stylesheet, a url() in a style attribute, a font) reached neither the
application nor another origin; the runtime ran; import() and scripts
it created were refused; fetch was refused by connect-src. Checked in
Chromium 151, Firefox 153 and WebKit 26.5 with core 2.1.0’s runtime (its
e2e/frame.spec.ts): a module’s <link>
(preconnect, dns-prefetch, prefetch, preload, a bound href, or one
re-created by foreign content or foster parenting) and an <iframe srcdoc> opened no connection in any of the three.
§Where the runtime comes from
From the application’s own build: the manifest’s assets names it under
FRAME_RUNTIME_ASSET (forma-runtime.js, mapped to its hashed file),
and the file is read from the application’s embedded dist, once, at
start (PreviewFrame::load), which also computes its SHA-256 for
script-src. That keeps the runtime the one the application built with,
the same core version as its bundles, in the binary it ships, and
found the way every other asset is found, with no path for the
application to keep in step. It must be a classic, self-contained script
(no import), and it is inlined as it is, never rewritten: so it may not
contain </script or <!-- (in any case), which would end or confuse its
element, nor a carriage return or a NUL, which the HTML parser rewrites
before it runs the script, so the script it runs would not be the one
hashed.
A missing or unusable runtime is a coded FrameError at start: there is
never a frame without its runtime.
| Code | When | Cause |
|---|---|---|
| FRAME-101 | PreviewFrame::load | The manifest’s assets names no forma-runtime.js |
| FRAME-102 | PreviewFrame::load | The manifest names a runtime file that is not embedded |
| FRAME-103 | PreviewFrame::load | The runtime cannot be inlined: empty, not UTF-8, or containing </script, <!--, \r or NUL |
| FRAME-104 | PreviewFrame::load | The application’s origin is not https://host[:port] (or http:// on a loopback host) |
| FRAME-201 | PageResponse::frame | The system random source failed to draw a nonce |
| FRAME-202 | PageResponse::frame | The frame’s policy is not a valid header value (ruled out by the checks at start) |
§The host’s side, which is the application’s
forma-server writes the frame’s document; the application ships the page that frames it and the bridge that talks to it. The frame is only as confined as that host keeps it:
- The
iframecarriessandbox="allow-scripts", neverallow-same-origin, set withsrcbefore it is inserted: aniframeinserted first loadsabout:blank, and its firstloadevent would be that one’s. The attribute survives the frame navigating itself; the response header does not. Served without the header, a document in such aniframestill haswindow.origin === "null". - The host page’s own policy names the frame’s exact URL in
frame-src:PolicyExtensions::new().frame_src("https://studio.example/preview-frame"), never'self'or a wider source. It is the only control on where the frame may navigate itself, and the host’s teardown on a later load (5) comes after that request has left. It still admits the frame’s own path with another query, the residual above. - One
MessageChannelport, handed once, on the frame’s first load, withiframe.contentWindow.postMessage(null, "*", [channel.port2]): the message carries nothing but the port;"*"because the frame’s origin is opaque (a target origin of"null"throws, and the application’s never matches). After that the host listens only on its own port, never onwindow’smessageevent, and no othermessagelistener on the host page trusts a message from origin"null": the frame canparent.postMessagetoo, and it arrives with that origin. The frame’s runtime takes the port only fromevent.source === parent(core’s side). - The frame may send only
ready,resizeanderror, and the host checks each against its schema (the type, and forresizea bounded number) before acting on it. None acts in the studio’s session: no message from the frame calls the application’s API, navigates the host, or reads its state. The host sends only the module to render. - Any later
loadevent tears the frame down (theiframeremoved, the port closed): a second load means the frame navigated, and what loaded is no longer the document forma-server wrote.
const frame = document.createElement("iframe");
frame.setAttribute("sandbox", "allow-scripts"); // before src, before insertion
frame.src = "/preview-frame";
const channel = new MessageChannel();
let loads = 0;
frame.addEventListener("load", () => {
if (++loads > 1) { // it navigated: tear it down
channel.port1.close();
frame.remove();
return;
}
channel.port1.onmessage = (event) => { /* only ready, resize, error, schema-checked */ };
frame.contentWindow.postMessage(null, "*", [channel.port2]); // only the port
});
document.body.append(frame);use axum::response::{IntoResponse, Response};
use forma_server::{assets, PageResponse, PolicyExtensions, PreviewFrame};
use std::sync::Arc;
// At start, before the port binds: refuse to start without the runtime.
fn start<Assets: rust_embed::Embed>() -> Result<Arc<PreviewFrame>, String> {
let manifest = assets::try_load_manifest::<Assets>()?;
PreviewFrame::load::<Assets>(&manifest, "https://studio.example")
.map(Arc::new)
.map_err(|e| e.to_string())
}
// The host page's policy: frames from the frame's exact URL, nothing else.
fn host_policy() -> PolicyExtensions {
PolicyExtensions::new().frame_src("https://studio.example/preview-frame")
}
// The `/preview-frame` route the host's iframe loads.
fn preview_frame(frame: &PreviewFrame) -> Response {
match PageResponse::frame(frame) {
Ok(page) => page.into_response(),
Err(error) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, error.code()).into_response(),
}
}Structs§
- Frame
Error - Why a preview frame could not be made: a stable code (
FRAME-101toFRAME-202, see the module docs) and the cause. - Preview
Frame - What the preview frame’s document needs, read once at start: the inline runtime, its SHA-256 and the application’s origin (see the module docs).
Constants§
- FRAME_
RUNTIME_ ASSET - The logical name the build’s manifest gives the frame runtime in its
assets, mapped to the hashed file the application embeds.