Skip to main content

Module frame

Module frame 

Source
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:

DirectiveWhat 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 RTCPeerConnection whose 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’s frame-src is 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’s SameSite=None cookies, 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.

CodeWhenCause
FRAME-101PreviewFrame::loadThe manifest’s assets names no forma-runtime.js
FRAME-102PreviewFrame::loadThe manifest names a runtime file that is not embedded
FRAME-103PreviewFrame::loadThe runtime cannot be inlined: empty, not UTF-8, or containing </script, <!--, \r or NUL
FRAME-104PreviewFrame::loadThe application’s origin is not https://host[:port] (or http:// on a loopback host)
FRAME-201PageResponse::frameThe system random source failed to draw a nonce
FRAME-202PageResponse::frameThe 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:

  1. The iframe carries sandbox="allow-scripts", never allow-same-origin, set with src before it is inserted: an iframe inserted first loads about:blank, and its first load event 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 an iframe still has window.origin === "null".
  2. 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.
  3. One MessageChannel port, handed once, on the frame’s first load, with iframe.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 on window’s message event, and no other message listener on the host page trusts a message from origin "null": the frame can parent.postMessage too, and it arrives with that origin. The frame’s runtime takes the port only from event.source === parent (core’s side).
  4. The frame may send only ready, resize and error, and the host checks each against its schema (the type, and for resize a 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.
  5. Any later load event tears the frame down (the iframe removed, 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§

FrameError
Why a preview frame could not be made: a stable code (FRAME-101 to FRAME-202, see the module docs) and the cause.
PreviewFrame
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.