Skip to main content

forma_server/
head.rs

1//! Typed homes for what applications put in `body_prefix` that is not their
2//! own markup: the route's `modulepreload` links, read from the
3//! build's manifest and nonced here, and the page's head links.
4//!
5//! The third, data, already has its home: [`PageConfig::config_json`]
6//! (`window.__FORMA_CONFIG__`, every `<` escaped), and in 3.0.0 the state
7//! block.
8//!
9//! Each reaches the page through [`RenderCtx`](crate::RenderCtx), so only
10//! [`try_render`](crate::try_render) and
11//! [`render_browser_shell`](crate::render_browser_shell) write them; the
12//! deprecated `render_page` writes the document 0.2.1 wrote.
13//!
14//! [`PageConfig::config_json`]: crate::PageConfig::config_json
15
16use serde::Deserialize;
17use std::collections::HashMap;
18use std::fmt;
19use std::sync::Arc;
20
21/// Each route's static ESM dependencies, as the build's manifest lists them
22/// in `modulepreload` (`@getforma/build` with `codeSplitting`).
23///
24/// forma-server writes one `<link rel="modulepreload">` per file into the
25/// route's head, after the route's own modules, with the page's nonce: under
26/// `'strict-dynamic'` a preload without it is dropped. Load the table once at
27/// start with [`assets::try_load_module_preloads`](crate::assets::try_load_module_preloads)
28/// and hand it to each render through
29/// [`RenderCtx::with_module_preloads`](crate::RenderCtx::with_module_preloads);
30/// cloning it is cheap. Dynamically imported chunks are not in the list: the
31/// build leaves them out, so they load when the page asks for them.
32///
33/// The table is the one from the manifest at start. In development, a chunk
34/// the watcher renames stays listed until the server restarts; its preload
35/// fails harmlessly, as a hint, and the module itself still loads.
36///
37/// Verified by: crates/forma-server/tests/typed_homes.rs > "try_render_writes_the_routes_manifest_modulepreload_links_nonced"
38#[derive(Debug, Clone)]
39pub struct ModulePreloads {
40    routes: Arc<HashMap<String, Vec<String>>>,
41}
42
43/// The two manifest fields the table is read from.
44#[derive(Deserialize)]
45struct Manifest {
46    assets: HashMap<String, String>,
47    routes: HashMap<String, Route>,
48}
49
50#[derive(Deserialize)]
51struct Route {
52    #[serde(default)]
53    js: Vec<String>,
54    #[serde(default)]
55    modulepreload: Vec<String>,
56}
57
58impl ModulePreloads {
59    /// Read the table from `manifest.json`'s text.
60    ///
61    /// Each listed file must be one of the build's own files, a value in the
62    /// manifest's `assets`, or the table is refused: a preload carries the
63    /// page's nonce, so it may name only what the build wrote. A route's own
64    /// modules (`js`), which forma-server already preloads, and repeats are
65    /// left out.
66    ///
67    /// Verified by: crates/forma-server/tests/typed_homes.rs > "a_manifest_preload_that_is_not_one_of_the_builds_files_refuses_the_table"
68    pub(crate) fn from_manifest_json(json: &str) -> Result<Self, String> {
69        let manifest: Manifest = serde_json::from_str(json)
70            .map_err(|e| format!("Failed to parse manifest.json: {e}"))?;
71        let built: std::collections::HashSet<&str> =
72            manifest.assets.values().map(String::as_str).collect();
73        let mut routes = HashMap::new();
74        for (route, assets) in &manifest.routes {
75            let mut files: Vec<String> = Vec::new();
76            for file in &assets.modulepreload {
77                if !built.contains(file.as_str()) {
78                    return Err(format!(
79                        "manifest.json: route {route} lists modulepreload {file:?}, which is not \
80                         one of the build's files (the manifest's assets)"
81                    ));
82                }
83                if !assets.js.contains(file) && !files.contains(file) {
84                    files.push(file.clone());
85                }
86            }
87            if !files.is_empty() {
88                routes.insert(route.clone(), files);
89            }
90        }
91        Ok(ModulePreloads {
92            routes: Arc::new(routes),
93        })
94    }
95
96    /// The files a route preloads, in the manifest's order; empty for a route
97    /// the manifest lists none for.
98    pub fn route(&self, route_pattern: &str) -> &[String] {
99        self.routes.get(route_pattern).map_or(&[], Vec::as_slice)
100    }
101}
102
103/// A `<link>` for the page's head that is not an asset of the build: an icon,
104/// a canonical URL, a feed. The typed home for what ksx Studio
105/// splices before `</head>`.
106///
107/// Its `rel` is one or more of a closed list of link types, none of which
108/// loads style or script:
109///
110/// | Link types | Why they are kept |
111/// |---|---|
112/// | `icon`, `apple-touch-icon`, `mask-icon` | An image the browser shows for the page, fetched under the policy's `img-src` |
113/// | `manifest` | The web app manifest; see below |
114/// | `canonical`, `alternate`, `author`, `license` | Metadata: the browser fetches and runs nothing |
115/// | `preconnect`, `dns-prefetch` | A connection hint to an origin the page will use: nothing is fetched or run |
116///
117/// Everything else is refused, with a code: `stylesheet`, `modulepreload`,
118/// `preload`, `prefetch`, `prerender` and `import` load style or script, and
119/// a page's stylesheets and module preloads come from the build's manifest
120/// (`HEAD-101`); any other word, `shortcut` included (write `icon`), is not on
121/// the list (`HEAD-102`). Link types are matched without regard to case and
122/// written in lower case. Every value is escaped for its attribute, and a
123/// script URL is refused, so a head link is never markup and never runs
124/// anything. Add it to a render with
125/// [`RenderCtx::with_head_link`](crate::RenderCtx::with_head_link); it is
126/// written after the route's stylesheets and preloads, in the order added.
127///
128/// **`manifest` needs a policy the default does not give.** The browser
129/// fetches a web app manifest under `manifest-src`, which falls back to the
130/// strict policy's `default-src 'none'`, so the default policy blocks it.
131/// [`PolicyExtensions`](crate::PolicyExtensions) has no manifest source
132/// (it names image and frame sources, a form target and the opener
133/// policy), so a page that links a manifest today sees it refused,
134/// with a policy violation in the console.
135///
136/// ```
137/// use forma_server::{HeadLink, RenderCtx};
138///
139/// let ctx = RenderCtx::new()
140///     .with_head_link(HeadLink::new("icon", "/favicon.svg")?.with_type("image/svg+xml"))
141///     .with_head_link(HeadLink::new("apple-touch-icon", "/apple-touch-icon.png")?);
142/// # let _ = ctx;
143/// # Ok::<(), forma_server::HeadLinkError>(())
144/// ```
145///
146/// Verified by: crates/forma-server/tests/typed_homes.rs > "head_links_are_written_escaped_in_the_head_in_the_order_added"
147/// Verified by: crates/forma-server/tests/typed_homes.rs > "a_head_link_carries_only_the_listed_link_types"
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct HeadLink {
150    rel: String,
151    href: String,
152    mime: Option<String>,
153    sizes: Option<String>,
154}
155
156/// The link types a head link may carry (see [`HeadLink`]).
157const LINK_TYPES: &[&str] = &[
158    "icon",
159    "apple-touch-icon",
160    "mask-icon",
161    "manifest",
162    "canonical",
163    "alternate",
164    "author",
165    "license",
166    "preconnect",
167    "dns-prefetch",
168];
169
170/// The link types that load style or script, refused as `HEAD-101`.
171const LOADING_TYPES: &[&str] = &[
172    "stylesheet",
173    "modulepreload",
174    "preload",
175    "prefetch",
176    "prerender",
177    "import",
178];
179
180impl HeadLink {
181    /// A link of type `rel` (one or more of [`HeadLink`]'s link types,
182    /// separated by whitespace) to `href`.
183    ///
184    /// # Errors
185    ///
186    /// A [`HeadLinkError`] with its [`code`](HeadLinkError::code):
187    ///
188    /// | Code | Cause |
189    /// |---|---|
190    /// | HEAD-101 | `rel` names a link type that loads style or script |
191    /// | HEAD-102 | `rel` names no link type, or one outside the list |
192    /// | HEAD-103 | `href` is a `javascript:` or `vbscript:` URL, in any case and with any tab, newline or leading control character a browser strips |
193    /// | HEAD-104 | `href` is empty |
194    ///
195    /// Verified by: crates/forma-server/tests/typed_homes.rs > "a_head_link_carries_only_the_listed_link_types"
196    /// Verified by: crates/forma-server/tests/typed_homes.rs > "a_head_link_refuses_a_script_url_and_keeps_every_other"
197    pub fn new(rel: &str, href: &str) -> Result<HeadLink, HeadLinkError> {
198        let refuse = |code, reason| {
199            Err(HeadLinkError {
200                rel: rel.to_string(),
201                href: href.to_string(),
202                code,
203                reason,
204            })
205        };
206        let types: Vec<String> = rel
207            .split_ascii_whitespace()
208            .map(str::to_ascii_lowercase)
209            .collect();
210        if types.iter().any(|t| LOADING_TYPES.contains(&t.as_str())) {
211            return refuse(
212                "HEAD-101",
213                "rel names a link type that loads style or script; a page's stylesheets and \
214                 module preloads come from the build's manifest",
215            );
216        }
217        if types.is_empty() || !types.iter().all(|t| LINK_TYPES.contains(&t.as_str())) {
218            return refuse(
219                "HEAD-102",
220                "rel is not one of the link types a head link carries: icon, apple-touch-icon, \
221                 mask-icon, manifest, canonical, alternate, author, license, preconnect, \
222                 dns-prefetch",
223            );
224        }
225        if is_script_url(href) {
226            return refuse("HEAD-103", "href is a script URL");
227        }
228        if href.trim().is_empty() {
229            return refuse("HEAD-104", "href is empty");
230        }
231        Ok(HeadLink {
232            rel: types.join(" "),
233            href: href.to_string(),
234            mime: None,
235            sizes: None,
236        })
237    }
238
239    /// Add a `type` attribute, such as `image/svg+xml`.
240    #[must_use]
241    pub fn with_type(mut self, mime: &str) -> Self {
242        self.mime = Some(mime.to_string());
243        self
244    }
245
246    /// Add a `sizes` attribute, such as `180x180`.
247    #[must_use]
248    pub fn with_sizes(mut self, sizes: &str) -> Self {
249        self.sizes = Some(sizes.to_string());
250        self
251    }
252
253    /// The element, every value escaped for its attribute.
254    pub(crate) fn write(&self, head: &mut String) {
255        use crate::template::escape_html;
256        head.push_str("<link rel=\"");
257        head.push_str(&escape_html(&self.rel));
258        head.push_str("\" href=\"");
259        head.push_str(&escape_html(&self.href));
260        head.push('"');
261        if let Some(mime) = &self.mime {
262            head.push_str(" type=\"");
263            head.push_str(&escape_html(mime));
264            head.push('"');
265        }
266        if let Some(sizes) = &self.sizes {
267            head.push_str(" sizes=\"");
268            head.push_str(&escape_html(sizes));
269            head.push('"');
270        }
271        head.push_str(">\n");
272    }
273}
274
275/// Whether a browser would read `href` as a `javascript:` or `vbscript:` URL.
276///
277/// The URL parser strips leading and trailing C0 controls and spaces and
278/// removes every ASCII tab and newline before it reads the scheme, so
279/// `" java\tscript:"` is still `javascript:`; scheme names are ASCII
280/// case-insensitive.
281fn is_script_url(href: &str) -> bool {
282    let cleaned: String = href
283        .trim_start_matches(|c: char| c <= ' ')
284        .chars()
285        .filter(|c| !matches!(c, '\t' | '\n' | '\r'))
286        .collect();
287    let Some((scheme, _)) = cleaned.split_once(':') else {
288        return false;
289    };
290    scheme.eq_ignore_ascii_case("javascript") || scheme.eq_ignore_ascii_case("vbscript")
291}
292
293/// A [`HeadLink`] that could not be made: its code and why.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct HeadLinkError {
296    rel: String,
297    href: String,
298    code: &'static str,
299    reason: &'static str,
300}
301
302impl HeadLinkError {
303    /// The stable code, `HEAD-101` to `HEAD-104` (see [`HeadLink::new`]).
304    pub fn code(&self) -> &'static str {
305        self.code
306    }
307
308    /// Why the link was refused.
309    pub fn reason(&self) -> &'static str {
310        self.reason
311    }
312}
313
314impl fmt::Display for HeadLinkError {
315    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
316        write!(
317            f,
318            "{} head link rel={:?} href={:?}: {}",
319            self.code, self.rel, self.href, self.reason
320        )
321    }
322}
323
324impl std::error::Error for HeadLinkError {}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    fn manifest_with_preloads(preloads: &[&str]) -> String {
331        serde_json::json!({
332            "version": 1,
333            "build_hash": "h",
334            "assets": {"home.js": "home.A1B2C3D4.js", "chunk.X.js": "chunk.X.js"},
335            "routes": {"/": {
336                "js": ["home.A1B2C3D4.js"],
337                "modulepreload": preloads,
338                "css": [], "fonts": [], "total_size_br": 0, "budget_warn_threshold": 0
339            }}
340        })
341        .to_string()
342    }
343
344    #[test]
345    fn a_preload_must_be_one_of_the_builds_files() {
346        // Hostile and mistaken names: another origin, a traversal, markup, a
347        // logical name rather than the built file, an unhashed sibling.
348        for file in [
349            "https://cdn.example/x.js",
350            "//cdn.example/x.js",
351            "../secret.js",
352            "\"><script>alert(1)</script>",
353            "home.js",
354            "chunk.Y.js",
355            "",
356        ] {
357            let error =
358                ModulePreloads::from_manifest_json(&manifest_with_preloads(&["chunk.X.js", file]))
359                    .expect_err(file);
360            assert!(error.contains(&format!("{file:?}")), "{error}");
361        }
362        // Allowed: the build's own files, the route's own entry dropped and a
363        // repeat written once.
364        let table = ModulePreloads::from_manifest_json(&manifest_with_preloads(&[
365            "chunk.X.js",
366            "home.A1B2C3D4.js",
367            "chunk.X.js",
368        ]))
369        .expect("loads");
370        assert_eq!(table.route("/"), ["chunk.X.js"]);
371        // A manifest that is not one fails as the manifest loader does.
372        let error = ModulePreloads::from_manifest_json("{").expect_err("not JSON");
373        assert!(
374            error.starts_with("Failed to parse manifest.json"),
375            "{error}"
376        );
377    }
378
379    #[test]
380    fn script_urls_are_recognised_as_a_browser_reads_them() {
381        for href in [
382            "javascript:alert(1)",
383            "JavaScript:alert(1)",
384            "  javascript:alert(1)",
385            "\u{1}javascript:alert(1)",
386            "java\tscript:alert(1)",
387            "java\nscript:alert(1)",
388            "jav\rascript:alert(1)",
389            "vbscript:msgbox(1)",
390            "VBSCRIPT:msgbox(1)",
391            "javascript://host/%0aalert(1)",
392        ] {
393            assert!(is_script_url(href), "{href:?} is a script URL");
394        }
395        for href in [
396            "/favicon.svg",
397            "/guides/javascript.svg",
398            "/javascript:not-a-scheme",
399            "https://cdn.example/javascript.png",
400            "data:image/svg+xml,%3Csvg%3E",
401            "javascripts:x",
402            "x-javascript:y",
403            "favicon.ico",
404        ] {
405            assert!(!is_script_url(href), "{href:?} is not a script URL");
406        }
407    }
408}