//! CSS sanitization for custom pages, built on [`lightningcss`]. //! //! The job is to take creator CSS and make it safe to inline on a public page //! without it escaping the user canvas or reaching off-platform. The pipeline: //! //! 1. **Parse** the creator's CSS to an AST (nesting enabled, error-recovery on //! so one bad rule doesn't discard the sheet). //! 2. **Visit** it once to: drop at-rules outside the allowlist, validate every //! `url()` (off-platform URLs are neutralized), strip system-slot hiding //! properties on `.mnw-*` selectors, enforce the strobe budget, flag //! `expression()`, and count rules/selectors against the DoS caps. //! 3. **Scope** by partitioning rules into ones that select elements (style, //! `@media`, `@supports`, `@layer` blocks) and ones that are global by nature //! (`@keyframes`, `@font-face`, `@page`, `@layer` statements). The former are //! re-emitted nested inside `.user-canvas#uc-{owner}` and flattened by //! lightningcss, so scoping is done by the engine's own spec-compliant //! nesting resolver rather than fragile string surgery. Selectors that try to //! escape (`html`, `body`, `:root`, `*`) become `.user-canvas html` etc. and //! match nothing outside the canvas. //! 4. **Append** a reduced-motion override as the final rule. //! //! The parse -> print -> wrap -> reparse round-trip is also what makes brace //! injection impossible: a stray `}` in creator input is a parse error, never a //! literal that could close the wrapper early. use std::convert::Infallible; use lightningcss::declaration::DeclarationBlock; use lightningcss::properties::Property; use lightningcss::properties::custom::Function; use lightningcss::rules::{CssRule, CssRuleList}; use lightningcss::selector::{Component, Selector, SelectorList}; use lightningcss::stylesheet::{ParserFlags, ParserOptions, PrinterOptions, StyleSheet}; use lightningcss::targets::{Features, Targets}; use lightningcss::values::url::Url; use lightningcss::visit_types; use lightningcss::visitor::{Visit, VisitTypes, Visitor}; use super::url_filter::{UrlPolicy, resolve_internal_url}; use super::{MAX_RULES, MAX_SELECTORS, Rejection, RejectionKind}; /// Sanitize creator CSS for a profile or project page, scoping it to /// `.user-canvas#uc-{scope_id}`. See [`scope_and_sanitize`]. pub fn sanitize_css(input: &str, scope_id: &str, policy: &UrlPolicy) -> (String, Vec) { scope_and_sanitize(input, "user-canvas", "uc", scope_id, policy) } /// Sanitize a project's CSS for one of its item pages, scoping it to /// `.item-canvas#ic-{project_id}`. Item pages have no HTML of their own; they /// wear the parent project's styling re-scoped to the item canvas root. pub fn sanitize_item_css( input: &str, project_id: &str, policy: &UrlPolicy, ) -> (String, Vec) { scope_and_sanitize(input, "item-canvas", "ic", project_id, policy) } /// Sanitize creator CSS, scoping it to `.{canvas_class}#{id_prefix}-{scope_id}`. /// /// `scope_id` must be an id-safe token (the owner/project UUID); anything else /// is refused outright. `canvas_class`/`id_prefix` are internal constants. /// Returns the sanitized, scoped stylesheet plus every reference stripped along /// the way. On a fatal parse failure or a complexity-cap breach, returns empty /// CSS and a single explanatory rejection, a page that can't be made safe /// renders as the platform default, never partially. fn scope_and_sanitize( input: &str, canvas_class: &str, id_prefix: &str, scope_id: &str, policy: &UrlPolicy, ) -> (String, Vec) { if input.trim().is_empty() { return (String::new(), Vec::new()); } if !is_id_safe(scope_id) { return ( String::new(), vec![Rejection { kind: RejectionKind::MalformedCss, location: "css".into(), original_value: scope_id.to_string(), reason: "internal: unsafe owner scope".into(), }], ); } let Ok(mut stylesheet) = StyleSheet::parse(input, parser_options()) else { tracing::warn!( input_len = input.len(), "custom-page CSS rejected: unparseable" ); return ( String::new(), vec![Rejection { kind: RejectionKind::MalformedCss, location: "css".into(), original_value: String::new(), reason: "CSS could not be parsed".into(), }], ); }; let mut sanitizer = CssSanitizer { policy, rejections: Vec::new(), rule_count: 0, selector_count: 0, }; // Our visitor never returns Err. let _: Result<(), Infallible> = stylesheet.visit(&mut sanitizer); if sanitizer.rule_count > MAX_RULES || sanitizer.selector_count > MAX_SELECTORS { tracing::warn!( rule_count = sanitizer.rule_count, selector_count = sanitizer.selector_count, "custom-page CSS rejected: exceeds complexity limits (MAX_RULES={MAX_RULES}, MAX_SELECTORS={MAX_SELECTORS})" ); return ( String::new(), vec![Rejection { kind: RejectionKind::ComplexityLimit, location: "css".into(), original_value: format!( "{} rules, {} selectors", sanitizer.rule_count, sanitizer.selector_count ), reason: format!( "stylesheet too complex (limit {MAX_RULES} rules, {MAX_SELECTORS} selectors)" ), }], ); } // THE CAPS ABOVE COUNT THE PARSED TREE; FLATTENING IS MULTIPLICATIVE. // // `rule_count` and `selector_count` are accumulated additively over the AST // as parsed, and they are checked before the scoping wrapper is flattened. // Nested selectors do not add, they multiply: a rule with N selectors // nested inside one with M produces N*M flattened selectors, and the caps // never see it. The css soak target found the consequence in its first // half-hour (infra `bd562c12`) as a 2.1 GB allocation from a 167-byte // stylesheet -- nested `&` selectors, roughly 30x per level: // // depth 1: 43 bytes in -> 723 bytes out // depth 2: 77 bytes in -> 17,253 bytes out // depth 3: 111 bytes in -> 513,153 bytes out // depth 4: 145 bytes in -> 15,390,153 bytes out // // and zero rejections at every depth. Sanitization is render-time, so that // is not a slow save: it is every visitor to that page allocating // gigabytes, which is a denial of service any creator could have written by // accident. // // The projection below multiplies down the nesting tree and refuses the // sheet before anything is flattened. It reuses MAX_SELECTORS and // `ComplexityLimit` rather than inventing a limit: "too complex, render the // platform default" is already this crate's designed answer, and it was // only ever measuring the wrong number. let projected = projected_expansion(&stylesheet.rules, 1); if projected > MAX_SELECTORS as u64 { tracing::warn!( projected, "custom-page CSS rejected: nested selectors project to {projected} flattened selectors (limit {MAX_SELECTORS})" ); return ( String::new(), vec![Rejection { kind: RejectionKind::ComplexityLimit, location: "css".into(), original_value: format!("{projected} flattened selectors"), reason: format!( "nested selectors expand to more than {MAX_SELECTORS} rules once flattened" ), }], ); } let mut rejections = sanitizer.rejections; // Partition surviving rules: element-selecting rules get scoped; rules that // are global by nature stay top-level (they have no document selectors, and // they cannot legally nest inside a style rule anyway). let rules = std::mem::take(&mut stylesheet.rules.0); let mut global = Vec::new(); let mut scopable = Vec::new(); for rule in rules { match rule { CssRule::Ignored => {} CssRule::Keyframes(_) | CssRule::FontFace(_) | CssRule::Page(_) | CssRule::LayerStatement(_) => global.push(rule), _ => scopable.push(rule), } } let scope_selector = format!(".{canvas_class}#{id_prefix}-{scope_id}"); let global_css = print_rules(global); let scopable_css = print_rules(scopable); // Wrap the element-selecting rules in the canvas selector and let // lightningcss flatten the nesting (scoping done by the engine). let flat_scoped = if scopable_css.trim().is_empty() { String::new() } else { let wrapped = format!("{scope_selector} {{\n{scopable_css}\n}}"); match StyleSheet::parse(&wrapped, parser_options()) { Ok(sheet) => sheet .to_css(PrinterOptions { targets: Targets { browsers: None, include: Features::Nesting, exclude: Features::empty(), }, ..Default::default() }) .map(|r| r.code) .unwrap_or_default(), Err(_) => { // Should not happen on already-sanitized input; fail safe. rejections.push(Rejection { kind: RejectionKind::MalformedCss, location: "css".into(), original_value: String::new(), reason: "internal: re-scope failed".into(), }); String::new() } } }; // NOT VERIFIED AGAINST THE PRINTER, and that is a known gap rather than an // oversight. See infra `bd562c12`: the css soak target found that // lightningcss's nesting flattener can print a selector whose text no // longer parses back to the scoped AST -- `-- &x` prints as `#uc-abcx`, the // trailing type selector merging into the canvas id. // // A guard that reparsed this function's own printed output and dropped // unscoped rules was written, and then REMOVED, because it was worse than // what it fixed. Every mis-flattened shape found NARROWS the match (a // longer identifier matches fewer elements), so the rules it dropped were // inert. The reparse, meanwhile, ran over the FLATTENED sheet, which is far // larger and more deeply nested than the input: 214 bytes of creator CSS // flattens to 219 KB, and parsing that overflowed a 2 MB stack -- tokio's // default worker size. A Rust stack overflow is not a catchable panic, so // that traded an inert rule for an abort of the whole server process, // reachable by any visitor to the page. // // Fixing it safely needs a check that does not reparse the output, and // choosing one is a design decision rather than a mechanical fix. // Reduced-motion override, always last (decision #3). Scoped to the canvas. let reduced_motion = format!( "@media (prefers-reduced-motion: reduce){{{scope_selector},{scope_selector} *{{animation:none!important;transition:none!important}}}}" ); let mut out = String::new(); if !global_css.trim().is_empty() { out.push_str(global_css.trim()); out.push('\n'); } if !flat_scoped.trim().is_empty() { out.push_str(flat_scoped.trim()); out.push('\n'); } out.push_str(&reduced_motion); (escape_lt_for_style_element(&out), rejections) } /// How far this rule tree expands when nesting is flattened. /// /// Not a selector count: a compound like `&&&&` is ONE selector whose four /// nesting components each expand to the parent's flattened text, so the /// growth is textual and multiplies down the tree. Counting selector-list /// length instead measures the wrong axis entirely and waves the input /// through, which is what a first attempt at this did. /// /// `factor` is what the enclosing rules already multiply by. Saturating /// throughout: the answer only has to be "past the cap", and an overflow would /// wrap to a small number and admit exactly the input this refuses. fn projected_expansion(rules: &CssRuleList<'_>, factor: u64) -> u64 { let mut worst = factor; for rule in &rules.0 { let (own, nested) = match rule { CssRule::Style(style) => { // Each nesting reference reproduces the parent selector; the // selector list multiplies on top of that. let refs: u64 = style .selectors .0 .iter() .map(|s| { s.iter_raw_match_order() .filter(|c| matches!(c, Component::Nesting)) .count() as u64 }) .sum(); let width = refs.max(style.selectors.0.len() as u64).max(1); (factor.saturating_mul(width), Some(&style.rules)) } CssRule::Media(r) => (factor, Some(&r.rules)), CssRule::Supports(r) => (factor, Some(&r.rules)), CssRule::LayerBlock(r) => (factor, Some(&r.rules)), _ => (factor, None), }; worst = worst.max(own); if let Some(inner) = nested { worst = worst.max(projected_expansion(inner, own)); } if worst > u64::from(u32::MAX) { return worst; // Already past any cap; stop walking. } } worst } /// Make the sheet safe to inline raw inside an HTML `..."` /// would otherwise break out and inject markup. lightningcss faithfully /// preserves the literal `<` inside CSS string tokens, so we escape every `<` /// in the final output to its CSS hex escape `\3c ` (the trailing space /// terminates the hex digits). `<` is not valid CSS syntax outside string/url /// tokens, so this rewrite is lossless where it matters and never produces a /// literal `<` for the HTML parser to act on. ``, `