1use crate::csp::{self, CspNonce};
2use crate::types::{AssetManifest, PageConfig, PageOutput, RenderMode};
3use forma_ir as ir;
4
5struct ResolvedAssets {
7 fonts: Vec<String>,
8 css_urls: Vec<String>,
9 js_urls: Vec<String>,
10}
11
12fn escape_html(s: &str) -> String {
14 s.replace('&', "&")
15 .replace('<', "<")
16 .replace('>', ">")
17 .replace('"', """)
18}
19
20fn neutralize_script_terminators(s: &str) -> String {
37 let lower = s.to_ascii_lowercase();
38 let mut out = String::with_capacity(s.len() + 8);
39 let mut i = 0;
40 while i < s.len() {
41 if lower[i..].starts_with("</script") {
42 out.push_str("<\\/script");
43 i += "</script".len();
44 } else if lower[i..].starts_with("<!--") {
45 out.push_str("<\\!--");
46 i += "<!--".len();
47 } else {
48 let ch = s[i..].chars().next().expect("in-bounds index");
50 out.push(ch);
51 i += ch.len_utf8();
52 }
53 }
54 out
55}
56
57fn build_head(
58 title: &str,
59 nonce: &str,
60 assets: &ResolvedAssets,
61 personality_css: Option<&str>,
62) -> String {
63 let mut head = String::with_capacity(2048);
64 head.push_str("<meta charset=\"utf-8\">\n");
65 head.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
66 head.push_str(&format!("<title>{}</title>\n", escape_html(title)));
67
68 for font in &assets.fonts {
70 head.push_str(&format!(
71 "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
72 escape_html(font)
73 ));
74 }
75
76 for css in &assets.css_urls {
78 head.push_str(&format!(
79 "<link rel=\"stylesheet\" href=\"{}\">\n",
80 escape_html(css)
81 ));
82 }
83
84 for js in &assets.js_urls {
90 head.push_str(&format!(
91 "<link rel=\"modulepreload\" nonce=\"{}\" href=\"{}\">\n",
92 escape_html(nonce),
93 escape_html(js)
94 ));
95 }
96
97 if let Some(css) = personality_css {
115 let safe_css = css.replace("</", "<\\/");
116 head.push_str(&format!("<style nonce=\"{nonce}\">{safe_css}</style>\n"));
117 }
118
119 head
120}
121
122fn resolve_assets(manifest: &AssetManifest, route_pattern: &str) -> ResolvedAssets {
124 let route = manifest.route(route_pattern);
125 ResolvedAssets {
126 fonts: route
127 .map(|r| r.fonts.iter().map(|f| format!("/_assets/{f}")).collect())
128 .unwrap_or_default(),
129 css_urls: route
130 .map(|r| r.css.iter().map(|f| format!("/_assets/{f}")).collect())
131 .unwrap_or_default(),
132 js_urls: route
133 .map(|r| r.js.iter().map(|f| format!("/_assets/{f}")).collect())
134 .unwrap_or_default(),
135 }
136}
137
138struct BodyParts {
140 body_class_attr: String,
141 body_prefix: String,
142 config_script_tag: String,
143 page_js_tag: String,
144 wasm_script: String,
145 sw_script: String,
146}
147
148fn build_body_parts(nonce: &str, config: &PageConfig, js_urls: &[String]) -> BodyParts {
149 let body_class_attr = config
150 .body_class
151 .map(|c| format!(" class=\"{}\"", escape_html(c)))
152 .unwrap_or_default();
153 let body_prefix = config.body_prefix.unwrap_or("").to_string();
155
156 let config_json_tag = config
161 .config_json
162 .map(|value| {
163 let json = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
164 format!(
165 "<script nonce=\"{nonce}\">window.__FORMA_CONFIG__={};</script>\n",
166 json.replace('<', "\\u003c")
167 )
168 })
169 .unwrap_or_default();
170
171 let config_script_tag = config
175 .config_script
176 .map(|s| {
177 format!(
178 "<script nonce=\"{nonce}\">{}</script>\n",
179 neutralize_script_terminators(s)
180 )
181 })
182 .unwrap_or_default();
183 let config_script_tag = format!("{config_json_tag}{config_script_tag}");
184
185 let page_js_tag = js_urls
186 .last()
187 .map(|url| {
188 format!(
189 "<script type=\"module\" nonce=\"{nonce}\" src=\"{}\"></script>",
190 escape_html(url)
191 )
192 })
193 .unwrap_or_default();
194
195 let wasm_script = match (
196 &config.manifest.wasm,
197 config.manifest.route(config.route_pattern),
198 ) {
199 (Some(wasm), Some(route)) => match route.ir.as_ref() {
200 Some(ir_name) => format!(
201 "<script nonce=\"{nonce}\">window.__FORMA_WASM__={{loader:\"/_assets/{}\",binary:\"/_assets/{}\",ir:\"/_assets/{}\"}};</script>\n",
202 escape_html(&wasm.loader), escape_html(&wasm.binary), escape_html(ir_name)
203 ),
204 None => String::new(),
205 },
206 _ => String::new(),
207 };
208
209 let sw_script = format!(
210 "<script nonce=\"{nonce}\">if('serviceWorker' in navigator)navigator.serviceWorker.register('/sw.js');</script>\n"
211 );
212
213 BodyParts {
214 body_class_attr,
215 body_prefix,
216 config_script_tag,
217 page_js_tag,
218 wasm_script,
219 sw_script,
220 }
221}
222
223pub fn render_page(config: &PageConfig) -> PageOutput {
235 render_page_with_nonce(config, &csp::generate_csp_nonce())
236}
237
238pub fn render_page_with_nonce(config: &PageConfig, nonce: &CspNonce) -> PageOutput {
244 match config.render_mode {
245 RenderMode::Phase1ClientMount => render_page_phase1(config, nonce),
246 RenderMode::Phase2SsrReconcile => render_page_phase2(config, nonce),
247 }
248}
249
250fn render_page_phase1(config: &PageConfig, nonce: &CspNonce) -> PageOutput {
252 let nonce_attr = escape_html(nonce.as_str());
255 let nonce = nonce.clone();
256 let assets = resolve_assets(config.manifest, config.route_pattern);
257 let head = build_head(config.title, &nonce_attr, &assets, config.personality_css);
258 let parts = build_body_parts(&nonce_attr, config, &assets.js_urls);
259
260 let html = format!(
261 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n{head}</head>\n<body{bc}>\n{bp}<div id=\"app\"></div>\n{cs}{pj}\n{wasm}{sw}</body>\n</html>",
262 bc = parts.body_class_attr,
263 bp = parts.body_prefix,
264 cs = parts.config_script_tag,
265 pj = parts.page_js_tag,
266 wasm = parts.wasm_script,
267 sw = parts.sw_script,
268 );
269
270 PageOutput {
271 html,
272 csp: csp::build_csp_header(&nonce),
273 nonce,
274 }
275}
276
277fn render_page_phase2(config: &PageConfig, nonce: &CspNonce) -> PageOutput {
280 let (ir_module, slots) = match (config.ir_module, config.slots) {
282 (Some(m), Some(s)) => (m, s),
283 _ => {
284 tracing::warn!(
285 route = config.route_pattern,
286 "Phase 2 SSR: missing IR module or slots, falling back to Phase 1"
287 );
288 return render_page_phase1(config, nonce);
289 }
290 };
291
292 let ssr_body = match ir::walker::walk_to_html(ir_module, slots) {
296 Ok(html) => html,
297 Err(err) => {
298 tracing::warn!(
299 route = config.route_pattern,
300 error = %err,
301 "Phase 2 SSR: IR walk failed, falling back to Phase 1"
302 );
303 return render_page_phase1(config, nonce);
304 }
305 };
306
307 let nonce_attr = escape_html(nonce.as_str());
309 let nonce = nonce.clone();
310 let assets = resolve_assets(config.manifest, config.route_pattern);
311 let head = build_head(config.title, &nonce_attr, &assets, config.personality_css);
312 let parts = build_body_parts(&nonce_attr, config, &assets.js_urls);
313
314 let html = format!(
315 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n{head}</head>\n<body{bc}>\n{bp}<div id=\"app\" data-forma-ssr>{ssr}</div>\n{cs}{pj}\n{wasm}{sw}</body>\n</html>",
316 bc = parts.body_class_attr,
317 bp = parts.body_prefix,
318 ssr = ssr_body,
319 cs = parts.config_script_tag,
320 pj = parts.page_js_tag,
321 wasm = parts.wasm_script,
322 sw = parts.sw_script,
323 );
324
325 PageOutput {
326 html,
327 csp: csp::build_csp_header(&nonce),
328 nonce,
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use crate::types::{RouteAssets, WasmAssets};
336 use forma_ir::parser::IrModule;
337 use forma_ir::slot::{SlotData, SlotValue};
338 use std::collections::HashMap;
339
340 fn empty_manifest() -> AssetManifest {
342 AssetManifest {
343 version: 1,
344 build_hash: "test".to_string(),
345 assets: HashMap::new(),
346 routes: HashMap::new(),
347 wasm: None,
348 }
349 }
350
351 fn hello_ir_module() -> IrModule {
353 use forma_ir::parser::test_helpers::{
354 build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
355 };
356
357 let mut opcodes = Vec::new();
358 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, &[]);
363 IrModule::parse(&data).unwrap()
364 }
365
366 #[test]
367 fn phase1_renders_empty_app_div() {
368 let manifest = empty_manifest();
369 let page = render_page(&PageConfig {
370 title: "Test",
371 route_pattern: "/test",
372 manifest: &manifest,
373 config_script: None,
374 config_json: None,
375 body_class: None,
376 personality_css: None,
377 body_prefix: None,
378 render_mode: RenderMode::Phase1ClientMount,
379 ir_module: None,
380 slots: None,
381 });
382
383 assert!(page.html.contains("<div id=\"app\"></div>"));
384 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
386 }
387
388 #[test]
389 fn phase2_renders_ssr_content_with_data_attr() {
390 let manifest = empty_manifest();
391 let ir = hello_ir_module();
392 let slots = SlotData::new(0);
393
394 let page = render_page(&PageConfig {
395 title: "SSR Test",
396 route_pattern: "/test",
397 manifest: &manifest,
398 config_script: None,
399 config_json: None,
400 body_class: None,
401 personality_css: None,
402 body_prefix: None,
403 render_mode: RenderMode::Phase2SsrReconcile,
404 ir_module: Some(&ir),
405 slots: Some(&slots),
406 });
407
408 assert!(page.html.contains("data-forma-ssr"));
409 assert!(page.html.contains("<p>hello</p>"));
410 assert!(page.html.contains("<div id=\"app\" data-forma-ssr>"));
411 }
412
413 #[test]
414 fn phase2_falls_back_to_phase1_when_ir_module_missing() {
415 let manifest = empty_manifest();
416
417 let page = render_page(&PageConfig {
418 title: "Fallback Test",
419 route_pattern: "/test",
420 manifest: &manifest,
421 config_script: None,
422 config_json: None,
423 body_class: None,
424 personality_css: None,
425 body_prefix: None,
426 render_mode: RenderMode::Phase2SsrReconcile,
427 ir_module: None,
428 slots: None,
429 });
430
431 assert!(page.html.contains("<div id=\"app\"></div>"));
433 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
434 }
435
436 #[test]
437 fn phase2_falls_back_to_phase1_when_slots_missing() {
438 let manifest = empty_manifest();
439 let ir = hello_ir_module();
440
441 let page = render_page(&PageConfig {
442 title: "Fallback Test",
443 route_pattern: "/test",
444 manifest: &manifest,
445 config_script: None,
446 config_json: None,
447 body_class: None,
448 personality_css: None,
449 body_prefix: None,
450 render_mode: RenderMode::Phase2SsrReconcile,
451 ir_module: Some(&ir),
452 slots: None,
453 });
454
455 assert!(page.html.contains("<div id=\"app\"></div>"));
456 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
457 }
458
459 #[test]
460 fn phase2_falls_back_on_ir_walk_error() {
461 let manifest = empty_manifest();
462 let mut opcodes = vec![0x04]; opcodes.extend_from_slice(&99u32.to_le_bytes());
469 let data = forma_ir::parser::test_helpers::build_minimal_ir(&["x"], &[], &opcodes, &[]);
470 let ir = IrModule::parse(&data).unwrap();
471 let slots = SlotData::new(0);
472
473 let page = render_page(&PageConfig {
474 title: "Walk Error Test",
475 route_pattern: "/test",
476 manifest: &manifest,
477 config_script: None,
478 config_json: None,
479 body_class: None,
480 personality_css: None,
481 body_prefix: None,
482 render_mode: RenderMode::Phase2SsrReconcile,
483 ir_module: Some(&ir),
484 slots: Some(&slots),
485 });
486
487 assert!(page.html.contains("<div id=\"app\"></div>"));
489 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
490 }
491
492 #[test]
493 fn phase2_ssr_preserves_head_content() {
494 let manifest = empty_manifest();
495 let ir = hello_ir_module();
496 let slots = SlotData::new(0);
497
498 let page = render_page(&PageConfig {
499 title: "Head Test",
500 route_pattern: "/test",
501 manifest: &manifest,
502 config_script: Some("window.__TEST__=true;"),
503 config_json: None,
504 body_class: Some("dark"),
505 personality_css: Some(":root{--c:red}"),
506 body_prefix: Some("<nav>nav</nav>"),
507 render_mode: RenderMode::Phase2SsrReconcile,
508 ir_module: Some(&ir),
509 slots: Some(&slots),
510 });
511
512 assert!(page.html.contains("<title>Head Test</title>"));
513 assert!(page.html.contains("window.__TEST__=true;"));
514 assert!(page.html.contains("class=\"dark\""));
515 assert!(page.html.contains(":root{--c:red}"));
516 assert!(page.html.contains("<nav>nav</nav>"));
517 assert!(page.html.contains("<p>hello</p>"));
518 assert!(page.html.contains("data-forma-ssr"));
519 }
520
521 #[test]
522 fn wasm_script_injected_when_manifest_has_wasm_and_route_has_ir() {
523 let mut routes = HashMap::new();
524 routes.insert(
525 "/test".to_string(),
526 RouteAssets {
527 js: vec!["test.abc123.js".to_string()],
528 css: vec![],
529 fonts: vec![],
530 total_size_br: 0,
531 budget_warn_threshold: 204800,
532 ir: Some("test.def456.ir".to_string()),
533 },
534 );
535
536 let manifest = AssetManifest {
537 version: 1,
538 build_hash: "test".to_string(),
539 assets: HashMap::new(),
540 routes,
541 wasm: Some(WasmAssets {
542 loader: "forma_ir.abc.js".to_string(),
543 binary: "forma_ir_bg.def.wasm".to_string(),
544 }),
545 };
546
547 let page = render_page(&PageConfig {
548 title: "WASM Test",
549 route_pattern: "/test",
550 manifest: &manifest,
551 config_script: None,
552 config_json: None,
553 body_class: None,
554 personality_css: None,
555 body_prefix: None,
556 render_mode: RenderMode::Phase1ClientMount,
557 ir_module: None,
558 slots: None,
559 });
560
561 assert!(page.html.contains("__FORMA_WASM__"));
562 assert!(page.html.contains("/_assets/forma_ir.abc.js"));
563 assert!(page.html.contains("/_assets/forma_ir_bg.def.wasm"));
564 assert!(page.html.contains("/_assets/test.def456.ir"));
565 }
566
567 #[test]
568 fn wasm_script_not_injected_when_no_wasm_in_manifest() {
569 let manifest = empty_manifest();
570 let page = render_page(&PageConfig {
571 title: "No WASM",
572 route_pattern: "/test",
573 manifest: &manifest,
574 config_script: None,
575 config_json: None,
576 body_class: None,
577 personality_css: None,
578 body_prefix: None,
579 render_mode: RenderMode::Phase1ClientMount,
580 ir_module: None,
581 slots: None,
582 });
583
584 assert!(!page.html.contains("__FORMA_WASM__"));
585 }
586
587 #[test]
588 fn wasm_script_not_injected_when_route_has_no_ir() {
589 let mut routes = HashMap::new();
590 routes.insert(
591 "/test".to_string(),
592 RouteAssets {
593 js: vec!["test.abc123.js".to_string()],
594 css: vec![],
595 fonts: vec![],
596 total_size_br: 0,
597 budget_warn_threshold: 204800,
598 ir: None, },
600 );
601
602 let manifest = AssetManifest {
603 version: 1,
604 build_hash: "test".to_string(),
605 assets: HashMap::new(),
606 routes,
607 wasm: Some(WasmAssets {
608 loader: "forma_ir.abc.js".to_string(),
609 binary: "forma_ir_bg.def.wasm".to_string(),
610 }),
611 };
612
613 let page = render_page(&PageConfig {
614 title: "No IR",
615 route_pattern: "/test",
616 manifest: &manifest,
617 config_script: None,
618 config_json: None,
619 body_class: None,
620 personality_css: None,
621 body_prefix: None,
622 render_mode: RenderMode::Phase1ClientMount,
623 ir_module: None,
624 slots: None,
625 });
626
627 assert!(!page.html.contains("__FORMA_WASM__"));
628 }
629
630 #[test]
631 fn phase2_with_slot_data() {
632 use forma_ir::parser::test_helpers::{build_minimal_ir, encode_close_tag, encode_open_tag};
633
634 let mut opcodes = Vec::new();
636 opcodes.extend_from_slice(&encode_open_tag(0, &[])); opcodes.push(0x05);
639 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(
644 &["span", "name"], &[(0, 1, 0x01, 0x00, &[])], &opcodes,
647 &[],
648 );
649 let ir = IrModule::parse(&data).unwrap();
650
651 let mut slots = SlotData::new(1);
652 slots.set(0, SlotValue::Text("World".to_string()));
653
654 let manifest = empty_manifest();
655 let page = render_page(&PageConfig {
656 title: "Slot Test",
657 route_pattern: "/test",
658 manifest: &manifest,
659 config_script: None,
660 config_json: None,
661 body_class: None,
662 personality_css: None,
663 body_prefix: None,
664 render_mode: RenderMode::Phase2SsrReconcile,
665 ir_module: Some(&ir),
666 slots: Some(&slots),
667 });
668
669 assert!(page.html.contains("data-forma-ssr"));
670 assert!(page
672 .html
673 .contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
674 }
675
676 #[test]
679 fn escape_html_basic_entities() {
680 assert_eq!(escape_html("<script>"), "<script>");
681 assert_eq!(escape_html("\"quotes\""), ""quotes"");
682 assert_eq!(escape_html("a&b"), "a&b");
683 assert_eq!(escape_html("clean"), "clean");
684 }
685
686 #[test]
687 fn asset_filename_xss_is_escaped() {
688 let mut routes = HashMap::new();
691 routes.insert(
692 "/test".to_string(),
693 RouteAssets {
694 js: vec!["\"><script>alert(1)</script>".to_string()],
695 css: vec!["\"><script>alert(2)</script>".to_string()],
696 fonts: vec!["\"><script>alert(3)</script>".to_string()],
697 total_size_br: 0,
698 budget_warn_threshold: 204800,
699 ir: None,
700 },
701 );
702 let manifest = AssetManifest {
703 version: 1,
704 build_hash: "test".to_string(),
705 assets: HashMap::new(),
706 routes,
707 wasm: None,
708 };
709
710 let page = render_page(&PageConfig {
711 title: "XSS Test",
712 route_pattern: "/test",
713 manifest: &manifest,
714 config_script: None,
715 config_json: None,
716 body_class: None,
717 personality_css: None,
718 body_prefix: None,
719 render_mode: RenderMode::Phase1ClientMount,
720 ir_module: None,
721 slots: None,
722 });
723
724 assert!(
726 !page.html.contains("<script>alert("),
727 "asset filenames must be HTML-escaped, got: {}",
728 page.html
729 );
730 assert!(
731 page.html.contains("<script>"),
732 "escaped <script> should be present"
733 );
734 }
735
736 #[test]
737 fn body_class_with_quotes_is_escaped() {
738 let manifest = empty_manifest();
739 let page = render_page(&PageConfig {
740 title: "Body Class Test",
741 route_pattern: "/test",
742 manifest: &manifest,
743 config_script: None,
744 config_json: None,
745 body_class: Some("dark\" onload=\"alert(1)"),
746 personality_css: None,
747 body_prefix: None,
748 render_mode: RenderMode::Phase1ClientMount,
749 ir_module: None,
750 slots: None,
751 });
752
753 assert!(
758 page.html.contains("""),
759 "body_class double quote must be escaped"
760 );
761 assert!(
762 !page.html.contains(r#"class="dark" onload="#),
763 "body_class must not allow attribute breakout via unescaped double quote"
764 );
765 }
766
767 #[test]
768 fn personality_css_style_breakout_prevented() {
769 for css in [
774 "body{color:red}</style><script>alert(1)</script>",
775 "body{color:red}</StYlE><script>alert(1)</script>",
776 "body{color:red}</style ><script>alert(1)</script>",
777 "body{color:red}</style\t><script>alert(1)</script>",
778 "body{color:red}</style/><script>alert(1)</script>",
779 "body{color:red}</style\n><script>alert(1)</script>",
780 "body{color:red}</style\x0c><script>alert(1)</script>",
781 "body{color:red}<</style><script>alert(1)</script>",
782 "body{color:red}</sty</style><script>alert(1)</script>",
783 ] {
784 let manifest = empty_manifest();
785 let page = render_page(&PageConfig {
786 title: "CSS Test",
787 route_pattern: "/test",
788 manifest: &manifest,
789 config_script: None,
790 config_json: None,
791 body_class: None,
792 personality_css: Some(css),
793 body_prefix: None,
794 render_mode: RenderMode::Phase1ClientMount,
795 ir_module: None,
796 slots: None,
797 });
798
799 let start = page.html.find("<style").expect("style element");
804 let body_start = page.html[start..].find('>').expect("style open") + start + 1;
805 let end = page.html[body_start..]
806 .find("</style>")
807 .expect("style close")
808 + body_start;
809 let body = &page.html[body_start..end];
810 assert!(
811 !body.contains("</"),
812 "style body must contain no `</` for input {css:?}, got: {body}"
813 );
814 }
815 }
816
817 #[test]
818 fn personality_css_round_trips_legitimate_values() {
819 let manifest = empty_manifest();
824 let css = "@media (width < 600px){#t{color:rgb(1,2,3)}}\
825 .a::after{content:\"</div>\"}\
826 .b{font:12px/1.4 sans-serif;aspect-ratio:16/9}";
827 let page = render_page(&PageConfig {
828 title: "CSS Test",
829 route_pattern: "/test",
830 manifest: &manifest,
831 config_script: None,
832 config_json: None,
833 body_class: None,
834 personality_css: Some(css),
835 body_prefix: None,
836 render_mode: RenderMode::Phase1ClientMount,
837 ir_module: None,
838 slots: None,
839 });
840
841 assert!(page.html.contains("@media (width < 600px)"));
843 assert!(page.html.contains("font:12px/1.4 sans-serif"));
844 assert!(page.html.contains("aspect-ratio:16/9"));
845 assert!(page.html.contains("content:\"<\\/div>\""));
848 }
849
850 fn page_with_config_script(script: &str) -> String {
854 let manifest = empty_manifest();
855 render_page(&PageConfig {
856 title: "Config Test",
857 route_pattern: "/test",
858 manifest: &manifest,
859 config_script: Some(script),
860 config_json: None,
861 body_class: None,
862 personality_css: None,
863 body_prefix: None,
864 render_mode: RenderMode::Phase1ClientMount,
865 ir_module: None,
866 slots: None,
867 })
868 .html
869 }
870
871 #[test]
872 fn config_script_terminators_are_neutralized() {
873 let html = page_with_config_script("window.x=\"</script><img src=x onerror=alert(1)>\";");
875 assert!(!html.contains("</script><img"));
876 assert!(html.contains("<\\/script>"));
877
878 let html = page_with_config_script("window.x=\"</ScRiPt ><img src=x>\";");
880 assert!(!html.to_ascii_lowercase().contains("</script ><img"));
881
882 let html = page_with_config_script("window.x=\"<!--<script>PAYLOAD\";");
885 assert!(!html.contains("<!--<script"));
886 assert!(html.contains("<\\!--"));
888 }
889
890 #[test]
891 fn config_json_emits_no_raw_angle_brackets() {
892 let manifest = empty_manifest();
893 let value = serde_json::json!({
894 "user": "</script><img src=x onerror=alert(1)>",
895 "note": "<!--<script>",
896 });
897 let page = render_page(&PageConfig {
898 title: "Config Test",
899 route_pattern: "/test",
900 manifest: &manifest,
901 config_script: None,
902 config_json: Some(&value),
903 body_class: None,
904 personality_css: None,
905 body_prefix: None,
906 render_mode: RenderMode::Phase1ClientMount,
907 ir_module: None,
908 slots: None,
909 });
910
911 let start = page
912 .html
913 .find("window.__FORMA_CONFIG__=")
914 .expect("config json script")
915 + "window.__FORMA_CONFIG__=".len();
916 let end = page.html[start..].find("</script>").expect("close") + start;
917 let json = &page.html[start..end];
918 assert!(
919 !json.contains('<'),
920 "config_json output must contain no raw '<', got: {json}"
921 );
922 let parsed: serde_json::Value = serde_json::from_str(json.trim_end_matches(';')).unwrap();
924 assert_eq!(parsed, value);
925 }
926
927 #[test]
930 fn page_output_exposes_the_nonce_it_embedded() {
931 let manifest = empty_manifest();
932 let page = render_page(&PageConfig {
933 title: "Nonce Test",
934 route_pattern: "/test",
935 manifest: &manifest,
936 config_script: Some("window.x=1;"),
937 config_json: None,
938 body_class: None,
939 personality_css: Some("body{color:red}"),
940 body_prefix: None,
941 render_mode: RenderMode::Phase1ClientMount,
942 ir_module: None,
943 slots: None,
944 });
945
946 assert!(page.csp.contains(&format!("'nonce-{}'", page.nonce)));
948 assert!(page
949 .html
950 .contains(&format!("<script nonce=\"{}\">", page.nonce)));
951 assert!(page
952 .html
953 .contains(&format!("<style nonce=\"{}\">", page.nonce)));
954 }
955
956 #[test]
957 fn render_page_with_nonce_uses_the_supplied_nonce() {
958 let manifest = empty_manifest();
961 let nonce = crate::csp::CspNonce::generate();
962 let page = render_page_with_nonce(
963 &PageConfig {
964 title: "Nonce Test",
965 route_pattern: "/test",
966 manifest: &manifest,
967 config_script: None,
968 config_json: None,
969 body_class: None,
970 personality_css: None,
971 body_prefix: None,
972 render_mode: RenderMode::Phase1ClientMount,
973 ir_module: None,
974 slots: None,
975 },
976 &nonce,
977 );
978 assert_eq!(page.nonce, nonce);
979 assert!(page.csp.contains(&format!("'nonce-{nonce}'")));
980 }
981
982 #[test]
983 fn page_output_headers_are_no_store() {
984 let manifest = empty_manifest();
986 let page = render_page(&PageConfig {
987 title: "Header Test",
988 route_pattern: "/test",
989 manifest: &manifest,
990 config_script: None,
991 config_json: None,
992 body_class: None,
993 personality_css: None,
994 body_prefix: None,
995 render_mode: RenderMode::Phase1ClientMount,
996 ir_module: None,
997 slots: None,
998 });
999
1000 let headers = page.headers();
1001 let get = |name: &str| {
1002 headers
1003 .iter()
1004 .find(|(n, _)| n.as_str() == name)
1005 .map(|(_, v)| v.to_str().unwrap().to_string())
1006 };
1007 assert_eq!(get("cache-control").as_deref(), Some("no-store"));
1008 assert_eq!(get("x-content-type-options").as_deref(), Some("nosniff"));
1009 assert!(get("content-security-policy").is_some());
1010 assert!(get("referrer-policy").is_some());
1011 }
1012
1013 #[test]
1016 fn style_attributes_rendered_by_the_walker_are_permitted_by_the_csp() {
1017 let mut opcodes = forma_ir::parser::test_helpers::encode_open_tag(0, &[]);
1021 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(
1025 &["div", "style", "css"],
1026 &[(0, 2, 0x01, 0x00, &[])],
1027 &opcodes,
1028 &[],
1029 );
1030 let ir = IrModule::parse(&data).unwrap();
1031 let mut slots = SlotData::new(1);
1032 slots.set(0, SlotValue::Text("color:red".into()));
1033
1034 let manifest = empty_manifest();
1035 let page = render_page(&PageConfig {
1036 title: "Style Attr",
1037 route_pattern: "/test",
1038 manifest: &manifest,
1039 config_script: None,
1040 config_json: None,
1041 body_class: None,
1042 personality_css: None,
1043 body_prefix: None,
1044 render_mode: RenderMode::Phase2SsrReconcile,
1045 ir_module: Some(&ir),
1046 slots: Some(&slots),
1047 });
1048
1049 assert!(
1050 page.html.contains("style=\"color:red\""),
1051 "walker must emit the style attribute: {}",
1052 page.html
1053 );
1054 assert!(
1055 page.csp.contains("style-src-attr 'unsafe-inline'"),
1056 "CSP must permit the attribute style the walker just emitted: {}",
1057 page.csp
1058 );
1059 }
1060}