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