forma-server 0.2.2

Axum middleware for Forma SSR: page rendering, asset serving, CSP, service worker
Documentation
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
//! Typed homes for what applications put in `body_prefix` that is not their
//! own markup: the route's `modulepreload` links, read from the
//! build's manifest and nonced here, and the page's head links.
//!
//! The third, data, already has its home: [`PageConfig::config_json`]
//! (`window.__FORMA_CONFIG__`, every `<` escaped), and in 3.0.0 the state
//! block.
//!
//! Each reaches the page through [`RenderCtx`](crate::RenderCtx), so only
//! [`try_render`](crate::try_render) and
//! [`render_browser_shell`](crate::render_browser_shell) write them; the
//! deprecated `render_page` writes the document 0.2.1 wrote.
//!
//! [`PageConfig::config_json`]: crate::PageConfig::config_json

use serde::Deserialize;
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;

/// Each route's static ESM dependencies, as the build's manifest lists them
/// in `modulepreload` (`@getforma/build` with `codeSplitting`).
///
/// forma-server writes one `<link rel="modulepreload">` per file into the
/// route's head, after the route's own modules, with the page's nonce: under
/// `'strict-dynamic'` a preload without it is dropped. Load the table once at
/// start with [`assets::try_load_module_preloads`](crate::assets::try_load_module_preloads)
/// and hand it to each render through
/// [`RenderCtx::with_module_preloads`](crate::RenderCtx::with_module_preloads);
/// cloning it is cheap. Dynamically imported chunks are not in the list: the
/// build leaves them out, so they load when the page asks for them.
///
/// The table is the one from the manifest at start. In development, a chunk
/// the watcher renames stays listed until the server restarts; its preload
/// fails harmlessly, as a hint, and the module itself still loads.
///
/// Verified by: crates/forma-server/tests/typed_homes.rs > "try_render_writes_the_routes_manifest_modulepreload_links_nonced"
#[derive(Debug, Clone)]
pub struct ModulePreloads {
    routes: Arc<HashMap<String, Vec<String>>>,
}

/// The two manifest fields the table is read from.
#[derive(Deserialize)]
struct Manifest {
    assets: HashMap<String, String>,
    routes: HashMap<String, Route>,
}

#[derive(Deserialize)]
struct Route {
    #[serde(default)]
    js: Vec<String>,
    #[serde(default)]
    modulepreload: Vec<String>,
}

impl ModulePreloads {
    /// Read the table from `manifest.json`'s text.
    ///
    /// Each listed file must be one of the build's own files, a value in the
    /// manifest's `assets`, or the table is refused: a preload carries the
    /// page's nonce, so it may name only what the build wrote. A route's own
    /// modules (`js`), which forma-server already preloads, and repeats are
    /// left out.
    ///
    /// Verified by: crates/forma-server/tests/typed_homes.rs > "a_manifest_preload_that_is_not_one_of_the_builds_files_refuses_the_table"
    pub(crate) fn from_manifest_json(json: &str) -> Result<Self, String> {
        let manifest: Manifest = serde_json::from_str(json)
            .map_err(|e| format!("Failed to parse manifest.json: {e}"))?;
        let built: std::collections::HashSet<&str> =
            manifest.assets.values().map(String::as_str).collect();
        let mut routes = HashMap::new();
        for (route, assets) in &manifest.routes {
            let mut files: Vec<String> = Vec::new();
            for file in &assets.modulepreload {
                if !built.contains(file.as_str()) {
                    return Err(format!(
                        "manifest.json: route {route} lists modulepreload {file:?}, which is not \
                         one of the build's files (the manifest's assets)"
                    ));
                }
                if !assets.js.contains(file) && !files.contains(file) {
                    files.push(file.clone());
                }
            }
            if !files.is_empty() {
                routes.insert(route.clone(), files);
            }
        }
        Ok(ModulePreloads {
            routes: Arc::new(routes),
        })
    }

    /// The files a route preloads, in the manifest's order; empty for a route
    /// the manifest lists none for.
    pub fn route(&self, route_pattern: &str) -> &[String] {
        self.routes.get(route_pattern).map_or(&[], Vec::as_slice)
    }
}

