Skip to main content

max / makenotwork

4.9 KB · 130 lines History Blame Raw
1 //! Custom Pages sanitization.
2 //!
3 //! <!-- wiki: mnw-server-custom-pages -->
4 //!
5 //! Creators author raw HTML and CSS for their profile and project pages. This
6 //! module turns that input into safe, closed-system page content: no scripting,
7 //! no off-platform references, and CSS that cannot escape the user canvas to
8 //! touch platform chrome.
9 //!
10 //! Three layers, one gate:
11 //! - [`url_filter`], the single rule that every URL (HTML attribute or CSS
12 //! `url()`) must resolve to MNW itself.
13 //! - [`html_sanitizer`], an `ammonia` allowlist (structure, text, media; no
14 //! script/embed/form/inline-style).
15 //! - [`css_sanitizer`], a `lightningcss` pass that scopes all selectors to the
16 //! canvas, filters at-rules, validates `url()`, and strips system-slot hiding.
17 //!
18 //! Sanitization is **render-time**, not write-time: the editor stores the
19 //! creator's *raw* HTML/CSS, and `sanitize_page` runs on every render of the
20 //! public page (on the cookieless, `default-src 'none'` host). The save path
21 //! runs the sanitizer only to *count* what would be stripped, for the editor's
22 //! blocked-references panel, it does not persist sanitized output. So the XSS
23 //! boundary is the render call, not the database: never inline stored
24 //! `custom_html`/`custom_css` anywhere without running them through this module
25 //! first.
26
27 mod css_sanitizer;
28 mod html_sanitizer;
29 mod url_filter;
30
31 pub use css_sanitizer::{sanitize_css, sanitize_item_css};
32 pub use html_sanitizer::sanitize_html;
33 pub use url_filter::UrlPolicy;
34
35 /// Why a single reference was stripped. Surfaced in the editor's
36 /// blocked-references panel, the primary teaching surface for the
37 /// closed-system rule.
38 #[derive(Debug, Clone, PartialEq, Eq)]
39 pub enum RejectionKind {
40 /// URL resolved to an off-platform host.
41 ExternalUrl,
42 /// URL carried a non-https scheme (`data:`, `javascript:`, `mailto:`, ...).
43 DisallowedScheme,
44 /// URL could not be parsed.
45 MalformedUrl,
46 /// A CSS at-rule outside the allowlist (`@import`, `@namespace`, ...).
47 BlockedAtRule,
48 /// A dangerous CSS function (`expression()`).
49 BlockedFunction,
50 /// A property that would hide a non-removable system slot (`.mnw-*`).
51 HidingProperty,
52 /// A fast infinite animation (strobe guard).
53 AnimationBudget,
54 /// Stylesheet exceeded a complexity cap (DoS guard).
55 ComplexityLimit,
56 /// CSS that could not be parsed at all.
57 MalformedCss,
58 }
59
60 /// One stripped reference, with enough context for the editor to point at it.
61 #[derive(Debug, Clone, PartialEq, Eq)]
62 pub struct Rejection {
63 pub kind: RejectionKind,
64 /// Human-readable origin, e.g. `"img src"`, `"css url()"`, `"@import"`.
65 pub location: String,
66 /// The value as the creator wrote it.
67 pub original_value: String,
68 /// One-line explanation shown to the creator.
69 pub reason: String,
70 }
71
72 /// Maximum style rules in a sanitized sheet (quadratic-matching DoS guard).
73 /// Far above any reasonable page.
74 pub(crate) const MAX_RULES: usize = 5000;
75 /// Maximum selectors across a sanitized sheet.
76 pub(crate) const MAX_SELECTORS: usize = 10000;
77
78 /// Sanitize a full custom page (HTML + CSS together).
79 ///
80 /// `owner_scope` is the id woven into the canvas selector
81 /// `.user-canvas#uc-{owner_scope}` that all CSS is confined to, pass the
82 /// owner's UUID. Returns sanitized HTML, sanitized CSS, and every reference the
83 /// sanitizer stripped (deduplicated only by being appended in order).
84 pub fn sanitize_page(
85 html: &str,
86 css: &str,
87 owner_scope: &str,
88 policy: &UrlPolicy,
89 ) -> (String, String, Vec<Rejection>) {
90 let (clean_html, mut rejections) = sanitize_html(html, policy);
91 let (clean_css, css_rejections) = sanitize_css(css, owner_scope, policy);
92 rejections.extend(css_rejections);
93 (clean_html, clean_css, rejections)
94 }
95
96 #[cfg(test)]
97 mod tests {
98 use super::*;
99
100 fn policy() -> UrlPolicy {
101 UrlPolicy::new(
102 "https://u.makenot.work/alice/proj",
103 [
104 "makenot.work".to_string(),
105 "u.makenot.work".to_string(),
106 "cdn.makenot.work".to_string(),
107 ],
108 )
109 .unwrap()
110 }
111
112 #[test]
113 fn page_sanitizes_both_and_collects_rejections() {
114 let (html, css, rej) = sanitize_page(
115 "<p>hi</p><script>evil()</script><img src=\"https://evil.com/x\">",
116 "body { color: red } .x { background: url(https://evil.com/y) }",
117 "11111111-1111-1111-1111-111111111111",
118 &policy(),
119 );
120 assert!(html.contains("hi"));
121 assert!(!html.contains("evil"));
122 // CSS is scoped to the canvas.
123 assert!(css.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111"));
124 // body got neutralized into the canvas; off-platform url stripped.
125 assert!(!css.contains("evil.com"));
126 // At least the two external refs were recorded.
127 assert!(rej.len() >= 2, "expected rejections, got {rej:?}");
128 }
129 }
130