//! Custom Pages sanitization. //! //! //! //! Creators author raw HTML and CSS for their profile and project pages. This //! crate turns that input into safe, closed-system page content: no scripting, //! no off-platform references, and CSS that cannot escape the user canvas to //! touch platform chrome. //! //! It lives outside the server for one reason: a fuzz target that has to build //! the whole server is a fuzz target nobody runs. It imports nothing from MNW. //! The consumer supplies a [`UrlPolicy`] and an owner scope, and gets back //! sanitized output plus every reference that was stripped. //! //! Three layers, one gate: //! - [`url_filter`], the single rule that every URL (HTML attribute or CSS //! `url()`) must resolve to MNW itself. //! - [`html_sanitizer`], an `ammonia` allowlist (structure, text, media; no //! script/embed/form/inline-style). //! - [`css_sanitizer`], a `lightningcss` pass that scopes all selectors to the //! canvas, filters at-rules, validates `url()`, and strips system-slot hiding. //! //! [`oracle`] is the crate's contract as an executable assertion; see its own //! docs for why it is a public module rather than a fuzzing-only one. //! //! Sanitization is **render-time**, not write-time: the editor stores the //! creator's *raw* HTML/CSS, and `sanitize_page` runs on every render of the //! public page (on the cookieless, `default-src 'none'` host). The save path //! runs the sanitizer only to *count* what would be stripped, for the editor's //! blocked-references panel, it does not persist sanitized output. So the XSS //! boundary is the render call, not the database: never inline stored //! `custom_html`/`custom_css` anywhere without running them through this module //! first. mod css_sanitizer; mod html_sanitizer; mod url_filter; pub use css_sanitizer::{sanitize_css, sanitize_item_css}; pub use html_sanitizer::sanitize_html; pub use url_filter::UrlPolicy; /// Why a single reference was stripped. Surfaced in the editor's /// blocked-references panel, the primary teaching surface for the /// closed-system rule. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RejectionKind { /// URL resolved to an off-platform host. ExternalUrl, /// URL carried a non-https scheme (`data:`, `javascript:`, `mailto:`, ...). DisallowedScheme, /// URL could not be parsed. MalformedUrl, /// A CSS at-rule outside the allowlist (`@import`, `@namespace`, ...). BlockedAtRule, /// A dangerous CSS function (`expression()`). BlockedFunction, /// A property that would hide a non-removable system slot (`.mnw-*`). HidingProperty, /// A fast infinite animation (strobe guard). AnimationBudget, /// Stylesheet exceeded a complexity cap (DoS guard). ComplexityLimit, /// CSS that could not be parsed at all. MalformedCss, } /// One stripped reference, with enough context for the editor to point at it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Rejection { pub kind: RejectionKind, /// Human-readable origin, e.g. `"img src"`, `"css url()"`, `"@import"`. pub location: String, /// The value as the creator wrote it. pub original_value: String, /// One-line explanation shown to the creator. pub reason: String, } /// Maximum style rules in a sanitized sheet (quadratic-matching DoS guard). /// Far above any reasonable page. pub(crate) const MAX_RULES: usize = 5000; /// Maximum selectors across a sanitized sheet. pub(crate) const MAX_SELECTORS: usize = 10000; /// Sanitize a full custom page (HTML + CSS together). /// /// `owner_scope` is the id woven into the canvas selector /// `.user-canvas#uc-{owner_scope}` that all CSS is confined to, pass the /// owner's UUID. Returns sanitized HTML, sanitized CSS, and every reference the /// sanitizer stripped (deduplicated only by being appended in order). pub fn sanitize_page( html: &str, css: &str, owner_scope: &str, policy: &UrlPolicy, ) -> (String, String, Vec) { let (clean_html, mut rejections) = sanitize_html(html, policy); let (clean_css, css_rejections) = sanitize_css(css, owner_scope, policy); rejections.extend(css_rejections); (clean_html, clean_css, rejections) } #[cfg(test)] mod tests { use super::*; 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() } #[test] fn page_sanitizes_both_and_collects_rejections() { let (html, css, rej) = sanitize_page( "

hi

