//! 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 mut stylesheet = match StyleSheet::parse(input, parser_options()) { Ok(s) => s, Err(_) => { 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 { 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)"), }], ); } 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() } } }; // Reduced-motion override, always last (decision #3). Scoped to the canvas. let reduced_motion = format!( "@media (prefers-reduced-motion: reduce){{{sel},{sel} *{{animation:none!important;transition:none!important}}}}", sel = scope_selector ); 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) } /// 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. ``, `