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