", "body { color: red } .x { background: url(https://evil.com/y) }", "11111111-1111-1111-1111-111111111111", &policy(), ); assert!(html.contains("hi")); assert!(!html.contains("evil")); // CSS is scoped to the canvas. assert!(css.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111")); // body got neutralized into the canvas; off-platform url stripped. assert!(!css.contains("evil.com")); // At least the two external refs were recorded. assert!(rej.len() >= 2, "expected rejections, got {rej:?}"); } } pub mod oracle { //! The crate's contract, written as an executable assertion. //! //! A normal public module rather than something behind a `fuzzing` feature, //! because two callers need it and neither is the fuzzer: the committed //! regression replay in `tests/regressions.rs` runs it on stable, and the //! soak tier's libFuzzer target runs it on nightly. A property asserted in //! one and not the other is a property that drifts, which is the whole //! reason the oracle lives in the crate (the shape `git-command` set). //! //! **Not panicking is the weakest thing a target can assert**, and it is not //! what this crate is for. Two properties are: //! //! - the **safety floor**: nothing executable survives either path, so no //! `"); } #[test] #[should_panic(expected = ""); } #[test] #[should_panic(expected = ""#); } #[test] #[should_panic(expected = ""); } #[test] #[should_panic(expected = "event handler")] fn catches_an_event_handler() { check(r#""#); } #[test] #[should_panic(expected = "event handler")] fn catches_an_event_handler_with_an_unquoted_value() { check(""); } #[test] #[should_panic(expected = "event handler")] fn catches_an_event_handler_with_space_before_equals() { check(r#""#); } #[test] #[should_panic(expected = "javascript:")] fn catches_a_javascript_href() { check(r#"x"#); } #[test] #[should_panic(expected = "javascript:")] fn catches_a_javascript_href_single_quoted() { check("x"); } #[test] #[should_panic(expected = "javascript:")] fn catches_a_scheme_hidden_behind_a_character_reference() { // A browser decodes before it resolves, so the oracle has to. // Without `decode_entities` this reads as harmless. check(r#"x"#); } #[test] #[should_panic(expected = "vbscript:")] fn catches_a_vbscript_href() { check(r#"x"#); } #[test] #[should_panic(expected = "off-platform")] fn catches_an_off_platform_href() { check(r#"x"#); } #[test] #[should_panic(expected = "off-platform")] fn catches_an_off_platform_image() { check(r#""#); } #[test] #[should_panic(expected = "off-platform")] fn catches_one_bad_candidate_in_a_srcset() { // The whole attribute is untrustworthy if any candidate is: a // responsive image set cannot be partly on-platform. check(r#""#); } #[test] #[should_panic(expected = "off-platform")] fn catches_a_protocol_relative_reference() { check(r#"x"#); } #[test] #[should_panic(expected = "off-platform")] fn catches_an_unquoted_off_platform_value() { check("x"); } } #[cfg(test)] mod tests { //! The oracle must not be vacuous. //! //! Every assertion it makes is loose enough not to report correct //! behaviour as a finding, and every such loosening is a chance to have //! loosened it into checking nothing. These feed hand-written output //! straight to [`assert_css_safe`], bypassing the sanitizer, which //! would never produce it, and assert that it still fires. use super::*; const CANVAS_CLASS: &str = "user-canvas"; const CANVAS_ID: &str = "uc-abc"; fn policy() -> UrlPolicy { UrlPolicy::new( "https://u.makenot.work/alice/proj", ["makenot.work".to_string(), "u.makenot.work".to_string()], ) .unwrap() } fn check(clean: &str) { assert_css_safe(clean, "test", "test input", &policy()); } /// Selector scoping, asserted directly. /// /// The fuzz path does not check this, so these tests are where the /// property lives. They are written in well-formed CSS, which is the /// input the check is sound over. fn check_scoped(clean: &str) { check_scoped_as(clean, CANVAS_CLASS, CANVAS_ID); } fn check_scoped_as(clean: &str, canvas_class: &str, canvas_id: &str) { let sheet = StyleSheet::parse(clean, super::super::css_sanitizer::parser_options()) .expect("the fixture parses"); walk(&sheet.rules, canvas_class, canvas_id); fn walk(rules: &CssRuleList<'_>, canvas_class: &str, canvas_id: &str) { for rule in &rules.0 { match rule { CssRule::Style(s) => { assert_scoped( &s.selectors, "test", "test input", "", canvas_class, canvas_id, ); walk(&s.rules, canvas_class, canvas_id); } CssRule::Media(r) => walk(&r.rules, canvas_class, canvas_id), CssRule::Supports(r) => walk(&r.rules, canvas_class, canvas_id), CssRule::LayerBlock(r) => walk(&r.rules, canvas_class, canvas_id), _ => {} } } } } #[test] fn accepts_properly_scoped_output() { check(".user-canvas#uc-abc .a{color:red}"); check("@keyframes spin{0%{opacity:0}100%{opacity:1}}"); check("@font-face{font-family:x;src:url(/f.woff2)}"); check(".user-canvas#uc-abc .a{content:\"@import\"}"); check(".user-canvas#uc-abc .a{background:url(https://u.makenot.work/y.png)}"); // The neutralized form the sanitizer actually emits. check(".user-canvas#uc-abc .a{background:url()}"); } #[test] fn accepts_a_canvas_inside_is() { // The shape lightningcss's nesting flattener actually emits, and // the one a printed-string check misread as an escape because // printing rendered it `:is()`. check_scoped("a b :is(.user-canvas#uc-abc .x){color:red}"); } #[test] #[should_panic(expected = "escaped")] fn a_canvas_inside_not_does_not_scope() { // `:not(.user-canvas#uc-abc)` selects everything OUTSIDE the // canvas, so counting it would accept the exact inversion of the // property. check_scoped(":not(.user-canvas#uc-abc){color:red}"); } #[test] #[should_panic(expected = "escaped")] fn half_the_canvas_compound_is_not_the_canvas() { // The class alone is not the canvas: another creator's canvas // carries the same class and a different id. check_scoped(".user-canvas .a{color:red}"); } #[test] #[should_panic(expected = "escaped")] fn catches_a_rule_outside_the_canvas() { check_scoped(".somewhere-else{color:red}"); } #[test] #[should_panic(expected = "escaped")] fn catches_a_rule_outside_the_canvas_inside_media() { check_scoped("@media print{.somewhere-else{color:red}}"); } #[test] #[should_panic(expected = "@import")] fn catches_an_import_rule() { check("@import url(https://u.makenot.work/x.css);"); } #[test] #[should_panic(expected = "off-platform")] fn catches_an_off_platform_url() { check(".user-canvas#uc-abc .a{background:url(https://evil.com/y.png)}"); } #[test] #[should_panic(expected = "off-platform")] fn catches_an_off_platform_url_in_font_face() { check("@font-face{font-family:x;src:url(https://evil.com/f.woff2)}"); } #[test] #[should_panic(expected = "escaped")] fn item_canvas_is_not_the_user_canvas() { // Guards the pairing rather than the parser: asserting the item // sheet against the user canvas must fail, or `check_css` could // check one sheet twice and report nothing. check_scoped_as(".item-canvas#ic-abc .a{color:red}", CANVAS_CLASS, CANVAS_ID); } } }