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