/// A `<link>` for the page's head that is not an asset of the build: an icon,
/// a canonical URL, a feed. The typed home for what ksx Studio
/// splices before `</head>`.
///
/// Its `rel` is one or more of a closed list of link types, none of which
/// loads style or script:
///
/// | Link types | Why they are kept |
/// |---|---|
/// | `icon`, `apple-touch-icon`, `mask-icon` | An image the browser shows for the page, fetched under the policy's `img-src` |
/// | `manifest` | The web app manifest; see below |
/// | `canonical`, `alternate`, `author`, `license` | Metadata: the browser fetches and runs nothing |
/// | `preconnect`, `dns-prefetch` | A connection hint to an origin the page will use: nothing is fetched or run |
///
/// Everything else is refused, with a code: `stylesheet`, `modulepreload`,
/// `preload`, `prefetch`, `prerender` and `import` load style or script, and
/// a page's stylesheets and module preloads come from the build's manifest
/// (`HEAD-101`); any other word, `shortcut` included (write `icon`), is not on
/// the list (`HEAD-102`). Link types are matched without regard to case and
/// written in lower case. Every value is escaped for its attribute, and a
/// script URL is refused, so a head link is never markup and never runs
/// anything. Add it to a render with
/// [`RenderCtx::with_head_link`](crate::RenderCtx::with_head_link); it is
/// written after the route's stylesheets and preloads, in the order added.
///
/// **`manifest` needs a policy the default does not give.** The browser
/// fetches a web app manifest under `manifest-src`, which falls back to the
/// strict policy's `default-src 'none'`, so the default policy blocks it.
/// [`PolicyExtensions`](crate::PolicyExtensions) has no manifest source
/// (it names image and frame sources, a form target and the opener
/// policy), so a page that links a manifest today sees it refused,
/// with a policy violation in the console.
///
/// ```
/// use forma_server::{HeadLink, RenderCtx};
///
/// let ctx = RenderCtx::new()
///     .with_head_link(HeadLink::new("icon", "/favicon.svg")?.with_type("image/svg+xml"))
///     .with_head_link(HeadLink::new("apple-touch-icon", "/apple-touch-icon.png")?);
/// # let _ = ctx;
/// # Ok::<(), forma_server::HeadLinkError>(())
/// ```
///
/// Verified by: crates/forma-server/tests/typed_homes.rs > "head_links_are_written_escaped_in_the_head_in_the_order_added"
/// Verified by: crates/forma-server/tests/typed_homes.rs > "a_head_link_carries_only_the_listed_link_types"
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadLink {
    rel: String,
    href: String,
    mime: Option<String>,
    sizes: Option<String>,
}

/// The link types a head link may carry (see [`HeadLink`]).
const LINK_TYPES: &[&str] = &[
    "icon",
    "apple-touch-icon",
    "mask-icon",
    "manifest",
    "canonical",
    "alternate",
    "author",
    "license",
    "preconnect",
    "dns-prefetch",
];

/// The link types that load style or script, refused as `HEAD-101`.
const LOADING_TYPES: &[&str] = &[
    "stylesheet",
    "modulepreload",
    "preload",
    "prefetch",
    "prerender",
    "import",
];

impl HeadLink {
    /// A link of type `rel` (one or more of [`HeadLink`]'s link types,
    /// separated by whitespace) to `href`.
    ///
    /// # Errors
    ///
    /// A [`HeadLinkError`] with its [`code`](HeadLinkError::code):
    ///
    /// | Code | Cause |
    /// |---|---|
    /// | HEAD-101 | `rel` names a link type that loads style or script |
    /// | HEAD-102 | `rel` names no link type, or one outside the list |
    /// | HEAD-103 | `href` is a `javascript:` or `vbscript:` URL, in any case and with any tab, newline or leading control character a browser strips |
    /// | HEAD-104 | `href` is empty |
    ///
    /// Verified by: crates/forma-server/tests/typed_homes.rs > "a_head_link_carries_only_the_listed_link_types"
    /// Verified by: crates/forma-server/tests/typed_homes.rs > "a_head_link_refuses_a_script_url_and_keeps_every_other"
    pub fn new(rel: &str, href: &str) -> Result<HeadLink, HeadLinkError> {
        let refuse = |code, reason| {
            Err(HeadLinkError {
                rel: rel.to_string(),
                href: href.to_string(),
                code,
                reason,
            })
        };
        let types: Vec<String> = rel
            .split_ascii_whitespace()
            .map(str::to_ascii_lowercase)
            .collect();
        if types.iter().any(|t| LOADING_TYPES.contains(&t.as_str())) {
            return refuse(
                "HEAD-101",
                "rel names a link type that loads style or script; a page's stylesheets and \
                 module preloads come from the build's manifest",
            );
        }
        if types.is_empty() || !types.iter().all(|t| LINK_TYPES.contains(&t.as_str())) {
            return refuse(
                "HEAD-102",
                "rel is not one of the link types a head link carries: icon, apple-touch-icon, \
                 mask-icon, manifest, canonical, alternate, author, license, preconnect, \
                 dns-prefetch",
            );
        }
        if is_script_url(href) {
            return refuse("HEAD-103", "href is a script URL");
        }
        if href.trim().is_empty() {
            return refuse("HEAD-104", "href is empty");
        }
        Ok(HeadLink {
            rel: types.join(" "),
            href: href.to_string(),
            mime: None,
            sizes: None,
        })
    }

    /// Add a `type` attribute, such as `image/svg+xml`.
    #[must_use]
    pub fn with_type(mut self, mime: &str) -> Self {
        self.mime = Some(mime.to_string());
        self
    }

    /// Add a `sizes` attribute, such as `180x180`.
    #[must_use]
    pub fn with_sizes(mut self, sizes: &str) -> Self {
        self.sizes = Some(sizes.to_string());
        self
    }

