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}