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