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