    /// The element, every value escaped for its attribute.
    pub(crate) fn write(&self, head: &mut String) {
        use crate::template::escape_html;
        head.push_str("<link rel=\"");
        head.push_str(&escape_html(&self.rel));
        head.push_str("\" href=\"");
        head.push_str(&escape_html(&self.href));
        head.push('"');
        if let Some(mime) = &self.mime {
            head.push_str(" type=\"");
            head.push_str(&escape_html(mime));
            head.push('"');
        }
        if let Some(sizes) = &self.sizes {
            head.push_str(" sizes=\"");
            head.push_str(&escape_html(sizes));
            head.push('"');
        }
        head.push_str(">\n");
    }
}

/// Whether a browser would read `href` as a `javascript:` or `vbscript:` URL.
///
/// The URL parser strips leading and trailing C0 controls and spaces and
/// removes every ASCII tab and newline before it reads the scheme, so
/// `" java\tscript:"` is still `javascript:`; scheme names are ASCII
/// case-insensitive.
fn is_script_url(href: &str) -> bool {
    let cleaned: String = href
        .trim_start_matches(|c: char| c <= ' ')
        .chars()
        .filter(|c| !matches!(c, '\t' | '\n' | '\r'))
        .collect();
    let Some((scheme, _)) = cleaned.split_once(':') else {
        return false;
    };
    scheme.eq_ignore_ascii_case("javascript") || scheme.eq_ignore_ascii_case("vbscript")
}

/// A [`HeadLink`] that could not be made: its code and why.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadLinkError {
    rel: String,
    href: String,
    code: &'static str,
    reason: &'static str,
}

impl HeadLinkError {
    /// The stable code, `HEAD-101` to `HEAD-104` (see [`HeadLink::new`]).
    pub fn code(&self) -> &'static str {
        self.code
    }

    /// Why the link was refused.
    pub fn reason(&self) -> &'static str {
        self.reason
    }
}

impl fmt::Display for HeadLinkError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} head link rel={:?} href={:?}: {}",
            self.code, self.rel, self.href, self.reason
        )
    }
}

impl std::error::Error for HeadLinkError {}

#[cfg(test)]
mod tests {
    use super::*;

    fn manifest_with_preloads(preloads: &[&str]) -> String {
        serde_json::json!({
            "version": 1,
            "build_hash": "h",
            "assets": {"home.js": "home.A1B2C3D4.js", "chunk.X.js": "chunk.X.js"},
            "routes": {"/": {
                "js": ["home.A1B2C3D4.js"],
                "modulepreload": preloads,
                "css": [], "fonts": [], "total_size_br": 0, "budget_warn_threshold": 0
            }}
        })
        .to_string()
    }

    #[test]
    fn a_preload_must_be_one_of_the_builds_files() {
        // Hostile and mistaken names: another origin, a traversal, markup, a
        // logical name rather than the built file, an unhashed sibling.
        for file in [
            "https://cdn.example/x.js",
            "//cdn.example/x.js",
            "../secret.js",
            "\"><script>alert(1)</script>",
            "home.js",
            "chunk.Y.js",
            "",
        ] {
            let error =
                ModulePreloads::from_manifest_json(&manifest_with_preloads(&["chunk.X.js", file]))
                    .expect_err(file);
            assert!(error.contains(&format!("{file:?}")), "{error}");
        }
        // Allowed: the build's own files, the route's own entry dropped and a
        // repeat written once.
        let table = ModulePreloads::from_manifest_json(&manifest_with_preloads(&[
            "chunk.X.js",
            "home.A1B2C3D4.js",
            "chunk.X.js",
        ]))
        .expect("loads");
        assert_eq!(table.route("/"), ["chunk.X.js"]);
        // A manifest that is not one fails as the manifest loader does.
        let error = ModulePreloads::from_manifest_json("{").expect_err("not JSON");
        assert!(
            error.starts_with("Failed to parse manifest.json"),
            "{error}"
        );
    }

    #[test]
    fn script_urls_are_recognised_as_a_browser_reads_them() {
        for href in [
            "javascript:alert(1)",
            "JavaScript:alert(1)",
            "  javascript:alert(1)",
            "\u{1}javascript:alert(1)",
            "java\tscript:alert(1)",
            "java\nscript:alert(1)",
            "jav\rascript:alert(1)",
            "vbscript:msgbox(1)",
            "VBSCRIPT:msgbox(1)",
            "javascript://host/%0aalert(1)",
        ] {
            assert!(is_script_url(href), "{href:?} is a script URL");
        }
        for href in [
            "/favicon.svg",
            "/guides/javascript.svg",
            "/javascript:not-a-scheme",
            "https://cdn.example/javascript.png",
            "data:image/svg+xml,%3Csvg%3E",
            "javascripts:x",
            "x-javascript:y",
            "favicon.ico",
        ] {
            assert!(!is_script_url(href), "{href:?} is not a script URL");
        }
    }
}