1use crate::csp::{self, CspNonce};
2use crate::head::HeadLink;
3use crate::render::RenderCtx;
4use crate::response::RenderCounts;
5use crate::types::{AssetManifest, PageConfig, PageOutput, RenderMode};
6use forma_ir as ir;
7use std::time::Instant;
8
9struct ResolvedAssets {
11 fonts: Vec<String>,
12 css_urls: Vec<String>,
13 js_urls: Vec<String>,
14}
15
16pub(crate) fn escape_html(s: &str) -> String {
18 s.replace('&', "&")
19 .replace('<', "<")
20 .replace('>', ">")
21 .replace('"', """)
22}
23
24fn neutralize_script_terminators(s: &str) -> String {
41 let lower = s.to_ascii_lowercase();
42 let mut out = String::with_capacity(s.len() + 8);
43 let mut i = 0;
44 while i < s.len() {
45 if lower[i..].starts_with("</script") {
46 out.push_str("<\\/script");
47 i += "</script".len();
48 } else if lower[i..].starts_with("<!--") {
49 out.push_str("<\\!--");
50 i += "<!--".len();
51 } else {
52 let Some(ch) = s[i..].chars().next() else {
55 break;
56 };
57 out.push(ch);
58 i += ch.len_utf8();
59 }
60 }
61 out
62}
63
64fn build_head(
70 title: &str,
71 nonce: &str,
72 assets: &ResolvedAssets,
73 personality_css: Option<&str>,
74 preloads: &[String],
75 links: &[HeadLink],
76) -> String {
77 let mut head = String::with_capacity(2048);
78 head.push_str("<meta charset=\"utf-8\">\n");
79 head.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
80 head.push_str(&format!("<title>{}</title>\n", escape_html(title)));
81
82 for font in &assets.fonts {
84 head.push_str(&format!(
85 "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
86 escape_html(font)
87 ));
88 }
89
90 for css in &assets.css_urls {
92 head.push_str(&format!(
93 "<link rel=\"stylesheet\" href=\"{}\">\n",
94 escape_html(css)
95 ));
96 }
97
98 for js in &assets.js_urls {
104 head.push_str(&format!(
105 "<link rel=\"modulepreload\" nonce=\"{}\" href=\"{}\">\n",
106 escape_html(nonce),
107 escape_html(js)
108 ));
109 }
110
111 for file in preloads {
116 head.push_str(&format!(
117 "<link rel=\"modulepreload\" nonce=\"{}\" href=\"/_assets/{}\">\n",
118 escape_html(nonce),
119 escape_html(file)
120 ));
121 }
122
123 for link in links {
125 link.write(&mut head);
126 }
127
128 if let Some(css) = personality_css {
146 let safe_css = css.replace("</", "<\\/");
147 head.push_str(&format!("<style nonce=\"{nonce}\">{safe_css}</style>\n"));
148 }
149
150 head
151}
152
153fn resolve_assets(manifest: &AssetManifest, route_pattern: &str) -> ResolvedAssets {
155 let route = manifest.route(route_pattern);
156 ResolvedAssets {
157 fonts: route
158 .map(|r| r.fonts.iter().map(|f| format!("/_assets/{f}")).collect())
159 .unwrap_or_default(),
160 css_urls: route
161 .map(|r| r.css.iter().map(|f| format!("/_assets/{f}")).collect())
162 .unwrap_or_default(),
163 js_urls: route
164 .map(|r| r.js.iter().map(|f| format!("/_assets/{f}")).collect())
165 .unwrap_or_default(),
166 }
167}
168
169struct BodyParts {
171 body_class_attr: String,
172 body_prefix: String,
173 config_script_tag: String,
174 page_js_tag: String,
175 wasm_script: String,
176 sw_script: String,
177}
178
179fn build_body_parts(nonce: &str, config: &PageConfig, js_urls: &[String]) -> BodyParts {
180 let body_class_attr = config
181 .body_class
182 .map(|c| format!(" class=\"{}\"", escape_html(c)))
183 .unwrap_or_default();
184 let body_prefix = config.body_prefix.unwrap_or("");
188 crate::raw::check_body_prefix(body_prefix, config.route_pattern);
189 let body_prefix = body_prefix.to_string();
190
191 let config_json_tag = config
196 .config_json
197 .map(|value| {
198 let json = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
199 format!(
200 "<script nonce=\"{nonce}\">window.__FORMA_CONFIG__={};</script>\n",
201 json.replace('<', "\\u003c")
202 )
203 })
204 .unwrap_or_default();
205
206 #[allow(deprecated)]
211 let config_script = config.config_script;
212 let config_script_tag = config_script
213 .map(|s| {
214 format!(
215 "<script nonce=\"{nonce}\">{}</script>\n",
216 neutralize_script_terminators(s)
217 )
218 })
219 .unwrap_or_default();
220 let config_script_tag = format!("{config_json_tag}{config_script_tag}");
221
222 let page_js_tag = js_urls
223 .last()
224 .map(|url| {
225 format!(
226 "<script type=\"module\" nonce=\"{nonce}\" src=\"{}\"></script>",
227 escape_html(url)
228 )
229 })
230 .unwrap_or_default();
231
232 #[allow(deprecated)]
236 let wasm_script = match (
237 &config.manifest.wasm,
238 config.manifest.route(config.route_pattern),
239 ) {
240 (Some(wasm), Some(route)) => match route.ir.as_ref() {
241 Some(ir_name) => format!(
242 "<script nonce=\"{nonce}\">window.__FORMA_WASM__={{loader:\"/_assets/{}\",binary:\"/_assets/{}\",ir:\"/_assets/{}\"}};</script>\n",
243 escape_html(&wasm.loader), escape_html(&wasm.binary), escape_html(ir_name)
244 ),
245 None => String::new(),
246 },
247 _ => String::new(),
248 };
249
250 let sw_script = crate::sw::registration_script(nonce);
254
255 BodyParts {
256 body_class_attr,
257 body_prefix,
258 config_script_tag,
259 page_js_tag,
260 wasm_script,
261 sw_script,
262 }
263}
264
265#[deprecated(
282 since = "0.2.2",
283 note = "serves an empty shell with a 200 on any failure; use `try_render`, which returns \
284 the failure as a `RenderError`, and `render_browser_shell` for a route declared \
285 `ssr: false`. Removed in 3.0.0"
286)]
287pub fn render_page(config: &PageConfig) -> PageOutput {
288 log_deprecation_once();
289 render_with_nonce(config, &csp::generate_csp_nonce())
290}
291
292fn log_deprecation_once() {
298 #[cfg(debug_assertions)]
299 {
300 use std::sync::atomic::{AtomicBool, Ordering};
301 static LOGGED: AtomicBool = AtomicBool::new(false);
302 if !LOGGED.swap(true, Ordering::Relaxed) {
303 tracing::warn!(
304 "forma_server::render_page is deprecated: it serves an empty shell with a 200 \
305 when a render fails; use try_render, which returns the failure. \
306 Logged once per process, in debug builds."
307 );
308 }
309 }
310}
311
312#[deprecated(
322 since = "0.2.2",
323 note = "the render draws the nonce: use `try_render`, which returns a `PageResponse`. \
324 Removed in 3.0.0"
325)]
326pub fn render_page_with_nonce(config: &PageConfig, nonce: &CspNonce) -> PageOutput {
327 render_with_nonce(config, nonce)
328}
329
330fn render_with_nonce(config: &PageConfig, nonce: &CspNonce) -> PageOutput {
332 match config.render_mode {
333 RenderMode::Phase1ClientMount => render_page_phase1(config, nonce),
334 RenderMode::Phase2SsrReconcile => render_page_phase2(config, nonce),
335 }
336}
337
338pub(crate) fn write_document(
342 config: &PageConfig,
343 nonce: &CspNonce,
344 ssr_body: Option<&str>,
345 ctx: &RenderCtx,
346) -> String {
347 let nonce_attr = escape_html(nonce.as_str());
350 let assets = resolve_assets(config.manifest, config.route_pattern);
351 let head = build_head(
352 config.title,
353 &nonce_attr,
354 &assets,
355 config.personality_css,
356 ctx.module_preloads(config.route_pattern),
357 ctx.head_links(),
358 );
359 let parts = build_body_parts(&nonce_attr, config, &assets.js_urls);
360
361 let ssr_len = ssr_body.map_or(0, str::len);
364 let mut html = String::with_capacity(head.len() + ssr_len + 1024);
365 html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
366 html.push_str(&head);
367 html.push_str("</head>\n<body");
368 html.push_str(&parts.body_class_attr);
369 html.push_str(">\n");
370 html.push_str(&parts.body_prefix);
371 match ssr_body {
372 Some(ssr) => {
373 html.push_str("<div id=\"app\" data-forma-ssr>");
374 html.push_str(ssr);
375 html.push_str("</div>");
376 }
377 None => html.push_str("<div id=\"app\"></div>"),
378 }
379 html.push('\n');
380 html.push_str(&parts.config_script_tag);
381 html.push_str(&parts.page_js_tag);
382 html.push('\n');
383 html.push_str(&parts.wasm_script);
384 html.push_str(&parts.sw_script);
385 html.push_str("</body>\n</html>");
386 html
387}
388
389#[allow(deprecated)] fn page_output(html: String, nonce: &CspNonce, counts: RenderCounts) -> PageOutput {
392 PageOutput {
393 html,
394 csp: csp::build_csp_header(nonce),
395 nonce: nonce.clone(),
396 counts,
397 }
398}
399
400fn render_page_phase1(config: &PageConfig, nonce: &CspNonce) -> PageOutput {
402 page_output(
403 write_document(config, nonce, None, &RenderCtx::default()),
404 nonce,
405 RenderCounts::default(),
406 )
407}
408
409fn render_page_phase2(config: &PageConfig, nonce: &CspNonce) -> PageOutput {
412 let (ir_module, slots) = match (config.ir_module, config.slots) {
414 (Some(m), Some(s)) => (m, s),
415 _ => {
416 tracing::warn!(
417 route = config.route_pattern,
418 "Phase 2 SSR: missing IR module or slots, falling back to Phase 1"
419 );
420 return render_page_phase1(config, nonce);
421 }
422 };
423
424 let started = Instant::now();
428 let walked = ir::walker::walk_to_html(ir_module, slots);
429 let walk_time = started.elapsed();
430 let ssr_body = match walked {
431 Ok(html) => html,
432 Err(err) => {
433 tracing::warn!(
434 route = config.route_pattern,
435 error = %err,
436 "Phase 2 SSR: IR walk failed, falling back to Phase 1"
437 );
438 return render_page_phase1(config, nonce);
439 }
440 };
441
442 let counts = RenderCounts {
443 bytes: ssr_body.len(),
444 walk_time,
445 ..RenderCounts::default()
446 };
447 page_output(
448 write_document(config, nonce, Some(&ssr_body), &RenderCtx::default()),
449 nonce,
450 counts,
451 )
452}
453
454#[cfg(test)]
455#[allow(deprecated)]
458mod tests {
459 use super::*;
460 use crate::types::{RouteAssets, WasmAssets};
461 use forma_ir::parser::IrModule;
462 use forma_ir::slot::{SlotData, SlotValue};
463 use std::collections::HashMap;
464
465 fn empty_manifest() -> AssetManifest {
467 AssetManifest {
468 version: 1,
469 build_hash: "test".to_string(),
470 assets: HashMap::new(),
471 routes: HashMap::new(),
472 wasm: None,
473 }
474 }
475
476 fn hello_ir_module() -> IrModule {
478 use forma_ir::parser::test_helpers::{
479 build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
480 };
481
482 let mut opcodes = Vec::new();
483 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, &[]);
488 IrModule::parse(&data).unwrap()
489 }
490
491 #[test]
492 fn phase1_renders_empty_app_div() {
493 let manifest = empty_manifest();
494 let page = render_page(&PageConfig {
495 title: "Test",
496 route_pattern: "/test",
497 manifest: &manifest,
498 config_script: None,
499 config_json: None,
500 body_class: None,
501 personality_css: None,
502 body_prefix: None,
503 render_mode: RenderMode::Phase1ClientMount,
504 ir_module: None,
505 slots: None,
506 });
507
508 assert!(page.html.contains("<div id=\"app\"></div>"));
509 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
511 }
512
513 #[test]
514 fn phase2_renders_ssr_content_with_data_attr() {
515 let manifest = empty_manifest();
516 let ir = hello_ir_module();
517 let slots = SlotData::new(0);
518
519 let page = render_page(&PageConfig {
520 title: "SSR Test",
521 route_pattern: "/test",
522 manifest: &manifest,
523 config_script: None,
524 config_json: None,
525 body_class: None,
526 personality_css: None,
527 body_prefix: None,
528 render_mode: RenderMode::Phase2SsrReconcile,
529 ir_module: Some(&ir),
530 slots: Some(&slots),
531 });
532
533 assert!(page.html.contains("data-forma-ssr"));
534 assert!(page.html.contains("<p>hello</p>"));
535 assert!(page.html.contains("<div id=\"app\" data-forma-ssr>"));
536 }
537
538 #[test]
539 fn phase2_falls_back_to_phase1_when_ir_module_missing() {
540 let manifest = empty_manifest();
541
542 let page = render_page(&PageConfig {
543 title: "Fallback Test",
544 route_pattern: "/test",
545 manifest: &manifest,
546 config_script: None,
547 config_json: None,
548 body_class: None,
549 personality_css: None,
550 body_prefix: None,
551 render_mode: RenderMode::Phase2SsrReconcile,
552 ir_module: None,
553 slots: None,
554 });
555
556 assert!(page.html.contains("<div id=\"app\"></div>"));
558 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
559 }
560
561 #[test]
562 fn phase2_falls_back_to_phase1_when_slots_missing() {
563 let manifest = empty_manifest();
564 let ir = hello_ir_module();
565
566 let page = render_page(&PageConfig {
567 title: "Fallback Test",
568 route_pattern: "/test",
569 manifest: &manifest,
570 config_script: None,
571 config_json: None,
572 body_class: None,
573 personality_css: None,
574 body_prefix: None,
575 render_mode: RenderMode::Phase2SsrReconcile,
576 ir_module: Some(&ir),
577 slots: None,
578 });
579
580 assert!(page.html.contains("<div id=\"app\"></div>"));
581 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
582 }
583
584 #[test]
585 fn phase2_falls_back_on_ir_walk_error() {
586 let manifest = empty_manifest();
587 let mut opcodes = vec![0x04]; opcodes.extend_from_slice(&99u32.to_le_bytes());
594 let data = forma_ir::parser::test_helpers::build_minimal_ir(&["x"], &[], &opcodes, &[]);
595 let ir = IrModule::parse(&data).unwrap();
596 let slots = SlotData::new(0);
597
598 let page = render_page(&PageConfig {
599 title: "Walk Error Test",
600 route_pattern: "/test",
601 manifest: &manifest,
602 config_script: None,
603 config_json: None,
604 body_class: None,
605 personality_css: None,
606 body_prefix: None,
607 render_mode: RenderMode::Phase2SsrReconcile,
608 ir_module: Some(&ir),
609 slots: Some(&slots),
610 });
611
612 assert!(page.html.contains("<div id=\"app\"></div>"));
614 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
615 }
616
617 #[test]
618 fn phase2_ssr_preserves_head_content() {
619 let manifest = empty_manifest();
620 let ir = hello_ir_module();
621 let slots = SlotData::new(0);
622
623 let page = render_page(&PageConfig {
624 title: "Head Test",
625 route_pattern: "/test",
626 manifest: &manifest,
627 config_script: Some("window.__TEST__=true;"),
628 config_json: None,
629 body_class: Some("dark"),
630 personality_css: Some(":root{--c:red}"),
631 body_prefix: Some("<nav>nav</nav>"),
632 render_mode: RenderMode::Phase2SsrReconcile,
633 ir_module: Some(&ir),
634 slots: Some(&slots),
635 });
636
637 assert!(page.html.contains("<title>Head Test</title>"));
638 assert!(page.html.contains("window.__TEST__=true;"));
639 assert!(page.html.contains("class=\"dark\""));
640 assert!(page.html.contains(":root{--c:red}"));
641 assert!(page.html.contains("<nav>nav</nav>"));
642 assert!(page.html.contains("<p>hello</p>"));
643 assert!(page.html.contains("data-forma-ssr"));
644 }
645
646 #[test]
647 fn wasm_script_injected_when_manifest_has_wasm_and_route_has_ir() {
648 let mut routes = HashMap::new();
649 routes.insert(
650 "/test".to_string(),
651 RouteAssets {
652 js: vec!["test.abc123.js".to_string()],
653 css: vec![],
654 fonts: vec![],
655 total_size_br: 0,
656 budget_warn_threshold: 204800,
657 ir: Some("test.def456.ir".to_string()),
658 },
659 );
660
661 let manifest = AssetManifest {
662 version: 1,
663 build_hash: "test".to_string(),
664 assets: HashMap::new(),
665 routes,
666 wasm: Some(WasmAssets {
667 loader: "forma_ir.abc.js".to_string(),
668 binary: "forma_ir_bg.def.wasm".to_string(),
669 }),
670 };
671
672 let page = render_page(&PageConfig {
673 title: "WASM Test",
674 route_pattern: "/test",
675 manifest: &manifest,
676 config_script: None,
677 config_json: None,
678 body_class: None,
679 personality_css: None,
680 body_prefix: None,
681 render_mode: RenderMode::Phase1ClientMount,
682 ir_module: None,
683 slots: None,
684 });
685
686 assert!(page.html.contains("__FORMA_WASM__"));
687 assert!(page.html.contains("/_assets/forma_ir.abc.js"));
688 assert!(page.html.contains("/_assets/forma_ir_bg.def.wasm"));
689 assert!(page.html.contains("/_assets/test.def456.ir"));
690 }
691
692 #[test]
693 fn wasm_script_not_injected_when_no_wasm_in_manifest() {
694 let manifest = empty_manifest();
695 let page = render_page(&PageConfig {
696 title: "No WASM",
697 route_pattern: "/test",
698 manifest: &manifest,
699 config_script: None,
700 config_json: None,
701 body_class: None,
702 personality_css: None,
703 body_prefix: None,
704 render_mode: RenderMode::Phase1ClientMount,
705 ir_module: None,
706 slots: None,
707 });
708
709 assert!(!page.html.contains("__FORMA_WASM__"));
710 }
711
712 #[test]
713 fn wasm_script_not_injected_when_route_has_no_ir() {
714 let mut routes = HashMap::new();
715 routes.insert(
716 "/test".to_string(),
717 RouteAssets {
718 js: vec!["test.abc123.js".to_string()],
719 css: vec![],
720 fonts: vec![],
721 total_size_br: 0,
722 budget_warn_threshold: 204800,
723 ir: None, },
725 );
726
727 let manifest = AssetManifest {
728 version: 1,
729 build_hash: "test".to_string(),
730 assets: HashMap::new(),
731 routes,
732 wasm: Some(WasmAssets {
733 loader: "forma_ir.abc.js".to_string(),
734 binary: "forma_ir_bg.def.wasm".to_string(),
735 }),
736 };
737
738 let page = render_page(&PageConfig {
739 title: "No IR",
740 route_pattern: "/test",
741 manifest: &manifest,
742 config_script: None,
743 config_json: None,
744 body_class: None,
745 personality_css: None,
746 body_prefix: None,
747 render_mode: RenderMode::Phase1ClientMount,
748 ir_module: None,
749 slots: None,
750 });
751
752 assert!(!page.html.contains("__FORMA_WASM__"));
753 }
754
755 #[test]
756 fn phase2_with_slot_data() {
757 use forma_ir::parser::test_helpers::{build_minimal_ir, encode_close_tag, encode_open_tag};
758
759 let mut opcodes = Vec::new();
761 opcodes.extend_from_slice(&encode_open_tag(0, &[])); opcodes.push(0x05);
764 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(
769 &["span", "name"], &[(0, 1, 0x01, 0x00, &[])], &opcodes,
772 &[],
773 );
774 let ir = IrModule::parse(&data).unwrap();
775
776 let mut slots = SlotData::new(1);
777 slots.set(0, SlotValue::Text("World".to_string()));
778
779 let manifest = empty_manifest();
780 let page = render_page(&PageConfig {
781 title: "Slot Test",
782 route_pattern: "/test",
783 manifest: &manifest,
784 config_script: None,
785 config_json: None,
786 body_class: None,
787 personality_css: None,
788 body_prefix: None,
789 render_mode: RenderMode::Phase2SsrReconcile,
790 ir_module: Some(&ir),
791 slots: Some(&slots),
792 });
793
794 assert!(page.html.contains("data-forma-ssr"));
795 assert!(page
797 .html
798 .contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
799 }
800
801 #[test]
804 fn escape_html_basic_entities() {
805 assert_eq!(escape_html("<script>"), "<script>");
806 assert_eq!(escape_html("\"quotes\""), ""quotes"");
807 assert_eq!(escape_html("a&b"), "a&b");
808 assert_eq!(escape_html("clean"), "clean");
809 }
810
811 #[test]
812 fn asset_filename_xss_is_escaped() {
813 let mut routes = HashMap::new();
816 routes.insert(
817 "/test".to_string(),
818 RouteAssets {
819 js: vec!["\"><script>alert(1)</script>".to_string()],
820 css: vec!["\"><script>alert(2)</script>".to_string()],
821 fonts: vec!["\"><script>alert(3)</script>".to_string()],
822 total_size_br: 0,
823 budget_warn_threshold: 204800,
824 ir: None,
825 },
826 );
827 let manifest = AssetManifest {
828 version: 1,
829 build_hash: "test".to_string(),
830 assets: HashMap::new(),
831 routes,
832 wasm: None,
833 };
834
835 let page = render_page(&PageConfig {
836 title: "XSS Test",
837 route_pattern: "/test",
838 manifest: &manifest,
839 config_script: None,
840 config_json: None,
841 body_class: None,
842 personality_css: None,
843 body_prefix: None,
844 render_mode: RenderMode::Phase1ClientMount,
845 ir_module: None,
846 slots: None,
847 });
848
849 assert!(
851 !page.html.contains("<script>alert("),
852 "asset filenames must be HTML-escaped, got: {}",
853 page.html
854 );
855 assert!(
856 page.html.contains("<script>"),
857 "escaped <script> should be present"
858 );
859 }
860
861 #[test]
862 fn body_class_with_quotes_is_escaped() {
863 let manifest = empty_manifest();
864 let page = render_page(&PageConfig {
865 title: "Body Class Test",
866 route_pattern: "/test",
867 manifest: &manifest,
868 config_script: None,
869 config_json: None,
870 body_class: Some("dark\" onload=\"alert(1)"),
871 personality_css: None,
872 body_prefix: None,
873 render_mode: RenderMode::Phase1ClientMount,
874 ir_module: None,
875 slots: None,
876 });
877
878 assert!(
883 page.html.contains("""),
884 "body_class double quote must be escaped"
885 );
886 assert!(
887 !page.html.contains(r#"class="dark" onload="#),
888 "body_class must not allow attribute breakout via unescaped double quote"
889 );
890 }
891
892 #[test]
893 fn personality_css_style_breakout_prevented() {
894 for css in [
899 "body{color:red}</style><script>alert(1)</script>",
900 "body{color:red}</StYlE><script>alert(1)</script>",
901 "body{color:red}</style ><script>alert(1)</script>",
902 "body{color:red}</style\t><script>alert(1)</script>",
903 "body{color:red}</style/><script>alert(1)</script>",
904 "body{color:red}</style\n><script>alert(1)</script>",
905 "body{color:red}</style\x0c><script>alert(1)</script>",
906 "body{color:red}<</style><script>alert(1)</script>",
907 "body{color:red}</sty</style><script>alert(1)</script>",
908 ] {
909 let manifest = empty_manifest();
910 let page = render_page(&PageConfig {
911 title: "CSS Test",
912 route_pattern: "/test",
913 manifest: &manifest,
914 config_script: None,
915 config_json: None,
916 body_class: None,
917 personality_css: Some(css),
918 body_prefix: None,
919 render_mode: RenderMode::Phase1ClientMount,
920 ir_module: None,
921 slots: None,
922 });
923
924 let start = page.html.find("<style").expect("style element");
929 let body_start = page.html[start..].find('>').expect("style open") + start + 1;
930 let end = page.html[body_start..]
931 .find("</style>")
932 .expect("style close")
933 + body_start;
934 let body = &page.html[body_start..end];
935 assert!(
936 !body.contains("</"),
937 "style body must contain no `</` for input {css:?}, got: {body}"
938 );
939 }
940 }
941
942 #[test]
943 fn personality_css_round_trips_legitimate_values() {
944 let manifest = empty_manifest();
949 let css = "@media (width < 600px){#t{color:rgb(1,2,3)}}\
950 .a::after{content:\"</div>\"}\
951 .b{font:12px/1.4 sans-serif;aspect-ratio:16/9}";
952 let page = render_page(&PageConfig {
953 title: "CSS Test",
954 route_pattern: "/test",
955 manifest: &manifest,
956 config_script: None,
957 config_json: None,
958 body_class: None,
959 personality_css: Some(css),
960 body_prefix: None,
961 render_mode: RenderMode::Phase1ClientMount,
962 ir_module: None,
963 slots: None,
964 });
965
966 assert!(page.html.contains("@media (width < 600px)"));
968 assert!(page.html.contains("font:12px/1.4 sans-serif"));
969 assert!(page.html.contains("aspect-ratio:16/9"));
970 assert!(page.html.contains("content:\"<\\/div>\""));
973 }
974
975 fn page_with_config_script(script: &str) -> String {
979 let manifest = empty_manifest();
980 render_page(&PageConfig {
981 title: "Config Test",
982 route_pattern: "/test",
983 manifest: &manifest,
984 config_script: Some(script),
985 config_json: None,
986 body_class: None,
987 personality_css: None,
988 body_prefix: None,
989 render_mode: RenderMode::Phase1ClientMount,
990 ir_module: None,
991 slots: None,
992 })
993 .html
994 }
995
996 #[test]
997 fn config_script_terminators_are_neutralized() {
998 let html = page_with_config_script("window.x=\"</script><img src=x onerror=alert(1)>\";");
1000 assert!(!html.contains("</script><img"));
1001 assert!(html.contains("<\\/script>"));
1002
1003 let html = page_with_config_script("window.x=\"</ScRiPt ><img src=x>\";");
1005 assert!(!html.to_ascii_lowercase().contains("</script ><img"));
1006
1007 let html = page_with_config_script("window.x=\"<!--<script>PAYLOAD\";");
1010 assert!(!html.contains("<!--<script"));
1011 assert!(html.contains("<\\!--"));
1013 }
1014
1015 #[test]
1016 fn config_json_emits_no_raw_angle_brackets() {
1017 let manifest = empty_manifest();
1018 let value = serde_json::json!({
1019 "user": "</script><img src=x onerror=alert(1)>",
1020 "note": "<!--<script>",
1021 });
1022 let page = render_page(&PageConfig {
1023 title: "Config Test",
1024 route_pattern: "/test",
1025 manifest: &manifest,
1026 config_script: None,
1027 config_json: Some(&value),
1028 body_class: None,
1029 personality_css: None,
1030 body_prefix: None,
1031 render_mode: RenderMode::Phase1ClientMount,
1032 ir_module: None,
1033 slots: None,
1034 });
1035
1036 let start = page
1037 .html
1038 .find("window.__FORMA_CONFIG__=")
1039 .expect("config json script")
1040 + "window.__FORMA_CONFIG__=".len();
1041 let end = page.html[start..].find("</script>").expect("close") + start;
1042 let json = &page.html[start..end];
1043 assert!(
1044 !json.contains('<'),
1045 "config_json output must contain no raw '<', got: {json}"
1046 );
1047 let parsed: serde_json::Value = serde_json::from_str(json.trim_end_matches(';')).unwrap();
1049 assert_eq!(parsed, value);
1050 }
1051
1052 #[test]
1055 fn page_output_exposes_the_nonce_it_embedded() {
1056 let manifest = empty_manifest();
1057 let page = render_page(&PageConfig {
1058 title: "Nonce Test",
1059 route_pattern: "/test",
1060 manifest: &manifest,
1061 config_script: Some("window.x=1;"),
1062 config_json: None,
1063 body_class: None,
1064 personality_css: Some("body{color:red}"),
1065 body_prefix: None,
1066 render_mode: RenderMode::Phase1ClientMount,
1067 ir_module: None,
1068 slots: None,
1069 });
1070
1071 assert!(page.csp.contains(&format!("'nonce-{}'", page.nonce)));
1073 assert!(page
1074 .html
1075 .contains(&format!("<script nonce=\"{}\">", page.nonce)));
1076 assert!(page
1077 .html
1078 .contains(&format!("<style nonce=\"{}\">", page.nonce)));
1079 }
1080
1081 #[test]
1082 fn render_page_with_nonce_uses_the_supplied_nonce() {
1083 let manifest = empty_manifest();
1086 let nonce = crate::csp::CspNonce::generate();
1087 let page = render_page_with_nonce(
1088 &PageConfig {
1089 title: "Nonce Test",
1090 route_pattern: "/test",
1091 manifest: &manifest,
1092 config_script: None,
1093 config_json: None,
1094 body_class: None,
1095 personality_css: None,
1096 body_prefix: None,
1097 render_mode: RenderMode::Phase1ClientMount,
1098 ir_module: None,
1099 slots: None,
1100 },
1101 &nonce,
1102 );
1103 assert_eq!(page.nonce, nonce);
1104 assert!(page.csp.contains(&format!("'nonce-{nonce}'")));
1105 }
1106
1107 #[test]
1108 fn page_output_headers_are_no_store() {
1109 let manifest = empty_manifest();
1111 let page = render_page(&PageConfig {
1112 title: "Header Test",
1113 route_pattern: "/test",
1114 manifest: &manifest,
1115 config_script: None,
1116 config_json: None,
1117 body_class: None,
1118 personality_css: None,
1119 body_prefix: None,
1120 render_mode: RenderMode::Phase1ClientMount,
1121 ir_module: None,
1122 slots: None,
1123 });
1124
1125 let headers = page.headers();
1126 let get = |name: &str| {
1127 headers
1128 .iter()
1129 .find(|(n, _)| n.as_str() == name)
1130 .map(|(_, v)| v.to_str().unwrap().to_string())
1131 };
1132 assert_eq!(get("cache-control").as_deref(), Some("no-store"));
1133 assert_eq!(get("x-content-type-options").as_deref(), Some("nosniff"));
1134 assert!(get("content-security-policy").is_some());
1135 assert!(get("referrer-policy").is_some());
1136 }
1137
1138 #[test]
1141 fn style_attributes_rendered_by_the_walker_are_permitted_by_the_csp() {
1142 let mut opcodes = forma_ir::parser::test_helpers::encode_open_tag(0, &[]);
1146 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(
1150 &["div", "style", "css"],
1151 &[(0, 2, 0x01, 0x00, &[])],
1152 &opcodes,
1153 &[],
1154 );
1155 let ir = IrModule::parse(&data).unwrap();
1156 let mut slots = SlotData::new(1);
1157 slots.set(0, SlotValue::Text("color:red".into()));
1158
1159 let manifest = empty_manifest();
1160 let page = render_page(&PageConfig {
1161 title: "Style Attr",
1162 route_pattern: "/test",
1163 manifest: &manifest,
1164 config_script: None,
1165 config_json: None,
1166 body_class: None,
1167 personality_css: None,
1168 body_prefix: None,
1169 render_mode: RenderMode::Phase2SsrReconcile,
1170 ir_module: Some(&ir),
1171 slots: Some(&slots),
1172 });
1173
1174 assert!(
1175 page.html.contains("style=\"color:red\""),
1176 "walker must emit the style attribute: {}",
1177 page.html
1178 );
1179 assert!(
1180 page.csp.contains("style-src-attr 'unsafe-inline'"),
1181 "CSP must permit the attribute style the walker just emitted: {}",
1182 page.csp
1183 );
1184 }
1185}