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}