1use crate::csp::CspNonce;
2use crate::head::HeadLink;
3use crate::render::{RenderCtx, RenderError};
4use crate::types::{AssetManifest, PageConfig};
5
6struct ResolvedAssets {
8 fonts: Vec<String>,
9 css_urls: Vec<Resource>,
10 js_urls: Vec<Resource>,
11}
12
13struct Resource {
15 url: String,
16 integrity: String,
17}
18
19pub(crate) fn escape_html(s: &str) -> String {
21 s.replace('&', "&")
22 .replace('<', "<")
23 .replace('>', ">")
24 .replace('"', """)
25}
26
27fn build_head(
32 title: &str,
33 nonce: &str,
34 assets: &ResolvedAssets,
35 personality_css: Option<&str>,
36 preloads: &[Resource],
37 links: &[HeadLink],
38) -> String {
39 let mut head = String::with_capacity(2048);
40 head.push_str("<meta charset=\"utf-8\">\n");
41 head.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
42 head.push_str(&format!("<title>{}</title>\n", escape_html(title)));
43
44 for font in &assets.fonts {
46 head.push_str(&format!(
47 "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
48 escape_html(font)
49 ));
50 }
51
52 for css in &assets.css_urls {
54 head.push_str(&format!(
55 "<link rel=\"stylesheet\" href=\"{}\" integrity=\"{}\" crossorigin=\"anonymous\">\n",
56 escape_html(&css.url),
57 escape_html(&css.integrity)
58 ));
59 }
60
61 for js in &assets.js_urls {
67 head.push_str(&format!(
68 "<link rel=\"modulepreload\" nonce=\"{}\" href=\"{}\" integrity=\"{}\" crossorigin=\"anonymous\">\n",
69 escape_html(nonce),
70 escape_html(&js.url),
71 escape_html(&js.integrity)
72 ));
73 }
74
75 for file in preloads {
80 head.push_str(&format!(
81 "<link rel=\"modulepreload\" nonce=\"{}\" href=\"{}\" integrity=\"{}\" crossorigin=\"anonymous\">\n",
82 escape_html(nonce),
83 escape_html(&file.url),
84 escape_html(&file.integrity)
85 ));
86 }
87
88 for link in links {
90 link.write(&mut head);
91 }
92
93 if let Some(css) = personality_css {
111 let safe_css = css.replace("</", "<\\/");
112 head.push_str(&format!("<style nonce=\"{nonce}\">{safe_css}</style>\n"));
113 }
114
115 head
116}
117
118fn resource(manifest: &AssetManifest, file: &str, route: &str) -> Result<Resource, RenderError> {
120 let metadata = manifest
121 .assets
122 .get(file)
123 .filter(|meta| crate::types::valid_integrity(&meta.integrity))
124 .ok_or_else(|| RenderError::Manifest {
125 route: route.into(),
126 message: format!(
127 "resource {file:?} requires canonical SHA-384 integrity in this manifest"
128 ),
129 })?;
130 Ok(Resource {
131 url: format!("/_assets/{file}"),
132 integrity: metadata.integrity.clone(),
133 })
134}
135
136fn resolve_assets(
138 manifest: &AssetManifest,
139 route_pattern: &str,
140) -> Result<ResolvedAssets, RenderError> {
141 let route = manifest.route(route_pattern);
142 let resolve = |files: &[String]| {
143 files
144 .iter()
145 .map(|file| resource(manifest, file, route_pattern))
146 .collect::<Result<Vec<_>, _>>()
147 };
148 Ok(ResolvedAssets {
149 fonts: route
150 .map(|r| r.fonts.iter().map(|f| format!("/_assets/{f}")).collect())
151 .unwrap_or_default(),
152 css_urls: resolve(route.map_or(&[], |r| &r.css))?,
153 js_urls: resolve(route.map_or(&[], |r| &r.js))?,
154 })
155}
156
157struct BodyParts {
159 body_class_attr: String,
160 body_prefix: String,
161 config_json_tag: String,
162 page_js_tag: String,
163 sw_script: String,
164}
165
166fn build_body_parts(nonce: &str, config: &PageConfig, js_urls: &[Resource]) -> BodyParts {
167 let body_class_attr = config
168 .body_class
169 .map(|c| format!(" class=\"{}\"", escape_html(c)))
170 .unwrap_or_default();
171 let body_prefix = config
172 .body_prefix
173 .map_or("", crate::RawHtml::as_str)
174 .to_string();
175
176 let config_json_tag = config
181 .config_json
182 .map(|value| {
183 let json = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
184 format!(
185 "<script nonce=\"{nonce}\">window.__FORMA_CONFIG__={};</script>\n",
186 json.replace('<', "\\u003c")
187 )
188 })
189 .unwrap_or_default();
190
191 let page_js_tag = js_urls
192 .last()
193 .map(|url| {
194 format!(
195 "<script type=\"module\" nonce=\"{nonce}\" src=\"{}\" integrity=\"{}\" crossorigin=\"anonymous\"></script>",
196 escape_html(&url.url),
197 escape_html(&url.integrity)
198 )
199 })
200 .unwrap_or_default();
201
202 let sw_script = crate::sw::registration_script(nonce);
206
207 BodyParts {
208 body_class_attr,
209 body_prefix,
210 config_json_tag,
211 page_js_tag,
212 sw_script,
213 }
214}
215
216pub(crate) fn write_document(
219 config: &PageConfig,
220 nonce: &CspNonce,
221 ssr_body: Option<&str>,
222 ctx: &RenderCtx,
223) -> Result<String, RenderError> {
224 let nonce_attr = escape_html(nonce.as_str());
227 let assets = resolve_assets(config.manifest, config.route_pattern)?;
228 let mut preloads = Vec::new();
229 let mut seen = std::collections::HashSet::new();
230 for file in ctx.module_preloads(config.route_pattern) {
231 let url = format!("/_assets/{file}");
232 if assets.js_urls.iter().any(|asset| asset.url == url) || !seen.insert(file) {
233 continue;
234 }
235 preloads.push(resource(config.manifest, file, config.route_pattern)?);
236 }
237 let head = build_head(
238 config.title,
239 &nonce_attr,
240 &assets,
241 config.personality_css,
242 &preloads,
243 ctx.head_links(),
244 );
245 let parts = build_body_parts(&nonce_attr, config, &assets.js_urls);
246
247 let ssr_len = ssr_body.map_or(0, str::len);
250 let mut html = String::with_capacity(head.len() + ssr_len + 1024);
251 html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
252 html.push_str(&head);
253 html.push_str("</head>\n<body");
254 html.push_str(&parts.body_class_attr);
255 html.push_str(">\n");
256 html.push_str(&parts.body_prefix);
257 match ssr_body {
258 Some(ssr) => {
259 html.push_str("<div id=\"app\" data-forma-ssr>");
260 html.push_str(ssr);
261 html.push_str("</div>");
262 }
263 None => html.push_str("<div id=\"app\"></div>"),
264 }
265 html.push('\n');
266 html.push_str(&parts.config_json_tag);
267 html.push_str(&parts.page_js_tag);
268 html.push('\n');
269 html.push_str(&parts.sw_script);
270 html.push_str("</body>\n</html>");
271 Ok(html)
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::render_contract::{RenderFixture as PageConfig, RenderPath as RenderMode};
278 use crate::types::RouteAssets;
279 use forma_ir::parser::IrModule;
280 use forma_ir::slot::{SlotData, SlotValue};
281 use std::collections::HashMap;
282
283 fn render_parts(config: &PageConfig) -> crate::PageParts {
284 let ctx = RenderCtx::new();
285 match config.render_mode {
286 RenderMode::Phase1ClientMount => crate::render_contract::render_shell(config, &ctx),
287 RenderMode::Phase2SsrReconcile => crate::render_contract::render_ssr(config, &ctx),
288 }
289 .expect("valid fixture renders")
290 .into_parts_unchecked()
291 }
292 fn policy(page: &crate::PageParts) -> &str {
293 page.headers
294 .iter()
295 .find(|(name, _)| name == "content-security-policy")
296 .unwrap()
297 .1
298 .to_str()
299 .unwrap()
300 }
301
302 fn empty_manifest() -> AssetManifest {
304 AssetManifest {
305 version: 2,
306 writer: "3.0.0".into(),
307 app: "ledger".into(),
308 aliases: HashMap::new(),
309 build: "test".to_string(),
310 assets: HashMap::new(),
311 routes: HashMap::new(),
312 }
313 }
314
315 #[test]
316 fn a_legacy_manifest_is_refused_before_removed_wasm_fields_can_be_used() {
317 let error = serde_json::from_value::<AssetManifest>(serde_json::json!({
318 "version":1,"build_hash":"legacy","assets":{},"routes":{},
319 "wasm":{"loader":"legacy-loader.js","binary":"legacy.wasm"}
320 }))
321 .unwrap_err();
322 assert!(error.to_string().contains("MANIFEST-101"));
323 }
324
325 fn hello_ir_module() -> IrModule {
327 use forma_ir::parser::test_helpers::{
328 build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
329 };
330
331 let mut opcodes = Vec::new();
332 opcodes.extend_from_slice(&encode_open_tag(0, &[])); opcodes.extend_from_slice(&encode_text(1)); opcodes.extend_from_slice(&encode_close_tag(0)); let data = build_minimal_ir(&["p", "hello"], &[], &opcodes, &[]);
337 IrModule::parse(&data).unwrap()
338 }
339
340 #[test]
341 fn phase1_renders_empty_app_div() {
342 let manifest = empty_manifest();
343 let page = render_parts(&PageConfig {
344 title: "Test",
345 route_pattern: "/test",
346 manifest: &manifest,
347 config_json: None,
348 body_class: None,
349 personality_css: None,
350 body_prefix: None,
351 render_mode: RenderMode::Phase1ClientMount,
352 ir_module: None,
353 slots: None,
354 });
355
356 assert!(page.html.contains("<div id=\"app\"></div>"));
357 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
359 }
360
361 #[test]
362 fn phase2_renders_ssr_content_with_data_attr() {
363 let manifest = empty_manifest();
364 let ir = hello_ir_module();
365 let slots = SlotData::new(0);
366
367 let page = render_parts(&PageConfig {
368 title: "SSR Test",
369 route_pattern: "/test",
370 manifest: &manifest,
371 config_json: None,
372 body_class: None,
373 personality_css: None,
374 body_prefix: None,
375 render_mode: RenderMode::Phase2SsrReconcile,
376 ir_module: Some(&ir),
377 slots: Some(&slots),
378 });
379
380 assert!(page.html.contains("data-forma-ssr"));
381 assert!(page.html.contains("<p>hello</p>"));
382 assert!(page.html.contains("<div id=\"app\" data-forma-ssr>"));
383 }
384
385 #[test]
386 fn phase2_ssr_preserves_head_content() {
387 let manifest = empty_manifest();
388 let ir = hello_ir_module();
389 let slots = SlotData::new(0);
390
391 let page = render_parts(&PageConfig {
392 title: "Head Test",
393 route_pattern: "/test",
394 manifest: &manifest,
395 config_json: Some(&serde_json::json!({"test":true})),
396 body_class: Some("dark"),
397 personality_css: Some(":root{--c:red}"),
398 body_prefix: Some(&crate::RawHtml::trusted("<nav>nav</nav>")),
399 render_mode: RenderMode::Phase2SsrReconcile,
400 ir_module: Some(&ir),
401 slots: Some(&slots),
402 });
403
404 assert!(page.html.contains("<title>Head Test</title>"));
405 assert!(page.html.contains("window.__FORMA_CONFIG__="));
406 assert!(page.html.contains("class=\"dark\""));
407 assert!(page.html.contains(":root{--c:red}"));
408 assert!(page.html.contains("<nav>nav</nav>"));
409 assert!(page.html.contains("<p>hello</p>"));
410 assert!(page.html.contains("data-forma-ssr"));
411 }
412
413 #[test]
414 fn phase2_with_slot_data() {
415 use forma_ir::parser::test_helpers::{build_minimal_ir, encode_close_tag, encode_open_tag};
416
417 let mut opcodes = Vec::new();
419 opcodes.extend_from_slice(&encode_open_tag(0, &[])); opcodes.push(0x05);
422 opcodes.extend_from_slice(&0u16.to_le_bytes()); opcodes.extend_from_slice(&0u16.to_le_bytes()); opcodes.extend_from_slice(&encode_close_tag(0)); let data = build_minimal_ir(
427 &["span", "name"], &[(0, 1, 0x01, 0x00, &[])], &opcodes,
430 &[],
431 );
432 let ir = IrModule::parse(&data).unwrap();
433
434 let mut slots = SlotData::new(1);
435 slots.set(0, SlotValue::Text("World".to_string()));
436
437 let manifest = empty_manifest();
438 let page = render_parts(&PageConfig {
439 title: "Slot Test",
440 route_pattern: "/test",
441 manifest: &manifest,
442 config_json: None,
443 body_class: None,
444 personality_css: None,
445 body_prefix: None,
446 render_mode: RenderMode::Phase2SsrReconcile,
447 ir_module: Some(&ir),
448 slots: Some(&slots),
449 });
450
451 assert!(page.html.contains("data-forma-ssr"));
452 assert!(page
454 .html
455 .contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
456 }
457
458 #[test]
461 fn escape_html_basic_entities() {
462 assert_eq!(escape_html("<script>"), "<script>");
463 assert_eq!(escape_html("\"quotes\""), ""quotes"");
464 assert_eq!(escape_html("a&b"), "a&b");
465 assert_eq!(escape_html("clean"), "clean");
466 }
467
468 #[test]
469 fn asset_filename_xss_is_escaped() {
470 let mut routes = HashMap::new();
473 routes.insert(
474 "/test".to_string(),
475 RouteAssets {
476 mode: crate::RouteMode::Browser,
477 modulepreload: Vec::new(),
478 js: vec!["\"><script>alert(1)</script>".to_string()],
479 css: vec!["\"><script>alert(2)</script>".to_string()],
480 fonts: vec!["\"><script>alert(3)</script>".to_string()],
481 total_size_br: 0,
482 budget_warn_threshold: 204800,
483 ir: None,
484 },
485 );
486 let manifest = AssetManifest {
487 version: 2,
488 writer: "3.0.0".into(),
489 app: "ledger".into(),
490 aliases: HashMap::new(),
491 build: "test".to_string(),
492 assets: crate::render_contract::fixture_assets(&routes),
493 routes,
494 };
495
496 let page = render_parts(&PageConfig {
497 title: "XSS Test",
498 route_pattern: "/test",
499 manifest: &manifest,
500 config_json: None,
501 body_class: None,
502 personality_css: None,
503 body_prefix: None,
504 render_mode: RenderMode::Phase1ClientMount,
505 ir_module: None,
506 slots: None,
507 });
508
509 assert!(
511 !page.html.contains("<script>alert("),
512 "asset filenames must be HTML-escaped, got: {}",
513 page.html
514 );
515 assert!(
516 page.html.contains("<script>"),
517 "escaped <script> should be present"
518 );
519 }
520
521 #[test]
522 fn body_class_with_quotes_is_escaped() {
523 let manifest = empty_manifest();
524 let page = render_parts(&PageConfig {
525 title: "Body Class Test",
526 route_pattern: "/test",
527 manifest: &manifest,
528 config_json: None,
529 body_class: Some("dark\" onload=\"alert(1)"),
530 personality_css: None,
531 body_prefix: None,
532 render_mode: RenderMode::Phase1ClientMount,
533 ir_module: None,
534 slots: None,
535 });
536
537 assert!(
542 page.html.contains("""),
543 "body_class double quote must be escaped"
544 );
545 assert!(
546 !page.html.contains(r#"class="dark" onload="#),
547 "body_class must not allow attribute breakout via unescaped double quote"
548 );
549 }
550
551 #[test]
552 fn personality_css_style_breakout_prevented() {
553 for css in [
558 "body{color:red}</style><script>alert(1)</script>",
559 "body{color:red}</StYlE><script>alert(1)</script>",
560 "body{color:red}</style ><script>alert(1)</script>",
561 "body{color:red}</style\t><script>alert(1)</script>",
562 "body{color:red}</style/><script>alert(1)</script>",
563 "body{color:red}</style\n><script>alert(1)</script>",
564 "body{color:red}</style\x0c><script>alert(1)</script>",
565 "body{color:red}<</style><script>alert(1)</script>",
566 "body{color:red}</sty</style><script>alert(1)</script>",
567 ] {
568 let manifest = empty_manifest();
569 let page = render_parts(&PageConfig {
570 title: "CSS Test",
571 route_pattern: "/test",
572 manifest: &manifest,
573 config_json: None,
574 body_class: None,
575 personality_css: Some(css),
576 body_prefix: None,
577 render_mode: RenderMode::Phase1ClientMount,
578 ir_module: None,
579 slots: None,
580 });
581
582 let start = page.html.find("<style").expect("style element");
587 let body_start = page.html[start..].find('>').expect("style open") + start + 1;
588 let end = page.html[body_start..]
589 .find("</style>")
590 .expect("style close")
591 + body_start;
592 let body = &page.html[body_start..end];
593 assert!(
594 !body.contains("</"),
595 "style body must contain no `</` for input {css:?}, got: {body}"
596 );
597 }
598 }
599
600 #[test]
601 fn personality_css_round_trips_legitimate_values() {
602 let manifest = empty_manifest();
607 let css = "@media (width < 600px){#t{color:rgb(1,2,3)}}\
608 .a::after{content:\"</div>\"}\
609 .b{font:12px/1.4 sans-serif;aspect-ratio:16/9}";
610 let page = render_parts(&PageConfig {
611 title: "CSS Test",
612 route_pattern: "/test",
613 manifest: &manifest,
614 config_json: None,
615 body_class: None,
616 personality_css: Some(css),
617 body_prefix: None,
618 render_mode: RenderMode::Phase1ClientMount,
619 ir_module: None,
620 slots: None,
621 });
622
623 assert!(page.html.contains("@media (width < 600px)"));
625 assert!(page.html.contains("font:12px/1.4 sans-serif"));
626 assert!(page.html.contains("aspect-ratio:16/9"));
627 assert!(page.html.contains("content:\"<\\/div>\""));
630 }
631
632 #[test]
633 fn config_json_emits_no_raw_angle_brackets() {
634 let manifest = empty_manifest();
635 let value = serde_json::json!({
636 "user": "</script><img src=x onerror=alert(1)>",
637 "note": "<!--<script>",
638 });
639 let page = render_parts(&PageConfig {
640 title: "Config Test",
641 route_pattern: "/test",
642 manifest: &manifest,
643 config_json: Some(&value),
644 body_class: None,
645 personality_css: None,
646 body_prefix: None,
647 render_mode: RenderMode::Phase1ClientMount,
648 ir_module: None,
649 slots: None,
650 });
651
652 let start = page
653 .html
654 .find("window.__FORMA_CONFIG__=")
655 .expect("config json script")
656 + "window.__FORMA_CONFIG__=".len();
657 let end = page.html[start..].find("</script>").expect("close") + start;
658 let json = &page.html[start..end];
659 assert!(
660 !json.contains('<'),
661 "config_json output must contain no raw '<', got: {json}"
662 );
663 let parsed: serde_json::Value = serde_json::from_str(json.trim_end_matches(';')).unwrap();
665 assert_eq!(parsed, value);
666 }
667
668 #[test]
671 fn named_parts_expose_the_nonce_bound_to_the_document() {
672 let manifest = empty_manifest();
673 let page = render_parts(&PageConfig {
674 title: "Nonce Test",
675 route_pattern: "/test",
676 manifest: &manifest,
677 config_json: Some(&serde_json::json!({"x":1})),
678 body_class: None,
679 personality_css: Some("body{color:red}"),
680 body_prefix: None,
681 render_mode: RenderMode::Phase1ClientMount,
682 ir_module: None,
683 slots: None,
684 });
685
686 assert!(policy(&page).contains(&format!("'nonce-{}'", page.nonce)));
688 assert!(page
689 .html
690 .contains(&format!("<script nonce=\"{}\">", page.nonce)));
691 assert!(page
692 .html
693 .contains(&format!("<style nonce=\"{}\">", page.nonce)));
694 }
695
696 #[test]
697 fn page_response_headers_are_no_store() {
698 let manifest = empty_manifest();
700 let page = render_parts(&PageConfig {
701 title: "Header Test",
702 route_pattern: "/test",
703 manifest: &manifest,
704 config_json: None,
705 body_class: None,
706 personality_css: None,
707 body_prefix: None,
708 render_mode: RenderMode::Phase1ClientMount,
709 ir_module: None,
710 slots: None,
711 });
712
713 let headers = page.headers;
714 let get = |name: &str| {
715 headers
716 .iter()
717 .find(|(n, _)| n.as_str() == name)
718 .map(|(_, v)| v.to_str().unwrap().to_string())
719 };
720 assert_eq!(get("cache-control").as_deref(), Some("no-store"));
721 assert_eq!(get("x-content-type-options").as_deref(), Some("nosniff"));
722 assert!(get("content-security-policy").is_some());
723 assert!(get("referrer-policy").is_some());
724 }
725
726 #[test]
729 fn style_attributes_rendered_by_the_walker_are_permitted_by_the_csp() {
730 let mut opcodes = forma_ir::parser::test_helpers::encode_open_tag(0, &[]);
734 opcodes.push(0x06); opcodes.extend_from_slice(&1u32.to_le_bytes()); opcodes.extend_from_slice(&0u16.to_le_bytes()); let data = forma_ir::parser::test_helpers::build_minimal_ir(
738 &["div", "style", "css"],
739 &[(0, 2, 0x01, 0x00, &[])],
740 &opcodes,
741 &[],
742 );
743 let ir = IrModule::parse(&data).unwrap();
744 let mut slots = SlotData::new(1);
745 slots.set(0, SlotValue::Text("color:red".into()));
746
747 let manifest = empty_manifest();
748 let page = render_parts(&PageConfig {
749 title: "Style Attr",
750 route_pattern: "/test",
751 manifest: &manifest,
752 config_json: None,
753 body_class: None,
754 personality_css: None,
755 body_prefix: None,
756 render_mode: RenderMode::Phase2SsrReconcile,
757 ir_module: Some(&ir),
758 slots: Some(&slots),
759 });
760
761 assert!(
762 page.html.contains("style=\"color:red\""),
763 "walker must emit the style attribute: {}",
764 page.html
765 );
766 assert!(
767 policy(&page).contains("style-src-attr 'unsafe-inline'"),
768 "CSP must permit the attribute style the walker just emitted: {}",
769 policy(&page)
770 );
771 }
772}