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    #[cfg(feature = "navigation")]
245    let navigation_metadata = crate::navigation::initial_metadata(config, ctx)?;
246    #[cfg(not(feature = "navigation"))]
247    let navigation_metadata = String::new();
248    write_document_parts(
249        config,
250        nonce,
251        ssr_body,
252        island_state.map(forma_ir::IslandState::as_json),
253        ctx,
254        max_output_bytes,
255        counts,
256        &navigation_metadata,
257        false,
258    )
259}
260
261#[allow(clippy::too_many_arguments)]
262pub(crate) fn write_document_parts(
263    config: &PageConfig,
264    nonce: &CspNonce,
265    ssr_body: Option<&str>,
266    island_state: Option<&str>,
267    ctx: &RenderCtx,
268    max_output_bytes: usize,
269    counts: crate::RenderCounts,
270    navigation_metadata: &str,
271    owner_branch: bool,
272) -> Result<String, RenderError> {
273    // Defence in depth: CspNonce already rejects anything attribute- or
274    // directive-significant, so this never changes a real nonce.
275    let nonce_attr = escape_html(nonce.as_str());
276    let assets = resolve_assets(config.manifest, config.route_pattern)?;
277    let mut preloads = Vec::new();
278    let mut seen = std::collections::HashSet::new();
279    for file in ctx.module_preloads(config.route_pattern) {
280        let url = format!("/_assets/{file}");
281        if assets.js_urls.iter().any(|asset| asset.url == url) || !seen.insert(file) {
282            continue;
283        }
284        preloads.push(resource(config.manifest, file, config.route_pattern)?);
285    }
286    let head = build_head(
287        config.title,
288        &nonce_attr,
289        &assets,
290        config.personality_css,
291        &preloads,
292        ctx.head_links(),
293    );
294    let parts = build_body_parts(&nonce_attr, config, &assets.js_urls);
295    // The root names the build the manifest names (FORMA-186, DR-29): core compares it
296    // with the identity compiled into the bundle before it activates anything. The
297    // manifest is data, so it is escaped as any attribute value is, and no render input
298    // can set or override it.
299    let static_route = config
300        .manifest
301        .route(config.route_pattern)
302        .is_some_and(crate::RouteAssets::is_static_ssr);
303    let build_attr = if static_route {
304        String::new()
305    } else {
306        format!(
307            " data-forma-build=\"{}\"",
308            escape_html(&config.manifest.build)
309        )
310    };
311
312    // Every byte of the completed document, including state tags/envelope,
313    // counts against the same response limit. Charge before allocating/copying
314    // the final document; body walk counts remain a separate diagnostic.
315    let fragments = [
316        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n",
317        head.as_str(),
318        if island_state.is_some() {
319            "<script type=\"application/json\" data-island-state>"
320        } else {
321            ""
322        },
323        island_state.unwrap_or(""),
324        if island_state.is_some() {
325            "</script>\n"
326        } else {
327            ""
328        },
329        navigation_metadata,
330        "</head>\n<body",
331        parts.body_class_attr.as_str(),
332        ">\n",
333        parts.body_prefix.as_str(),
334        "<div id=\"app\"",
335        build_attr.as_str(),
336        if owner_branch {
337            " data-forma-router-root"
338        } else {
339            ""
340        },
341        if ssr_body.is_some() {
342            if static_route {
343                ">"
344            } else {
345                " data-forma-ssr>"
346            }
347        } else {
348            "></div>"
349        },
350        ssr_body.unwrap_or(""),
351        if ssr_body.is_some() { "</div>" } else { "" },
352        "\n",
353        parts.config_json_tag.as_str(),
354        parts.page_js_tag.as_str(),
355        "\n",
356        parts.sw_script.as_str(),
357        "</body>\n</html>",
358    ];
359    let total = fragments
360        .iter()
361        .try_fold(0usize, |total, part| total.checked_add(part.len()));
362    let Some(total) = total.filter(|total| *total <= max_output_bytes) else {
363        return Err(RenderError::Budget {
364            route: config.route_pattern.into(),
365            component_path: Vec::new(),
366            counts,
367            cause: forma_ir::IrError::OutputLimitExceeded {
368                limit: max_output_bytes,
369            },
370        });
371    };
372    let mut html = String::with_capacity(total);
373    for fragment in fragments {
374        html.push_str(fragment);
375    }
376
377    Ok(html)
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::render_contract::{RenderFixture as PageConfig, RenderPath as RenderMode};
384    use crate::types::RouteAssets;
385    use forma_ir::parser::IrModule;
386    use forma_ir::slot::{SlotData, SlotValue};
387    use std::collections::HashMap;
388
389    fn render_parts(config: &PageConfig) -> crate::PageParts {
390        let ctx = RenderCtx::new();
391        match config.render_mode {
392            RenderMode::Phase1ClientMount => crate::render_contract::render_shell(config, &ctx),
393            RenderMode::Phase2SsrReconcile => crate::render_contract::render_ssr(config, &ctx),
394        }
395        .expect("valid fixture renders")
396        .into_parts_unchecked()
397    }
398    fn policy(page: &crate::PageParts) -> &str {
399        page.headers
400            .iter()
401            .find(|(name, _)| name == "content-security-policy")
402            .unwrap()
403            .1
404            .to_str()
405            .unwrap()
406    }
407
408    /// Build a minimal AssetManifest with no routes for testing.
409    fn empty_manifest() -> AssetManifest {
410        AssetManifest {
411            version: 2,
412            writer: "3.0.0".into(),
413            app: "ledger".into(),
414            aliases: HashMap::new(),
415            build: "test".to_string(),
416            assets: HashMap::new(),
417            routes: HashMap::new(),
418        }
419    }
420
421    #[test]
422    fn a_legacy_manifest_is_refused_before_removed_wasm_fields_can_be_used() {
423        let error = serde_json::from_value::<AssetManifest>(serde_json::json!({
424            "version":1,"build_hash":"legacy","assets":{},"routes":{},
425            "wasm":{"loader":"legacy-loader.js","binary":"legacy.wasm"}
426        }))
427        .unwrap_err();
428        assert!(error.to_string().contains("MANIFEST-101"));
429    }
430
431    /// Build a valid IR module that renders "<p>hello</p>".
432    fn hello_ir_module() -> IrModule {
433        use forma_ir::parser::test_helpers::{
434            build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
435        };
436
437        let mut opcodes = Vec::new();
438        opcodes.extend_from_slice(&encode_open_tag(0, &[])); // <p>
439        opcodes.extend_from_slice(&encode_text(1)); // hello
440        opcodes.extend_from_slice(&encode_close_tag(0)); // </p>
441
442        let data = build_minimal_ir(&["p", "hello"], &[], &opcodes, &[]);
443        IrModule::parse(&data).unwrap()
444    }
445
446    #[test]
447    fn phase1_renders_empty_app_div() {
448        let manifest = empty_manifest();
449        let page = render_parts(&PageConfig {
450            title: "Test",
451            route_pattern: "/test",
452            manifest: &manifest,
453            config_json: None,
454            body_class: None,
455            personality_css: None,
456            body_prefix: None,
457            render_mode: RenderMode::Phase1ClientMount,
458            ir_module: None,
459            slots: None,
460        });
461
462        // The root carries the build's identity (FORMA-186); the manifest's build here is "test".
463        assert!(page
464            .html
465            .contains("<div id=\"app\" data-forma-build=\"test\"></div>"));
466        // Check that the #app div does NOT have the SSR attribute
467        assert!(!page.html.contains("data-forma-ssr"));
468    }
469
470    #[test]
471    fn phase2_renders_ssr_content_with_data_attr() {
472        let manifest = empty_manifest();
473        let ir = hello_ir_module();
474        let slots = SlotData::new(0);
475
476        let page = render_parts(&PageConfig {
477            title: "SSR Test",
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: RenderMode::Phase2SsrReconcile,
485            ir_module: Some(&ir),
486            slots: Some(&slots),
487        });
488
489        assert!(page.html.contains("data-forma-ssr"));
490        assert!(page.html.contains("<p>hello</p>"));
491        assert!(page.html.contains(
492            "<div id=\"app\" data-forma-build=\"test\" data-forma-ssr><p>hello</p></div>"
493        ));
494    }
495
496    /// The deployment identity (FORMA-186, DR-29) is the manifest's `build`, written
497    /// on the root of a server-rendered page and of a browser shell, once, escaped
498    /// as any attribute value is: a manifest is data, never markup.
499    ///
500    /// Verified by: crates/forma-server/src/template.rs > "the_root_carries_the_manifests_build_escaped_on_both_forms"
501    #[test]
502    fn the_root_carries_the_manifests_build_escaped_on_both_forms() {
503        let mut manifest = empty_manifest();
504        manifest.build = "5f0c9a1e7d2b4c86".into();
505        let ir = hello_ir_module();
506        let slots = SlotData::new(0);
507        for (mode, ir_module, slots) in [
508            (RenderMode::Phase1ClientMount, None, None),
509            (RenderMode::Phase2SsrReconcile, Some(&ir), Some(&slots)),
510        ] {
511            let page = render_parts(&PageConfig {
512                title: "Identity",
513                route_pattern: "/test",
514                manifest: &manifest,
515                config_json: None,
516                body_class: None,
517                personality_css: None,
518                body_prefix: None,
519                render_mode: mode,
520                ir_module,
521                slots,
522            });
523            assert_eq!(
524                page.html.matches("data-forma-build=").count(),
525                1,
526                "exactly one stamp: {}",
527                page.html
528            );
529            assert!(page
530                .html
531                .contains("<div id=\"app\" data-forma-build=\"5f0c9a1e7d2b4c86\""));
532        }
533        manifest.build = "a\"b<c>&d".into();
534        let page = render_parts(&PageConfig {
535            title: "Identity",
536            route_pattern: "/test",
537            manifest: &manifest,
538            config_json: None,
539            body_class: None,
540            personality_css: None,
541            body_prefix: None,
542            render_mode: RenderMode::Phase1ClientMount,
543            ir_module: None,
544            slots: None,
545        });
546        assert!(page
547            .html
548            .contains("data-forma-build=\"a&quot;b&lt;c&gt;&amp;d\""));
549        assert!(!page.html.contains("a\"b<c>"));
550    }
551
552    #[test]
553    fn phase2_ssr_preserves_head_content() {
554        let manifest = empty_manifest();
555        let ir = hello_ir_module();
556        let slots = SlotData::new(0);
557
558        let page = render_parts(&PageConfig {
559            title: "Head Test",
560            route_pattern: "/test",
561            manifest: &manifest,
562            config_json: Some(&serde_json::json!({"test":true})),
563            body_class: Some("dark"),
564            personality_css: Some(":root{--c:red}"),
565            body_prefix: Some(&crate::RawHtml::trusted("<nav>nav</nav>")),
566            render_mode: RenderMode::Phase2SsrReconcile,
567            ir_module: Some(&ir),
568            slots: Some(&slots),
569        });
570
571        assert!(page.html.contains("<title>Head Test</title>"));
572        assert!(page.html.contains("window.__FORMA_CONFIG__="));
573        assert!(page.html.contains("class=\"dark\""));
574        assert!(page.html.contains(":root{--c:red}"));
575        assert!(page.html.contains("<nav>nav</nav>"));
576        assert!(page.html.contains("<p>hello</p>"));
577        assert!(page.html.contains("data-forma-ssr"));
578    }
579
580    #[test]
581    fn phase2_with_slot_data() {
582        use forma_ir::parser::test_helpers::{build_minimal_ir, encode_close_tag, encode_open_tag};
583
584        // Build IR with DYN_TEXT referencing slot 0
585        let mut opcodes = Vec::new();
586        opcodes.extend_from_slice(&encode_open_tag(0, &[])); // <span>
587                                                             // DYN_TEXT opcode: 0x05 + slot_id(u16) + marker_id(u16)
588        opcodes.push(0x05);
589        opcodes.extend_from_slice(&0u16.to_le_bytes()); // slot_id = 0
590        opcodes.extend_from_slice(&0u16.to_le_bytes()); // marker_id = 0
591        opcodes.extend_from_slice(&encode_close_tag(0)); // </span>
592
593        let data = build_minimal_ir(
594            &["span", "name"],          // strings
595            &[(0, 1, 0x01, 0x00, &[])], // slot: id=0, name_str_idx=1, type=Text, source=Server
596            &opcodes,
597            &[],
598        );
599        let ir = IrModule::parse(&data).unwrap();
600
601        let mut slots = SlotData::new(1);
602        slots.set(0, SlotValue::Text("World".to_string()));
603
604        let manifest = empty_manifest();
605        let page = render_parts(&PageConfig {
606            title: "Slot Test",
607            route_pattern: "/test",
608            manifest: &manifest,
609            config_json: None,
610            body_class: None,
611            personality_css: None,
612            body_prefix: None,
613            render_mode: RenderMode::Phase2SsrReconcile,
614            ir_module: Some(&ir),
615            slots: Some(&slots),
616        });
617
618        assert!(page.html.contains("data-forma-ssr"));
619        // DYN_TEXT wraps content in marker comments for client reconciliation
620        assert!(page
621            .html
622            .contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
623    }
624
625    // -- Security: escape_html tests -----------------------------------------
626
627    #[test]
628    fn escape_html_basic_entities() {
629        assert_eq!(escape_html("<script>"), "&lt;script&gt;");
630        assert_eq!(escape_html("\"quotes\""), "&quot;quotes&quot;");
631        assert_eq!(escape_html("a&b"), "a&amp;b");
632        assert_eq!(escape_html("clean"), "clean");
633    }
634
635    #[test]
636    fn asset_filename_xss_is_escaped() {
637        // If a manifest somehow contained a malicious filename, it must be escaped
638        // in the HTML output rather than injected raw.
639        let mut routes = HashMap::new();
640        routes.insert(
641            "/test".to_string(),
642            RouteAssets {
643                #[cfg(feature = "navigation")]
644                navigation: None,
645                mode: crate::RouteMode::Browser,
646                modulepreload: Vec::new(),
647                js: vec!["\"><script>alert(1)</script>".to_string()],
648                css: vec!["\"><script>alert(2)</script>".to_string()],
649                fonts: vec!["\"><script>alert(3)</script>".to_string()],
650                total_size_br: 0,
651                budget_warn_threshold: 204800,
652                ir: None,
653            },
654        );
655        let manifest = AssetManifest {
656            version: 2,
657            writer: "3.0.0".into(),
658            app: "ledger".into(),
659            aliases: HashMap::new(),
660            build: "test".to_string(),
661            assets: crate::render_contract::fixture_assets(&routes),
662            routes,
663        };
664
665        let page = render_parts(&PageConfig {
666            title: "XSS Test",
667            route_pattern: "/test",
668            manifest: &manifest,
669            config_json: None,
670            body_class: None,
671            personality_css: None,
672            body_prefix: None,
673            render_mode: RenderMode::Phase1ClientMount,
674            ir_module: None,
675            slots: None,
676        });
677
678        // The raw "<script>" must not appear unescaped in the output
679        assert!(
680            !page.html.contains("<script>alert("),
681            "asset filenames must be HTML-escaped, got: {}",
682            page.html
683        );
684        assert!(
685            page.html.contains("&lt;script&gt;"),
686            "escaped <script> should be present"
687        );
688    }
689
690    #[test]
691    fn body_class_with_quotes_is_escaped() {
692        let manifest = empty_manifest();
693        let page = render_parts(&PageConfig {
694            title: "Body Class Test",
695            route_pattern: "/test",
696            manifest: &manifest,
697            config_json: None,
698            body_class: Some("dark\" onload=\"alert(1)"),
699            personality_css: None,
700            body_prefix: None,
701            render_mode: RenderMode::Phase1ClientMount,
702            ir_module: None,
703            slots: None,
704        });
705
706        // The double quote in body_class must be escaped so the attacker
707        // cannot break out of the class="..." attribute.
708        // Raw: class="dark" onload="alert(1)"  (attribute injection)
709        // Safe: class="dark&quot; onload=&quot;alert(1)"  (single quoted attr value)
710        assert!(
711            page.html.contains("&quot;"),
712            "body_class double quote must be escaped"
713        );
714        assert!(
715            !page.html.contains(r#"class="dark" onload="#),
716            "body_class must not allow attribute breakout via unescaped double quote"
717        );
718    }
719
720    #[test]
721    fn personality_css_style_breakout_prevented() {
722        // Table-driven, because the old two-case literal replace passed a test
723        // that only checked the adjacent-literal shape. HTML tag names are
724        // case-insensitive and the tokenizer accepts whitespace and `/` before
725        // `>`, so every one of these used to escape the <style> element.
726        for css in [
727            "body{color:red}</style><script>alert(1)</script>",
728            "body{color:red}</StYlE><script>alert(1)</script>",
729            "body{color:red}</style ><script>alert(1)</script>",
730            "body{color:red}</style\t><script>alert(1)</script>",
731            "body{color:red}</style/><script>alert(1)</script>",
732            "body{color:red}</style\n><script>alert(1)</script>",
733            "body{color:red}</style\x0c><script>alert(1)</script>",
734            "body{color:red}<</style><script>alert(1)</script>",
735            "body{color:red}</sty</style><script>alert(1)</script>",
736        ] {
737            let manifest = empty_manifest();
738            let page = render_parts(&PageConfig {
739                title: "CSS Test",
740                route_pattern: "/test",
741                manifest: &manifest,
742                config_json: None,
743                body_class: None,
744                personality_css: Some(css),
745                body_prefix: None,
746                render_mode: RenderMode::Phase1ClientMount,
747                ir_module: None,
748                slots: None,
749            });
750
751            // Assert the real invariant, not one literal shape: the rendered
752            // <style> region must contain no `</` other than the closing tag
753            // the template itself appends. `</` is the ONLY way out of a
754            // RAWTEXT element, so if none survives, no end tag can be formed.
755            let start = page.html.find("<style").expect("style element");
756            let body_start = page.html[start..].find('>').expect("style open") + start + 1;
757            let end = page.html[body_start..]
758                .find("</style>")
759                .expect("style close")
760                + body_start;
761            let body = &page.html[body_start..end];
762            assert!(
763                !body.contains("</"),
764                "style body must contain no `</` for input {css:?}, got: {body}"
765            );
766        }
767    }
768
769    #[test]
770    fn personality_css_round_trips_legitimate_values() {
771        // `\/` is a valid CSS escape for `/`, so real CSS is unchanged in
772        // meaning. Range media queries in particular must survive: escaping
773        // `<` instead (the obvious alternative) turns `@media (width < 600px)`
774        // into an ident token that parses but never matches.
775        let manifest = empty_manifest();
776        let css = "@media (width < 600px){#t{color:rgb(1,2,3)}}\
777                   .a::after{content:\"</div>\"}\
778                   .b{font:12px/1.4 sans-serif;aspect-ratio:16/9}";
779        let page = render_parts(&PageConfig {
780            title: "CSS Test",
781            route_pattern: "/test",
782            manifest: &manifest,
783            config_json: None,
784            body_class: None,
785            personality_css: Some(css),
786            body_prefix: None,
787            render_mode: RenderMode::Phase1ClientMount,
788            ir_module: None,
789            slots: None,
790        });
791
792        // Untouched: no `<` before a `/`.
793        assert!(page.html.contains("@media (width < 600px)"));
794        assert!(page.html.contains("font:12px/1.4 sans-serif"));
795        assert!(page.html.contains("aspect-ratio:16/9"));
796        // Escaped but value-preserving: `content:"<\/div>"` computes to
797        // `</div>`.
798        assert!(page.html.contains("content:\"<\\/div>\""));
799    }
800
801    #[test]
802    fn config_json_emits_no_raw_angle_brackets() {
803        let manifest = empty_manifest();
804        let value = serde_json::json!({
805            "user": "</script><img src=x onerror=alert(1)>",
806            "note": "<!--<script>",
807        });
808        let page = render_parts(&PageConfig {
809            title: "Config Test",
810            route_pattern: "/test",
811            manifest: &manifest,
812            config_json: Some(&value),
813            body_class: None,
814            personality_css: None,
815            body_prefix: None,
816            render_mode: RenderMode::Phase1ClientMount,
817            ir_module: None,
818            slots: None,
819        });
820
821        let start = page
822            .html
823            .find("window.__FORMA_CONFIG__=")
824            .expect("config json script")
825            + "window.__FORMA_CONFIG__=".len();
826        let end = page.html[start..].find("</script>").expect("close") + start;
827        let json = &page.html[start..end];
828        assert!(
829            !json.contains('<'),
830            "config_json output must contain no raw '<', got: {json}"
831        );
832        // ...and it is value-preserving: < is a valid JSON escape.
833        let parsed: serde_json::Value = serde_json::from_str(json.trim_end_matches(';')).unwrap();
834        assert_eq!(parsed, value);
835    }
836
837    // -- Nonce plumbing (S11) -----------------------------------------------
838
839    #[test]
840    fn named_parts_expose_the_nonce_bound_to_the_document() {
841        let manifest = empty_manifest();
842        let page = render_parts(&PageConfig {
843            title: "Nonce Test",
844            route_pattern: "/test",
845            manifest: &manifest,
846            config_json: Some(&serde_json::json!({"x":1})),
847            body_class: None,
848            personality_css: Some("body{color:red}"),
849            body_prefix: None,
850            render_mode: RenderMode::Phase1ClientMount,
851            ir_module: None,
852            slots: None,
853        });
854
855        // No string-parsing the CSP to recover it.
856        assert!(policy(&page).contains(&format!("'nonce-{}'", page.nonce)));
857        assert!(page
858            .html
859            .contains(&format!("<script nonce=\"{}\">", page.nonce)));
860        assert!(page
861            .html
862            .contains(&format!("<style nonce=\"{}\">", page.nonce)));
863    }
864
865    #[test]
866    fn page_response_headers_are_no_store() {
867        // The HTML embeds a per-request nonce, so it must never be cached.
868        let manifest = empty_manifest();
869        let page = render_parts(&PageConfig {
870            title: "Header Test",
871            route_pattern: "/test",
872            manifest: &manifest,
873            config_json: None,
874            body_class: None,
875            personality_css: None,
876            body_prefix: None,
877            render_mode: RenderMode::Phase1ClientMount,
878            ir_module: None,
879            slots: None,
880        });
881
882        let headers = page.headers;
883        let get = |name: &str| {
884            headers
885                .iter()
886                .find(|(n, _)| n.as_str() == name)
887                .map(|(_, v)| v.to_str().unwrap().to_string())
888        };
889        assert_eq!(get("cache-control").as_deref(), Some("no-store"));
890        assert_eq!(get("x-content-type-options").as_deref(), Some("nosniff"));
891        assert!(get("content-security-policy").is_some());
892        assert!(get("referrer-policy").is_some());
893    }
894
895    // -- CSP / walker interlock (S4) ----------------------------------------
896
897    #[test]
898    fn style_attributes_rendered_by_the_walker_are_permitted_by_the_csp() {
899        // A DYN_ATTR bound to `style` renders a style ATTRIBUTE, so the header
900        // this crate ships must permit attribute styles — otherwise the
901        // framework's default CSP breaks the framework's own output.
902        let mut opcodes = forma_ir::parser::test_helpers::encode_open_tag(0, &[]);
903        opcodes.push(0x06); // DYN_ATTR
904        opcodes.extend_from_slice(&1u32.to_le_bytes()); // "style"
905        opcodes.extend_from_slice(&0u16.to_le_bytes()); // slot 0
906        let data = forma_ir::parser::test_helpers::build_minimal_ir(
907            &["div", "style", "css"],
908            &[(0, 2, 0x01, 0x00, &[])],
909            &opcodes,
910            &[],
911        );
912        let ir = IrModule::parse(&data).unwrap();
913        let mut slots = SlotData::new(1);
914        slots.set(0, SlotValue::Text("color:red".into()));
915
916        let manifest = empty_manifest();
917        let page = render_parts(&PageConfig {
918            title: "Style Attr",
919            route_pattern: "/test",
920            manifest: &manifest,
921            config_json: None,
922            body_class: None,
923            personality_css: None,
924            body_prefix: None,
925            render_mode: RenderMode::Phase2SsrReconcile,
926            ir_module: Some(&ir),
927            slots: Some(&slots),
928        });
929
930        assert!(
931            page.html.contains("style=\"color:red\""),
932            "walker must emit the style attribute: {}",
933            page.html
934        );
935        assert!(
936            policy(&page).contains("style-src-attr 'unsafe-inline'"),
937            "CSP must permit the attribute style the walker just emitted: {}",
938            policy(&page)
939        );
940    }
941}