Skip to main content

forma_server/
frame.rs

1//! The preview frame: a document with an opaque origin, for content nobody
2//! reviewed.
3//!
4//! A generated module rendered in the application's own page runs with the
5//! page's origin: its cookies, its storage, its API. Content nobody reviewed
6//! (`@getforma/core`'s `mountMarkup` with `tier: 'unreviewed'`) renders in a
7//! frame instead, whose document [`PageResponse::frame`] writes. The
8//! document is one root, `<div id="forma-frame">`, and core's zero-build
9//! runtime as one inline script; it names no URL, so loading it fetches
10//! nothing. The runtime (`@getforma/core`'s frame runtime) then mounts the
11//! module the host sends it into that root, with `tier: 'unreviewed'`.
12//!
13//! # What the frame's policy closes, and what it does not
14//!
15//! Its enforced `Content-Security-Policy` is the strict policy with nine
16//! directives written in their place (the one exception to the rule that a
17//! page's policy is never rewritten), in the enforced header because
18//! a browser ignores `sandbox` in Report-Only and in `meta`:
19//!
20//! | Directive | What it closes |
21//! |---|---|
22//! | `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 |
23//! | `frame-ancestors <app origin>` | Framing by any other site |
24//! | `script-src 'sha256-<runtime>'` | Every script but the inline runtime: none it creates, none it imports (`import()`), none from anywhere |
25//! | `connect-src 'none'` | `fetch`, XHR, WebSocket, EventSource, `sendBeacon`, `<a ping>` |
26//! | `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 |
27//!
28//! The strict policy's `default-src 'none'` closes nested frames, media and
29//! prefetches, and `object-src` and `base-uri 'none'` stay. Trusted Types are
30//! enforced in every build. The frame names no reporting endpoint: an unreviewed
31//! document's reports do not belong to the application's reporting service.
32//!
33//! **What stays open, the residuals:**
34//!
35//! - **WebRTC.** A script in the frame can open an `RTCPeerConnection` whose
36//!   STUN or TURN servers are any host: UDP packets and DNS lookups leave the
37//!   browser, and no CSP directive closes that in Chrome 155 (`webrtc
38//!   'block'` is reported as unrecognised). It needs script: the runtime, or
39//!   code escaping it; markup alone cannot.
40//! - **The frame navigating itself.** Neither the policy nor the sandbox
41//!   stops a frame navigating its own document (by script, or by markup
42//!   alone through `<meta http-equiv="refresh">`). The host's `frame-src` is
43//!   the control: with the frame's exact path, every navigation to another
44//!   URL is refused, save the frame's own path with another query (CSP's
45//!   path matching ignores the query), which reaches the frame route, with
46//!   the application's `SameSite=None` cookies, before the host's teardown.
47//!   The frame route ignores its query, so what that carries lands in the
48//!   application's access log and nowhere else. With anything wider than
49//!   the exact path, a navigation it admits reaches that URL, data in its
50//!   query, and on the application's origin carries those cookies. Name the
51//!   exact path (below), and keep the frame route's query out of anything
52//!   but the access log.
53//! - **What the module shows.** It can show the user who previews it
54//!   anything, inside the frame.
55//!
56//! **Closed by the runtime, not by the policy: link hints and `srcdoc`.** No
57//! CSP directive governs `<link rel="preconnect">` and its kin, and in WebKit
58//! a module's `<link rel="preconnect" href="…">`, its `href` written or bound,
59//! opened a connection to the host it named from inside this frame, with
60//! `connect-src 'none'` and the sandbox in place; an `<iframe srcdoc>` holding
61//! such a `<link>` did too. `@getforma/core` 2.1.0's frame runtime mounts the
62//! module with `tier: 'unreviewed'`, which parses it once in place, removes
63//! every `<link>` and every `srcdoc` attribute (and refuses
64//! `data-bind:srcdoc`), checks the live subtree after each write, and refuses
65//! the render if one reaches the document (`FORMA_E_UNREVIEWED_LINK`,
66//! `FORMA_E_UNREVIEWED_SRCDOC`). Only core 2.1.0 and later ship the frame
67//! runtime, so every frame has it. Since FORMA-276 the same strip and live
68//! check also remove every `on*` handler attribute and every URL attribute
69//! whose value has a script scheme (`FORMA_E_UNREVIEWED_HANDLER`,
70//! `FORMA_E_UNREVIEWED_URL`), and since FORMA-277 every SVG animation value
71//! with one: `to`, `from`, `by` or any `;`-separated item of `values` on
72//! `<set>` or an `<animate…>` element (also `FORMA_E_UNREVIEWED_URL`). This
73//! policy refuses these too, so the frame does not depend on the policy alone
74//! for them.
75//!
76//! Checked in Chrome 155 (forma#22 and its review): markup alone (`<img>`, a
77//! stylesheet, a `url()` in a `style` attribute, a font) reached neither the
78//! application nor another origin; the runtime ran; `import()` and scripts
79//! it created were refused; `fetch` was refused by `connect-src`. Checked in
80//! Chromium 151, Firefox 153 and WebKit 26.5 with core 2.1.0's runtime (its
81//! `e2e/frame.spec.ts`): a module's `<link>`
82//! (preconnect, dns-prefetch, prefetch, preload, a bound `href`, or one
83//! re-created by foreign content or foster parenting) and an `<iframe
84//! srcdoc>` opened no connection in any of the three.
85//!
86//! # Where the runtime comes from
87//!
88//! From the application's own build: the manifest's `assets` names it under
89//! [`FRAME_RUNTIME_ASSET`] (`forma-runtime.js`, mapped to its hashed file),
90//! and the file is read from the application's embedded `dist`, once, at
91//! start ([`PreviewFrame::load`]), which also computes its SHA-256 for
92//! `script-src`. That keeps the runtime the one the application built with,
93//! the same core version as its bundles, in the binary it ships, and
94//! found the way every other asset is found, with no path for the
95//! application to keep in step. It must be a classic, self-contained script
96//! (no `import`), and it is inlined as it is, never rewritten: so it may not
97//! contain `</script` or `<!--` (in any case), which would end or confuse its
98//! element, nor a carriage return or a NUL, which the HTML parser rewrites
99//! before it runs the script, so the script it runs would not be the one
100//! hashed.
101//!
102//! A missing or unusable runtime is a coded [`FrameError`] at start: there is
103//! never a frame without its runtime.
104//!
105//! | Code | When | Cause |
106//! |---|---|---|
107//! | FRAME-101 | [`PreviewFrame::load`] | The manifest's `assets` names no `forma-runtime.js` |
108//! | FRAME-102 | [`PreviewFrame::load`] | The manifest names a runtime file that is not embedded |
109//! | FRAME-103 | [`PreviewFrame::load`] | The runtime cannot be inlined: empty, not UTF-8, or containing `</script`, `<!--`, `\r` or NUL |
110//! | FRAME-104 | [`PreviewFrame::load`] | The application's origin is not `https://host[:port]` (or `http://` on a loopback host) |
111//! | FRAME-201 | [`PageResponse::frame`] | The system random source failed to draw a nonce |
112//! | FRAME-202 | [`PageResponse::frame`] | The frame's policy is not a valid header value (ruled out by the checks at start) |
113//!
114//! # The host's side, which is the application's
115//!
116//! forma-server writes the frame's document; the application ships the page
117//! that frames it and the bridge that talks to it. The frame is only as
118//! confined as that host keeps it:
119//!
120//! 1. **The `iframe` carries `sandbox="allow-scripts"`**, never
121//!    `allow-same-origin`, **set with `src` before it is inserted**: an
122//!    `iframe` inserted first loads `about:blank`, and its first `load` event
123//!    would be that one's. The attribute survives the frame navigating
124//!    itself; the response header does not. Served without the header, a
125//!    document in such an `iframe` still has `window.origin === "null"`.
126//! 2. **The host page's own policy names the frame's exact URL in
127//!    `frame-src`**: `PolicyExtensions::new().frame_src("https://studio.example/preview-frame")`,
128//!    never `'self'` or a wider source. It is the only control on where the
129//!    frame may navigate itself, and the host's teardown on a later load
130//!    (5) comes after that request has left. It still admits the frame's
131//!    own path with another query, the residual above.
132//! 3. **One `MessageChannel` port, handed once, on the frame's first load,**
133//!    with `iframe.contentWindow.postMessage(null, "*", [channel.port2])`:
134//!    the message carries nothing but the port; `"*"` because the frame's
135//!    origin is opaque (a target origin of `"null"` throws, and the
136//!    application's never matches). After that the host listens only on its
137//!    own port, never on `window`'s `message` event, and no other `message`
138//!    listener on the host page trusts a message from origin `"null"`: the
139//!    frame can `parent.postMessage` too, and it arrives with that origin.
140//!    The frame's runtime takes the port only from `event.source === parent`
141//!    (core's side).
142//! 4. **The frame may send only `ready`, `resize` and `error`,** and the
143//!    host checks each against its schema (the type, and for `resize` a
144//!    bounded number) before acting on it. None acts in the studio's
145//!    session: no message from the frame calls the application's API,
146//!    navigates the host, or reads its state. **The host sends only the
147//!    module to render.**
148//! 5. **Any later `load` event tears the frame down** (the `iframe` removed,
149//!    the port closed): a second load means the frame navigated, and what
150//!    loaded is no longer the document forma-server wrote.
151//!
152//! ```js
153//! const frame = document.createElement("iframe");
154//! frame.setAttribute("sandbox", "allow-scripts"); // before src, before insertion
155//! frame.src = "/preview-frame";
156//! const channel = new MessageChannel();
157//! let loads = 0;
158//! frame.addEventListener("load", () => {
159//!   if (++loads > 1) { // it navigated: tear it down
160//!     channel.port1.close();
161//!     frame.remove();
162//!     return;
163//!   }
164//!   channel.port1.onmessage = (event) => { /* only ready, resize, error, schema-checked */ };
165//!   frame.contentWindow.postMessage(null, "*", [channel.port2]); // only the port
166//! });
167//! document.body.append(frame);
168//! ```
169//!
170//! ```no_run
171//! use axum::response::{IntoResponse, Response};
172//! use forma_server::{assets, PageResponse, PolicyExtensions, PreviewFrame};
173//! use std::sync::Arc;
174//!
175//! // At start, before the port binds: refuse to start without the runtime.
176//! fn start<Assets: rust_embed::Embed>() -> Result<Arc<PreviewFrame>, String> {
177//!     let manifest = assets::try_load_manifest::<Assets>()?;
178//!     PreviewFrame::load::<Assets>(&manifest, "https://studio.example")
179//!         .map(Arc::new)
180//!         .map_err(|e| e.to_string())
181//! }
182//!
183//! // The host page's policy: frames from the frame's exact URL, nothing else.
184//! fn host_policy() -> PolicyExtensions {
185//!     PolicyExtensions::new().frame_src("https://studio.example/preview-frame")
186//! }
187//!
188//! // The `/preview-frame` route the host's iframe loads.
189//! fn preview_frame(frame: &PreviewFrame) -> Response {
190//!     match PageResponse::frame(frame) {
191//!         Ok(page) => page.into_response(),
192//!         Err(error) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, error.code()).into_response(),
193//!     }
194//! }
195//! ```
196
197use crate::csp::{self, CspNonce};
198use crate::response::{PageResponse, RenderCounts};
199use crate::types::AssetManifest;
200use base64::{engine::general_purpose::STANDARD, Engine as _};
201use rust_embed::Embed;
202use std::fmt;
203use std::sync::Arc;
204
205/// The logical name the build's manifest gives the frame runtime in its
206/// `assets`, mapped to the hashed file the application embeds.
207pub const FRAME_RUNTIME_ASSET: &str = "forma-runtime.js";
208
209/// What the preview frame's document needs, read once at start: the inline
210/// runtime, its SHA-256 and the application's origin (see the
211/// [module docs](self)).
212#[derive(Clone)]
213pub struct PreviewFrame {
214    runtime: Arc<str>,
215    runtime_sha256: String,
216    app_origin: String,
217}
218
219impl PreviewFrame {
220    /// Read the runtime the manifest names under [`FRAME_RUNTIME_ASSET`] from
221    /// the application's embedded files `A`, hash it for `script-src`, and
222    /// check `app_origin`, the origin of the pages that frame it
223    /// (`https://studio.example`).
224    ///
225    /// # Errors
226    ///
227    /// `FRAME-101` to `FRAME-104` (see the [module docs](self)); refuse to
228    /// start on any of them.
229    ///
230    /// Verified by: crates/forma-server/tests/internal_renderer/preview_frame.rs > "a_frame_without_its_runtime_is_a_coded_error_at_start"
231    pub fn load<A: Embed>(manifest: &AssetManifest, app_origin: &str) -> Result<Self, FrameError> {
232        manifest
233            .validate()
234            .map_err(|e| FrameError::new("FRAME-101", e))?;
235        let app_origin = csp::check_origin(app_origin)
236            .map_err(|reason| FrameError::new("FRAME-104", format!("{app_origin:?}: {reason}")))?;
237        let Some(file) = manifest.aliases.get(FRAME_RUNTIME_ASSET) else {
238            return Err(FrameError::new(
239                "FRAME-101",
240                format!(
241                    "the manifest's assets name no {FRAME_RUNTIME_ASSET}: build with the frame \
242                     runtime, or serve no preview frame"
243                ),
244            ));
245        };
246        let Some(embedded) = A::get(file) else {
247            return Err(FrameError::new(
248                "FRAME-102",
249                format!(
250                    "the manifest names {FRAME_RUNTIME_ASSET} as {file}, which is not embedded"
251                ),
252            ));
253        };
254        let runtime = std::str::from_utf8(&embedded.data)
255            .map_err(|_| FrameError::new("FRAME-103", format!("{file} is not UTF-8")))?;
256        if runtime.trim().is_empty() {
257            return Err(FrameError::new("FRAME-103", format!("{file} is empty")));
258        }
259        let lower = runtime.to_ascii_lowercase();
260        for sequence in ["</script", "<!--"] {
261            if lower.contains(sequence) {
262                return Err(FrameError::new(
263                    "FRAME-103",
264                    format!(
265                        "{file} contains {sequence:?}, which would end or confuse its inline \
266                         script; the runtime is inlined as it is, never rewritten"
267                    ),
268                ));
269            }
270        }
271        // The HTML parser turns CR and CRLF into LF and NUL into U+FFFD before
272        // the script runs, so the hash of bytes holding either would not be
273        // the hash of the script the browser checks.
274        for (character, name) in [('\r', "a carriage return"), ('\0', "a NUL")] {
275            if runtime.contains(character) {
276                return Err(FrameError::new(
277                    "FRAME-103",
278                    format!(
279                        "{file} contains {name}, which the HTML parser rewrites before it runs \
280                         the script, so the script would not match its hash; build it with LF \
281                         line endings"
282                    ),
283                ));
284            }
285        }
286        let digest = ring::digest::digest(&ring::digest::SHA256, runtime.as_bytes());
287        Ok(PreviewFrame {
288            runtime: Arc::from(runtime),
289            runtime_sha256: STANDARD.encode(digest.as_ref()),
290            app_origin,
291        })
292    }
293
294    /// The origin the frame's `frame-ancestors` names, in lower case.
295    pub fn app_origin(&self) -> &str {
296        &self.app_origin
297    }
298}
299
300impl fmt::Debug for PreviewFrame {
301    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
302        f.debug_struct("PreviewFrame")
303            .field("runtime_bytes", &self.runtime.len())
304            .field("runtime_sha256", &self.runtime_sha256)
305            .field("app_origin", &self.app_origin)
306            .finish()
307    }
308}
309
310impl PageResponse {
311    /// The preview frame's document, ready to send: an opaque origin, the
312    /// runtime inline and allowed by its hash, and the requests the
313    /// [`frame` module docs](crate::frame) list closed, with their residuals
314    /// (the docs also set out the host's side). The only response whose
315    /// policy is rewritten, and only in its nine directives.
316    ///
317    /// As every `PageResponse`, it writes `Cache-Control: no-store`, the
318    /// document headers, and in a development build Trusted Types in
319    /// Report-Only (no endpoint), with it; its HTML is reachable apart from
320    /// them only through `into_parts_unchecked()`.
321    ///
322    /// # Errors
323    ///
324    /// `FRAME-201` when no nonce can be drawn for `style-src`: there is no
325    /// frame without one; `FRAME-202`, ruled out by the checks at start.
326    ///
327    /// Verified by: crates/forma-server/tests/internal_renderer/preview_frame.rs > "the_frame_document_inlines_the_runtime_by_its_hash_and_names_nothing_to_fetch"
328    pub fn frame(preview: &PreviewFrame) -> Result<PageResponse, FrameError> {
329        frame_with(preview, CspNonce::try_generate)
330    }
331}
332
333/// [`PageResponse::frame`] with the nonce source a parameter, so a test can
334/// make the draw fail.
335fn frame_with(
336    preview: &PreviewFrame,
337    draw: impl FnOnce() -> Result<CspNonce, csp::NonceError>,
338) -> Result<PageResponse, FrameError> {
339    let nonce = draw().map_err(|e| FrameError::new("FRAME-201", e.to_string()))?;
340    let policy = csp::frame_policy(&nonce, &preview.runtime_sha256, &preview.app_origin)
341        .ok_or_else(|| {
342            FrameError::new(
343                "FRAME-202",
344                "the frame's policy is not a valid header value".into(),
345            )
346        })?;
347    let html = document(&preview.runtime);
348    Ok(PageResponse::with_policy(
349        html,
350        nonce,
351        policy,
352        RenderCounts::default(),
353    ))
354}
355
356/// The frame's document: one root for the runtime to render into, and the
357/// runtime inline, allowed by its hash. No `src`, `href` or other URL: the
358/// document itself fetches nothing.
359fn document(runtime: &str) -> String {
360    let mut html = String::with_capacity(runtime.len() + 256);
361    html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
362    html.push_str("<meta charset=\"utf-8\">\n");
363    html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
364    html.push_str("<title>Preview</title>\n");
365    html.push_str("</head>\n<body>\n<div id=\"forma-frame\"></div>\n<script>");
366    html.push_str(runtime);
367    html.push_str("</script>\n</body>\n</html>");
368    html
369}
370
371/// Why a preview frame could not be made: a stable code (`FRAME-101` to
372/// `FRAME-202`, see the [module docs](self)) and the cause.
373#[derive(Debug, Clone, PartialEq, Eq)]
374#[non_exhaustive]
375pub struct FrameError {
376    code: &'static str,
377    detail: String,
378}
379
380impl FrameError {
381    fn new(code: &'static str, detail: String) -> Self {
382        FrameError { code, detail }
383    }
384
385    /// The stable code, such as `FRAME-101`.
386    pub fn code(&self) -> &'static str {
387        self.code
388    }
389}
390
391impl fmt::Display for FrameError {
392    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
393        write!(f, "{} {}", self.code, self.detail)
394    }
395}
396
397impl std::error::Error for FrameError {}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    fn preview() -> PreviewFrame {
404        PreviewFrame {
405            runtime: Arc::from("window.__runtime = 1;"),
406            runtime_sha256: "AAAA".to_string(),
407            app_origin: "https://studio.example".to_string(),
408        }
409    }
410
411    #[test]
412    fn frame_201_a_failed_nonce_draw_is_no_frame() {
413        let error = frame_with(&preview(), || Err(csp::NonceError)).expect_err("no frame");
414        assert_eq!(error.code(), "FRAME-201");
415        // The same frame with a real draw is made: the failure is the draw's.
416        assert!(frame_with(&preview(), CspNonce::try_generate).is_ok());
417    }
418
419    #[test]
420    fn frame_202_a_policy_that_is_no_header_value_is_no_frame() {
421        // Unreachable through `load`, which checks the origin: built by hand
422        // here, an origin with a newline makes no header value.
423        let bad = PreviewFrame {
424            app_origin: "https://studio.example\r\nx: y".to_string(),
425            ..preview()
426        };
427        let error = frame_with(&bad, CspNonce::try_generate).expect_err("no frame");
428        assert_eq!(error.code(), "FRAME-202");
429    }
430}