Skip to main content

forma_server/
template.rs

1use crate::csp::CspNonce;
2use crate::head::HeadLink;
3use crate::render::{RenderCtx, RenderError};
4use crate::types::{AssetManifest, PageConfig};
5
6/// Resolved asset URLs for a route — shared between Phase 1 and Phase 2 paths.
7struct ResolvedAssets {
8    fonts: Vec<String>,
9    css_urls: Vec<Resource>,
10    js_urls: Vec<Resource>,
11}
12
13/// A resource whose manifest digest was checked before any document is emitted.
14struct Resource {
15    url: String,
16    integrity: String,
17}
18
19/// Escape text for an HTML attribute value or text content.
20pub(crate) fn escape_html(s: &str) -> String {
21    s.replace('&', "&amp;")
22        .replace('<', "&lt;")
23        .replace('>', "&gt;")
24        .replace('"', "&quot;")
25}
26
27/// Rule 1 applies to ordinary DOM values before HTML escaping. Transport
28/// data, nonces, digests, and build identity keep their existing exact encoding.
29pub(crate) fn escape_dom_html(s: &str) -> String {
30    escape_html(
31        &s.replace("\r\n", "\n")
32            .replace('\r', "\n")
33            .replace('\0', "\u{fffd}"),
34    )
35}
36
37/// Build the common `<head>` content shared by both render paths.
38///
39/// `preloads` and `links` are the typed homes a [`RenderCtx`] carries;
40/// a render without one passes neither. External resource tags always include SRI.
41fn build_head(
42    title: &str,
43    nonce: &str,
44    assets: &ResolvedAssets,
45    personality_css: Option<&str>,
46    preloads: &[Resource],
47    links: &[HeadLink],
48) -> String {
49    let mut head = String::with_capacity(2048);
50    head.push_str("<meta charset=\"utf-8\">\n");
51    head.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
52    head.push_str(&format!("<title>{}</title>\n", escape_dom_html(title)));
53
54    // Font preloads
55    for font in &assets.fonts {
56        head.push_str(&format!(
57            "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
58            escape_dom_html(font)
59        ));
60    }
61
62    // CSS stylesheets
63    for css in &assets.css_urls {
64        head.push_str(&format!(
65            "<link rel=\"stylesheet\" href=\"{}\" integrity=\"{}\" crossorigin=\"anonymous\">\n",
66            escape_dom_html(&css.url),
67            escape_html(&css.integrity)
68        ));
69    }
70
71    // JS modulepreloads. Nonced because under `script-src 'strict-dynamic'` an
72    // un-nonced script-destination preload hint is dropped. Losing it is a
73    // degradation, not a breakage — the real module tag is nonced and its
74    // static imports inherit that nonce, so the chunk still loads; you lose a
75    // warm-cache round trip and gain a console violation.
76    for js in &assets.js_urls {
77        head.push_str(&format!(
78            "<link rel=\"modulepreload\" nonce=\"{}\" href=\"{}\" integrity=\"{}\" crossorigin=\"anonymous\">\n",
79            escape_html(nonce),
80            escape_dom_html(&js.url),
81            escape_html(&js.integrity)
82        ));
83    }
84
85    // The route's static dependencies from the build's manifest (DR-19), in
86    // the same form and for the same reason: the typed home for Ledger's
87    // preloads in `body_prefix`. The table leaves out the route's own
88    // modules and repeats (`ModulePreloads`).
89    for file in preloads {
90        head.push_str(&format!(
91            "<link rel=\"modulepreload\" nonce=\"{}\" href=\"{}\" integrity=\"{}\" crossorigin=\"anonymous\">\n",
92            escape_html(nonce),
93            escape_dom_html(&file.url),
94            escape_html(&file.integrity)
95        ));
96    }
97
98    // Head links (icons, a web app manifest), every value escaped (DR-19).
99    for link in links {
100        link.write(&mut head);
101    }
102
103    // Personality CSS (inline, small).
104    //
105    // Neutralize `</`, not `</style>`. A literal two-case string replace was
106    // never sufficient: HTML tag names are case-insensitive and the tokenizer
107    // accepts whitespace and `/` before `>`, so `</StYlE>`, `</style >`,
108    // `</style\t>` and `</style/>` all escaped the element. The ONLY way out
109    // of a RAWTEXT element is `</` followed by a tag name — character
110    // references are not decoded and comments are not recognised there — so if
111    // no `</` survives, no end tag can be produced.
112    //
113    // `\/` is a valid CSS escape for `/`, valid inside strings, `url()` tokens
114    // and idents, and it consumes no following whitespace, so real CSS
115    // round-trips byte-identically (`content:"</div>"` still computes to
116    // `</div>`). Escaping `<` instead would be secure but silently breaks
117    // modern CSS: `\00003c` is an ident-start escape, not the `<` delim token
118    // that the Media Queries Level 4 range grammar needs, so
119    // `@media (width < 600px)` would parse and then never match.
120    if let Some(css) = personality_css {
121        let safe_css = css.replace("</", "<\\/");
122        head.push_str(&format!("<style nonce=\"{nonce}\">{safe_css}</style>\n"));
123    }
124
125    head
126}
127
128/// Resolve each resource through this response's manifest snapshot.
129fn resource(manifest: &AssetManifest, file: &str, route: &str) -> Result<Resource, RenderError> {
130    let metadata = manifest
131        .assets
132        .get(file)
133        .filter(|meta| crate::types::valid_integrity(&meta.integrity))
134        .ok_or_else(|| RenderError::Manifest {
135            route: route.into(),
136            message: format!(
137                "resource {file:?} requires canonical SHA-384 integrity in this manifest"
138            ),
139        })?;
140    Ok(Resource {
141        url: format!("/_assets/{file}"),
142        integrity: metadata.integrity.clone(),
143    })
144}
145
146/// Resolve asset URLs from the manifest for a given route pattern.
147fn resolve_assets(
148    manifest: &AssetManifest,
149    route_pattern: &str,
150) -> Result<ResolvedAssets, RenderError> {
151    let route = manifest.route(route_pattern);
152    let resolve = |files: &[String]| {
153        files
154            .iter()
155            .map(|file| resource(manifest, file, route_pattern))
156            .collect::<Result<Vec<_>, _>>()
157    };
158    Ok(ResolvedAssets {
159        fonts: route
160            .map(|r| r.fonts.iter().map(|f| format!("/_assets/{f}")).collect())
161            .unwrap_or_default(),
162        css_urls: resolve(route.map_or(&[], |r| &r.css))?,
163        js_urls: resolve(route.map_or(&[], |r| &r.js))?,
164    })
165}
166
167/// Build shared body fragments (class attr, prefix, escaped JSON data, page JS tag).
168struct BodyParts {
169    body_class_attr: String,
170    body_prefix: String,
171    config_json_tag: String,
172    page_js_tag: String,
173    sw_script: String,
174}
175
176fn build_body_parts(nonce: &str, config: &PageConfig, js_urls: &[Resource]) -> BodyParts {
177    let body_class_attr = config
178        .body_class
179        .map(|c| format!(" class=\"{}\"", escape_dom_html(c)))
180        .unwrap_or_default();
181    let body_prefix = config
182        .body_prefix
183        .map_or("", crate::RawHtml::as_str)
184        .to_string();
185
186    // `config_json` is the safe path: serde_json output with every `<`
187    // escaped, which simultaneously kills `</script`, `<!--` and `<script`
188    // and is value-preserving in JSON (`<` is a valid string escape, so
189    // JSON.parse yields byte-identical values).
190    let config_json_tag = config
191        .config_json
192        .map(|value| {
193            let json = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
194            format!(
195                "<script nonce=\"{nonce}\">window.__FORMA_CONFIG__={};</script>\n",
196                json.replace('<', "\\u003c")
197            )
198        })
199        .unwrap_or_default();
200
201    // Route `js` contains declared entry modules in order. Dependency chunks
202    // live in the separate modulepreload table and must not execute eagerly.
203    let page_js_tag = js_urls
204        .iter()
205        .map(|url| {
206            format!(
207                "<script type=\"module\" nonce=\"{nonce}\" src=\"{}\" integrity=\"{}\" crossorigin=\"anonymous\"></script>",
208                escape_dom_html(&url.url),
209                escape_html(&url.integrity)
210            )
211        })
212        .collect::<Vec<_>>()
213        .join("\n");
214
215    // Registration belongs only to pages that publish a JavaScript entry.
216    // Static pages never bootstrap a worker, even in an enabled application.
217    // The worker endpoint and off-mode retirement remain independently available.
218    let sw_script = if js_urls.is_empty() {
219        String::new()
220    } else {
221        crate::sw::registration_script(nonce)
222    };
223
224    BodyParts {
225        body_class_attr,
226        body_prefix,
227        config_json_tag,
228        page_js_tag,
229        sw_script,
230    }
231}
232
233/// The document around the page's root: empty for a client-mounted page,
234/// or holding the walk's HTML. `ctx` carries the typed head metadata.
235pub(crate) fn write_document(
236    config: &PageConfig,
237    nonce: &CspNonce,
238    ssr_body: Option<&str>,
239    island_state: Option<&forma_ir::IslandState>,
240    ctx: &RenderCtx,
241    max_output_bytes: usize,
242    counts: crate::RenderCounts,
243) -> Result<String, RenderError> {
244    // Defence in depth: CspNonce already rejects anything attribute- or
245    // directive-significant, so this never changes a real nonce.
246    let nonce_attr = escape_html(nonce.as_str());
247    let assets = resolve_assets(config.manifest, config.route_pattern)?;
248    let mut preloads = Vec::new();
249    let mut seen = std::collections::HashSet::new();
250    for file in ctx.module_preloads(config.route_pattern) {
251        let url = format!("/_assets/{file}");
252        if assets.js_urls.iter().any(|asset| asset.url == url) || !seen.insert(file) {
253            continue;
254        }
255        preloads.push(resource(config.manifest, file, config.route_pattern)?);
256    }
257    let head = build_head(
258        config.title,
259        &nonce_attr,
260        &assets,
261        config.personality_css,
262        &preloads,
263        ctx.head_links(),
264    );
265    let parts = build_body_parts(&nonce_attr, config, &assets.js_urls);
266    // The root names the build the manifest names (FORMA-186, DR-29): core compares it
267    // with the identity compiled into the bundle before it activates anything. The
268    // manifest is data, so it is escaped as any attribute value is, and no render input
269    // can set or override it.
270    let static_route = config
271        .manifest
272        .route(config.route_pattern)
273        .is_some_and(crate::RouteAssets::is_static_ssr);
274    let build_attr = if static_route {
275        String::new()
276    } else {
277        format!(
278            " data-forma-build=\"{}\"",
279            escape_html(&config.manifest.build)
280        )
281    };
282
283    // Every byte of the completed document, including state tags/envelope,
284    // counts against the same response limit. Charge before allocating/copying
285    // the final document; body walk counts remain a separate diagnostic.
286    let fragments = [
287        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n",
288        head.as_str(),
289        if island_state.is_some() {
290            "<script type=\"application/json\" data-island-state>"
291        } else {
292            ""
293        },
294        island_state.map_or("", forma_ir::IslandState::as_json),
295        if island_state.is_some() {
296            "</script>\n"
297        } else {
298            ""
299        },
300        "</head>\n<body",
301        parts.body_class_attr.as_str(),
302        ">\n",
303        parts.body_prefix.as_str(),
304        "<div id=\"app\"",
305        build_attr.as_str(),
306        if ssr_body.is_some() {
307            if static_route {
308                ">"
309            } else {
310                " data-forma-ssr>"
311            }
312        } else {
313            "></div>"
314        },
315        ssr_body.unwrap_or(""),
316        if ssr_body.is_some() { "</div>" } else { "" },
317        "\n",
318        parts.config_json_tag.as_str(),
319        parts.page_js_tag.as_str(),
320        "\n",
321        parts.sw_script.as_str(),
322        "</body>\n</html>",
323    ];
324    let total = fragments
325        .iter()
326        .try_fold(0usize, |total, part| total.checked_add(part.len()));
327    let Some(total) = total.filter(|total| *total <= max_output_bytes) else {
328        return Err(RenderError::Budget {
329            route: config.route_pattern.into(),
330            component_path: Vec::new(),
331            counts,
332            cause: forma_ir::IrError::OutputLimitExceeded {
333                limit: max_output_bytes,
334            },
335        });
336    };
337    let mut html = String::with_capacity(total);
338    for fragment in fragments {
339        html.push_str(fragment);
340    }
341
342    Ok(html)
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::render_contract::{RenderFixture as PageConfig, RenderPath as RenderMode};
349    use crate::types::RouteAssets;
350    use forma_ir::parser::IrModule;
351    use forma_ir::slot::{SlotData, SlotValue};
352    use std::collections::HashMap;
353
354    fn render_parts(config: &PageConfig) -> crate::PageParts {
355        let ctx = RenderCtx::new();
356        match config.render_mode {
357            RenderMode::Phase1ClientMount => crate::render_contract::render_shell(config, &ctx),
358            RenderMode::Phase2SsrReconcile => crate::render_contract::render_ssr(config, &ctx),
359        }
360        .expect("valid fixture renders")
361        .into_parts_unchecked()
362    }
363    fn policy(page: &crate::PageParts) -> &str {
364        page.headers
365            .iter()
366            .find(|(name, _)| name == "content-security-policy")
367            .unwrap()
368            .1
369            .to_str()
370            .unwrap()
371    }
372
373    /// Build a minimal AssetManifest with no routes for testing.
374    fn empty_manifest() -> AssetManifest {
375        AssetManifest {
376            version: 2,
377            writer: "3.0.0".into(),
378            app: "ledger".into(),
379            aliases: HashMap::new(),
380            build: "test".to_string(),
381            assets: HashMap::new(),
382            routes: HashMap::new(),
383        }
384    }
385
386    #[test]
387    fn a_legacy_manifest_is_refused_before_removed_wasm_fields_can_be_used() {
388        let error = serde_json::from_value::<AssetManifest>(serde_json::json!({
389            "version":1,"build_hash":"legacy","assets":{},"routes":{},
390            "wasm":{"loader":"legacy-loader.js","binary":"legacy.wasm"}
391        }))
392        .unwrap_err();
393        assert!(error.to_string().contains("MANIFEST-101"));
394    }
395
396    /// Build a valid IR module that renders "<p>hello</p>".
397    fn hello_ir_module() -> IrModule {
398        use forma_ir::parser::test_helpers::{
399            build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
400        };
401
402        let mut opcodes = Vec::new();
403        opcodes.extend_from_slice(&encode_open_tag(0, &[])); // <p>
404        opcodes.extend_from_slice(&encode_text(1)); // hello
405        opcodes.extend_from_slice(&encode_close_tag(0)); // </p>
406
407        let data = build_minimal_ir(&["p", "hello"], &[], &opcodes, &[]);
408        IrModule::parse(&data).unwrap()
409    }
410
411    #[test]
412    fn phase1_renders_empty_app_div() {
413        let manifest = empty_manifest();
414        let page = render_parts(&PageConfig {
415            title: "Test",
416            route_pattern: "/test",
417            manifest: &manifest,
418            config_json: None,
419            body_class: None,
420            personality_css: None,
421            body_prefix: None,
422            render_mode: RenderMode::Phase1ClientMount,
423            ir_module: None,
424            slots: None,
425        });
426
427        // The root carries the build's identity (FORMA-186); the manifest's build here is "test".
428        assert!(page
429            .html
430            .contains("<div id=\"app\" data-forma-build=\"test\"></div>"));
431        // Check that the #app div does NOT have the SSR attribute
432        assert!(!page.html.contains("data-forma-ssr"));
433    }
434
435    #[test]
436    fn phase2_renders_ssr_content_with_data_attr() {
437        let manifest = empty_manifest();
438        let ir = hello_ir_module();
439        let slots = SlotData::new(0);
440
441        let page = render_parts(&PageConfig {
442            title: "SSR Test",
443            route_pattern: "/test",
444            manifest: &manifest,
445            config_json: None,
446            body_class: None,
447            personality_css: None,
448            body_prefix: None,
449            render_mode: RenderMode::Phase2SsrReconcile,
450            ir_module: Some(&ir),
451            slots: Some(&slots),
452        });
453
454        assert!(page.html.contains("data-forma-ssr"));
455        assert!(page.html.contains("<p>hello</p>"));
456        assert!(page.html.contains(
457            "<div id=\"app\" data-forma-build=\"test\" data-forma-ssr><p>hello</p></div>"
458        ));
459    }
460
461    /// The deployment identity (FORMA-186, DR-29) is the manifest's `build`, written
462    /// on the root of a server-rendered page and of a browser shell, once, escaped
463    /// as any attribute value is: a manifest is data, never markup.
464    ///
465    /// Verified by: crates/forma-server/src/template.rs > "the_root_carries_the_manifests_build_escaped_on_both_forms"
466    #[test]
467    fn the_root_carries_the_manifests_build_escaped_on_both_forms() {
468        let mut manifest = empty_manifest();
469        manifest.build = "5f0c9a1e7d2b4c86".into();
470        let ir = hello_ir_module();
471        let slots = SlotData::new(0);
472        for (mode, ir_module, slots) in [
473            (RenderMode::Phase1ClientMount, None, None),
474            (RenderMode::Phase2SsrReconcile, Some(&ir), Some(&slots)),
475        ] {
476            let page = render_parts(&PageConfig {
477                title: "Identity",
478                route_pattern: "/test",
479                manifest: &manifest,
480                config_json: None,
481                body_class: None,
482                personality_css: None,
483                body_prefix: None,
484                render_mode: mode,
485                ir_module,
486                slots,
487            });
488            assert_eq!(
489                page.html.matches("data-forma-build=").count(),
490                1,
491                "exactly one stamp: {}",
492                page.html
493            );
494            assert!(page
495                .html
496                .contains("<div id=\"app\" data-forma-build=\"5f0c9a1e7d2b4c86\""));
497        }
498        manifest.build = "a\"b<c>&d".into();
499        let page = render_parts(&PageConfig {
500            title: "Identity",
501            route_pattern: "/test",
502            manifest: &manifest,
503            config_json: None,
504            body_class: None,
505            personality_css: None,
506            body_prefix: None,
507            render_mode: RenderMode::Phase1ClientMount,
508            ir_module: None,
509            slots: None,
510        });
511        assert!(page
512            .html
513            .contains("data-forma-build=\"a&quot;b&lt;c&gt;&amp;d\""));
514        assert!(!page.html.contains("a\"b<c>"));
515    }
516
517    #[test]
518    fn phase2_ssr_preserves_head_content() {
519        let manifest = empty_manifest();
520        let ir = hello_ir_module();
521        let slots = SlotData::new(0);
522
523        let page = render_parts(&PageConfig {
524            title: "Head Test",
525            route_pattern: "/test",
526            manifest: &manifest,
527            config_json: Some(&serde_json::json!({"test":true})),
528            body_class: Some("dark"),
529            personality_css: Some(":root{--c:red}"),
530            body_prefix: Some(&crate::RawHtml::trusted("<nav>nav</nav>")),
531            render_mode: RenderMode::Phase2SsrReconcile,
532            ir_module: Some(&ir),
533            slots: Some(&slots),
534        });
535
536        assert!(page.html.contains("<title>Head Test</title>"));
537        assert!(page.html.contains("window.__FORMA_CONFIG__="));
538        assert!(page.html.contains("class=\"dark\""));
539        assert!(page.html.contains(":root{--c:red}"));
540        assert!(page.html.contains("<nav>nav</nav>"));
541        assert!(page.html.contains("<p>hello</p>"));
542        assert!(page.html.contains("data-forma-ssr"));
543    }
544
545    #[test]
546    fn phase2_with_slot_data() {
547        use forma_ir::parser::test_helpers::{build_minimal_ir, encode_close_tag, encode_open_tag};
548
549        // Build IR with DYN_TEXT referencing slot 0
550        let mut opcodes = Vec::new();
551        opcodes.extend_from_slice(&encode_open_tag(0, &[])); // <span>
552                                                             // DYN_TEXT opcode: 0x05 + slot_id(u16) + marker_id(u16)
553        opcodes.push(0x05);
554        opcodes.extend_from_slice(&0u16.to_le_bytes()); // slot_id = 0
555        opcodes.extend_from_slice(&0u16.to_le_bytes()); // marker_id = 0
556        opcodes.extend_from_slice(&encode_close_tag(0)); // </span>
557
558        let data = build_minimal_ir(
559            &["span", "name"],          // strings
560            &[(0, 1, 0x01, 0x00, &[])], // slot: id=0, name_str_idx=1, type=Text, source=Server
561            &opcodes,
562            &[],
563        );
564        let ir = IrModule::parse(&data).unwrap();
565
566        let mut slots = SlotData::new(1);
567        slots.set(0, SlotValue::Text("World".to_string()));
568
569        let manifest = empty_manifest();
570        let page = render_parts(&PageConfig {
571            title: "Slot Test",
572            route_pattern: "/test",
573            manifest: &manifest,
574            config_json: None,
575            body_class: None,
576            personality_css: None,
577            body_prefix: None,
578            render_mode: RenderMode::Phase2SsrReconcile,
579            ir_module: Some(&ir),
580            slots: Some(&slots),
581        });
582
583        assert!(page.html.contains("data-forma-ssr"));
584        // DYN_TEXT wraps content in marker comments for client reconciliation
585        assert!(page
586            .html
587            .contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
588    }
589
590    // -- Security: escape_html tests -----------------------------------------
591
592    #[test]
593    fn escape_html_basic_entities() {
594        assert_eq!(escape_html("<script>"), "&lt;script&gt;");
595        assert_eq!(escape_html("\"quotes\""), "&quot;quotes&quot;");
596        assert_eq!(escape_html("a&b"), "a&amp;b");
597        assert_eq!(escape_html("clean"), "clean");
598    }
599
600    #[test]
601    fn asset_filename_xss_is_escaped() {
602        // If a manifest somehow contained a malicious filename, it must be escaped
603        // in the HTML output rather than injected raw.
604        let mut routes = HashMap::new();
605        routes.insert(
606            "/test".to_string(),
607            RouteAssets {
608                mode: crate::RouteMode::Browser,
609                modulepreload: Vec::new(),
610                js: vec!["\"><script>alert(1)</script>".to_string()],
611                css: vec!["\"><script>alert(2)</script>".to_string()],
612                fonts: vec!["\"><script>alert(3)</script>".to_string()],
613                total_size_br: 0,
614                budget_warn_threshold: 204800,
615                ir: None,
616            },
617        );
618        let manifest = AssetManifest {
619            version: 2,
620            writer: "3.0.0".into(),
621            app: "ledger".into(),
622            aliases: HashMap::new(),
623            build: "test".to_string(),
624            assets: crate::render_contract::fixture_assets(&routes),
625            routes,
626        };
627
628        let page = render_parts(&PageConfig {
629            title: "XSS Test",
630            route_pattern: "/test",
631            manifest: &manifest,
632            config_json: None,
633            body_class: None,
634            personality_css: None,
635            body_prefix: None,
636            render_mode: RenderMode::Phase1ClientMount,
637            ir_module: None,
638            slots: None,
639        });
640
641        // The raw "<script>" must not appear unescaped in the output
642        assert!(
643            !page.html.contains("<script>alert("),
644            "asset filenames must be HTML-escaped, got: {}",
645            page.html
646        );
647        assert!(
648            page.html.contains("&lt;script&gt;"),
649            "escaped <script> should be present"
650        );
651    }
652
653    #[test]
654    fn body_class_with_quotes_is_escaped() {
655        let manifest = empty_manifest();
656        let page = render_parts(&PageConfig {
657            title: "Body Class Test",
658            route_pattern: "/test",
659            manifest: &manifest,
660            config_json: None,
661            body_class: Some("dark\" onload=\"alert(1)"),
662            personality_css: None,
663            body_prefix: None,
664            render_mode: RenderMode::Phase1ClientMount,
665            ir_module: None,
666            slots: None,
667        });
668
669        // The double quote in body_class must be escaped so the attacker
670        // cannot break out of the class="..." attribute.
671        // Raw: class="dark" onload="alert(1)"  (attribute injection)
672        // Safe: class="dark&quot; onload=&quot;alert(1)"  (single quoted attr value)
673        assert!(
674            page.html.contains("&quot;"),
675            "body_class double quote must be escaped"
676        );
677        assert!(
678            !page.html.contains(r#"class="dark" onload="#),
679            "body_class must not allow attribute breakout via unescaped double quote"
680        );
681    }
682
683    #[test]
684    fn personality_css_style_breakout_prevented() {
685        // Table-driven, because the old two-case literal replace passed a test
686        // that only checked the adjacent-literal shape. HTML tag names are
687        // case-insensitive and the tokenizer accepts whitespace and `/` before
688        // `>`, so every one of these used to escape the <style> element.
689        for css in [
690            "body{color:red}</style><script>alert(1)</script>",
691            "body{color:red}</StYlE><script>alert(1)</script>",
692            "body{color:red}</style ><script>alert(1)</script>",
693            "body{color:red}</style\t><script>alert(1)</script>",
694            "body{color:red}</style/><script>alert(1)</script>",
695            "body{color:red}</style\n><script>alert(1)</script>",
696            "body{color:red}</style\x0c><script>alert(1)</script>",
697            "body{color:red}<</style><script>alert(1)</script>",
698            "body{color:red}</sty</style><script>alert(1)</script>",
699        ] {
700            let manifest = empty_manifest();
701            let page = render_parts(&PageConfig {
702                title: "CSS Test",
703                route_pattern: "/test",
704                manifest: &manifest,
705                config_json: None,
706                body_class: None,
707                personality_css: Some(css),
708                body_prefix: None,
709                render_mode: RenderMode::Phase1ClientMount,
710                ir_module: None,
711                slots: None,
712            });
713
714            // Assert the real invariant, not one literal shape: the rendered
715            // <style> region must contain no `</` other than the closing tag
716            // the template itself appends. `</` is the ONLY way out of a
717            // RAWTEXT element, so if none survives, no end tag can be formed.
718            let start = page.html.find("<style").expect("style element");
719            let body_start = page.html[start..].find('>').expect("style open") + start + 1;
720            let end = page.html[body_start..]
721                .find("</style>")
722                .expect("style close")
723                + body_start;
724            let body = &page.html[body_start..end];
725            assert!(
726                !body.contains("</"),
727                "style body must contain no `</` for input {css:?}, got: {body}"
728            );
729        }
730    }
731
732    #[test]
733    fn personality_css_round_trips_legitimate_values() {
734        // `\/` is a valid CSS escape for `/`, so real CSS is unchanged in
735        // meaning. Range media queries in particular must survive: escaping
736        // `<` instead (the obvious alternative) turns `@media (width < 600px)`
737        // into an ident token that parses but never matches.
738        let manifest = empty_manifest();
739        let css = "@media (width < 600px){#t{color:rgb(1,2,3)}}\
740                   .a::after{content:\"</div>\"}\
741                   .b{font:12px/1.4 sans-serif;aspect-ratio:16/9}";
742        let page = render_parts(&PageConfig {
743            title: "CSS Test",
744            route_pattern: "/test",
745            manifest: &manifest,
746            config_json: None,
747            body_class: None,
748            personality_css: Some(css),
749            body_prefix: None,
750            render_mode: RenderMode::Phase1ClientMount,
751            ir_module: None,
752            slots: None,
753        });
754
755        // Untouched: no `<` before a `/`.
756        assert!(page.html.contains("@media (width < 600px)"));
757        assert!(page.html.contains("font:12px/1.4 sans-serif"));
758        assert!(page.html.contains("aspect-ratio:16/9"));
759        // Escaped but value-preserving: `content:"<\/div>"` computes to
760        // `</div>`.
761        assert!(page.html.contains("content:\"<\\/div>\""));
762    }
763
764    #[test]
765    fn config_json_emits_no_raw_angle_brackets() {
766        let manifest = empty_manifest();
767        let value = serde_json::json!({
768            "user": "</script><img src=x onerror=alert(1)>",
769            "note": "<!--<script>",
770        });
771        let page = render_parts(&PageConfig {
772            title: "Config Test",
773            route_pattern: "/test",
774            manifest: &manifest,
775            config_json: Some(&value),
776            body_class: None,
777            personality_css: None,
778            body_prefix: None,
779            render_mode: RenderMode::Phase1ClientMount,
780            ir_module: None,
781            slots: None,
782        });
783
784        let start = page
785            .html
786            .find("window.__FORMA_CONFIG__=")
787            .expect("config json script")
788            + "window.__FORMA_CONFIG__=".len();
789        let end = page.html[start..].find("</script>").expect("close") + start;
790        let json = &page.html[start..end];
791        assert!(
792            !json.contains('<'),
793            "config_json output must contain no raw '<', got: {json}"
794        );
795        // ...and it is value-preserving: < is a valid JSON escape.
796        let parsed: serde_json::Value = serde_json::from_str(json.trim_end_matches(';')).unwrap();
797        assert_eq!(parsed, value);
798    }
799
800    // -- Nonce plumbing (S11) -----------------------------------------------
801
802    #[test]
803    fn named_parts_expose_the_nonce_bound_to_the_document() {
804        let manifest = empty_manifest();
805        let page = render_parts(&PageConfig {
806            title: "Nonce Test",
807            route_pattern: "/test",
808            manifest: &manifest,
809            config_json: Some(&serde_json::json!({"x":1})),
810            body_class: None,
811            personality_css: Some("body{color:red}"),
812            body_prefix: None,
813            render_mode: RenderMode::Phase1ClientMount,
814            ir_module: None,
815            slots: None,
816        });
817
818        // No string-parsing the CSP to recover it.
819        assert!(policy(&page).contains(&format!("'nonce-{}'", page.nonce)));
820        assert!(page
821            .html
822            .contains(&format!("<script nonce=\"{}\">", page.nonce)));
823        assert!(page
824            .html
825            .contains(&format!("<style nonce=\"{}\">", page.nonce)));
826    }
827
828    #[test]
829    fn page_response_headers_are_no_store() {
830        // The HTML embeds a per-request nonce, so it must never be cached.
831        let manifest = empty_manifest();
832        let page = render_parts(&PageConfig {
833            title: "Header Test",
834            route_pattern: "/test",
835            manifest: &manifest,
836            config_json: None,
837            body_class: None,
838            personality_css: None,
839            body_prefix: None,
840            render_mode: RenderMode::Phase1ClientMount,
841            ir_module: None,
842            slots: None,
843        });
844
845        let headers = page.headers;
846        let get = |name: &str| {
847            headers
848                .iter()
849                .find(|(n, _)| n.as_str() == name)
850                .map(|(_, v)| v.to_str().unwrap().to_string())
851        };
852        assert_eq!(get("cache-control").as_deref(), Some("no-store"));
853        assert_eq!(get("x-content-type-options").as_deref(), Some("nosniff"));
854        assert!(get("content-security-policy").is_some());
855        assert!(get("referrer-policy").is_some());
856    }
857
858    // -- CSP / walker interlock (S4) ----------------------------------------
859
860    #[test]
861    fn style_attributes_rendered_by_the_walker_are_permitted_by_the_csp() {
862        // A DYN_ATTR bound to `style` renders a style ATTRIBUTE, so the header
863        // this crate ships must permit attribute styles — otherwise the
864        // framework's default CSP breaks the framework's own output.
865        let mut opcodes = forma_ir::parser::test_helpers::encode_open_tag(0, &[]);
866        opcodes.push(0x06); // DYN_ATTR
867        opcodes.extend_from_slice(&1u32.to_le_bytes()); // "style"
868        opcodes.extend_from_slice(&0u16.to_le_bytes()); // slot 0
869        let data = forma_ir::parser::test_helpers::build_minimal_ir(
870            &["div", "style", "css"],
871            &[(0, 2, 0x01, 0x00, &[])],
872            &opcodes,
873            &[],
874        );
875        let ir = IrModule::parse(&data).unwrap();
876        let mut slots = SlotData::new(1);
877        slots.set(0, SlotValue::Text("color:red".into()));
878
879        let manifest = empty_manifest();
880        let page = render_parts(&PageConfig {
881            title: "Style Attr",
882            route_pattern: "/test",
883            manifest: &manifest,
884            config_json: None,
885            body_class: None,
886            personality_css: None,
887            body_prefix: None,
888            render_mode: RenderMode::Phase2SsrReconcile,
889            ir_module: Some(&ir),
890            slots: Some(&slots),
891        });
892
893        assert!(
894            page.html.contains("style=\"color:red\""),
895            "walker must emit the style attribute: {}",
896            page.html
897        );
898        assert!(
899            policy(&page).contains("style-src-attr 'unsafe-inline'"),
900            "CSP must permit the attribute style the walker just emitted: {}",
901            policy(&page)
902        );
903    }
904}