Skip to main content

max / makenotwork

47.8 KB · 1103 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 //! crate 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 //! It lives outside the server for one reason: a fuzz target that has to build
11 //! the whole server is a fuzz target nobody runs. It imports nothing from MNW.
12 //! The consumer supplies a [`UrlPolicy`] and an owner scope, and gets back
13 //! sanitized output plus every reference that was stripped.
14 //!
15 //! Three layers, one gate:
16 //! - [`url_filter`], the single rule that every URL (HTML attribute or CSS
17 //! `url()`) must resolve to MNW itself.
18 //! - [`html_sanitizer`], an `ammonia` allowlist (structure, text, media; no
19 //! script/embed/form/inline-style).
20 //! - [`css_sanitizer`], a `lightningcss` pass that scopes all selectors to the
21 //! canvas, filters at-rules, validates `url()`, and strips system-slot hiding.
22 //!
23 //! [`oracle`] is the crate's contract as an executable assertion; see its own
24 //! docs for why it is a public module rather than a fuzzing-only one.
25 //!
26 //! Sanitization is **render-time**, not write-time: the editor stores the
27 //! creator's *raw* HTML/CSS, and `sanitize_page` runs on every render of the
28 //! public page (on the cookieless, `default-src 'none'` host). The save path
29 //! runs the sanitizer only to *count* what would be stripped, for the editor's
30 //! blocked-references panel, it does not persist sanitized output. So the XSS
31 //! boundary is the render call, not the database: never inline stored
32 //! `custom_html`/`custom_css` anywhere without running them through this module
33 //! first.
34
35 mod css_sanitizer;
36 mod html_sanitizer;
37 mod url_filter;
38
39 pub use css_sanitizer::{sanitize_css, sanitize_item_css};
40 pub use html_sanitizer::sanitize_html;
41 pub use url_filter::UrlPolicy;
42
43 /// Why a single reference was stripped. Surfaced in the editor's
44 /// blocked-references panel, the primary teaching surface for the
45 /// closed-system rule.
46 #[derive(Debug, Clone, PartialEq, Eq)]
47 pub enum RejectionKind {
48 /// URL resolved to an off-platform host.
49 ExternalUrl,
50 /// URL carried a non-https scheme (`data:`, `javascript:`, `mailto:`, ...).
51 DisallowedScheme,
52 /// URL could not be parsed.
53 MalformedUrl,
54 /// A CSS at-rule outside the allowlist (`@import`, `@namespace`, ...).
55 BlockedAtRule,
56 /// A dangerous CSS function (`expression()`).
57 BlockedFunction,
58 /// A property that would hide a non-removable system slot (`.mnw-*`).
59 HidingProperty,
60 /// A fast infinite animation (strobe guard).
61 AnimationBudget,
62 /// Stylesheet exceeded a complexity cap (DoS guard).
63 ComplexityLimit,
64 /// CSS that could not be parsed at all.
65 MalformedCss,
66 }
67
68 /// One stripped reference, with enough context for the editor to point at it.
69 #[derive(Debug, Clone, PartialEq, Eq)]
70 pub struct Rejection {
71 pub kind: RejectionKind,
72 /// Human-readable origin, e.g. `"img src"`, `"css url()"`, `"@import"`.
73 pub location: String,
74 /// The value as the creator wrote it.
75 pub original_value: String,
76 /// One-line explanation shown to the creator.
77 pub reason: String,
78 }
79
80 /// Maximum style rules in a sanitized sheet (quadratic-matching DoS guard).
81 /// Far above any reasonable page.
82 pub(crate) const MAX_RULES: usize = 5000;
83 /// Maximum selectors across a sanitized sheet.
84 pub(crate) const MAX_SELECTORS: usize = 10000;
85
86 /// Sanitize a full custom page (HTML + CSS together).
87 ///
88 /// `owner_scope` is the id woven into the canvas selector
89 /// `.user-canvas#uc-{owner_scope}` that all CSS is confined to, pass the
90 /// owner's UUID. Returns sanitized HTML, sanitized CSS, and every reference the
91 /// sanitizer stripped (deduplicated only by being appended in order).
92 pub fn sanitize_page(
93 html: &str,
94 css: &str,
95 owner_scope: &str,
96 policy: &UrlPolicy,
97 ) -> (String, String, Vec<Rejection>) {
98 let (clean_html, mut rejections) = sanitize_html(html, policy);
99 let (clean_css, css_rejections) = sanitize_css(css, owner_scope, policy);
100 rejections.extend(css_rejections);
101 (clean_html, clean_css, rejections)
102 }
103
104 #[cfg(test)]
105 mod tests {
106 use super::*;
107
108 fn policy() -> UrlPolicy {
109 UrlPolicy::new(
110 "https://u.makenot.work/alice/proj",
111 [
112 "makenot.work".to_string(),
113 "u.makenot.work".to_string(),
114 "cdn.makenot.work".to_string(),
115 ],
116 )
117 .unwrap()
118 }
119
120 #[test]
121 fn page_sanitizes_both_and_collects_rejections() {
122 let (html, css, rej) = sanitize_page(
123 "<p>hi</p><script>evil()</script><img src=\"https://evil.com/x\">",
124 "body { color: red } .x { background: url(https://evil.com/y) }",
125 "11111111-1111-1111-1111-111111111111",
126 &policy(),
127 );
128 assert!(html.contains("hi"));
129 assert!(!html.contains("evil"));
130 // CSS is scoped to the canvas.
131 assert!(css.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111"));
132 // body got neutralized into the canvas; off-platform url stripped.
133 assert!(!css.contains("evil.com"));
134 // At least the two external refs were recorded.
135 assert!(rej.len() >= 2, "expected rejections, got {rej:?}");
136 }
137 }
138
139 pub mod oracle {
140 //! The crate's contract, written as an executable assertion.
141 //!
142 //! A normal public module rather than something behind a `fuzzing` feature,
143 //! because two callers need it and neither is the fuzzer: the committed
144 //! regression replay in `tests/regressions.rs` runs it on stable, and the
145 //! soak tier's libFuzzer target runs it on nightly. A property asserted in
146 //! one and not the other is a property that drifts, which is the whole
147 //! reason the oracle lives in the crate (the shape `git-command` set).
148 //!
149 //! **Not panicking is the weakest thing a target can assert**, and it is not
150 //! what this crate is for. Two properties are:
151 //!
152 //! - the **safety floor**: nothing executable survives either path, so no
153 //! `<script>`, no event-handler attribute, no `javascript:`, no
154 //! `expression()`;
155 //! - the **closed system**: no URL survives that resolves off-platform.
156 //!
157 //! Both are asserted over *output*, never over input. What a creator may
158 //! write is not the question; what may reach a rendered page is.
159 //!
160 //! ## The HTML side parses; it does not scan
161 //!
162 //! Substring matching over the whole output cannot tell markup from text,
163 //! and a check that reports correct behaviour as a finding is worse than no
164 //! check: it trains people to skim the tier. A page whose prose reads
165 //! `javascript:` is a page the sanitizer correctly passes through as
166 //! escaped text. A value like `Þ˜ttps://evil.com/a.png` is a *relative
167 //! path*, not a scheme, since the leading byte is not ASCII, so a browser
168 //! resolves it against `u.makenot.work` like any other.
169 //!
170 //! So the check walks the output tag by tag, reads attribute NAMES, and
171 //! skips quoted values by construction, so escaped content inside one can
172 //! never reach the assertions. The closed-system property is then stated as
173 //! what it actually is: every URL-bearing attribute that survived must be
174 //! accepted by the very gate the sanitizer is supposed to route it through.
175 //!
176 //! That check is deliberately **not** tautological. It runs against a
177 //! superset of the attribute names [`super::html_sanitizer`] gates, so the
178 //! bug it exists to catch is the one nobody would write a unit test for:
179 //! a tag or attribute added to the allowlist and never added to
180 //! `url_attribute`, which would carry an ungated URL to a reader.
181
182 use std::convert::Infallible;
183
184 use lightningcss::properties::custom::Function;
185 use lightningcss::rules::{CssRule, CssRuleList};
186 #[cfg(test)]
187 use lightningcss::selector::{Component, Selector, SelectorList};
188 use lightningcss::stylesheet::StyleSheet;
189 use lightningcss::values::url::Url as CssUrl;
190 use lightningcss::visit_types;
191 use lightningcss::visitor::{Visit, VisitTypes, Visitor};
192
193 use super::url_filter::resolve_internal_url;
194 use super::{UrlPolicy, sanitize_css, sanitize_html};
195
196 /// Element names no sanitized output may contain, whatever the input was.
197 ///
198 /// Matched as parsed tag names rather than as substrings, for the reason
199 /// written on [`assert_html_safe`].
200 const FORBIDDEN_TAGS: &[&str] = &[
201 "script", "iframe", "object", "embed", "form", "svg", "math", "base", "meta", "link",
202 "style", "template", "noscript", "input", "button", "textarea", "select", "applet",
203 "frame", "frameset",
204 ];
205
206 /// Attribute values that must never appear on a URL-bearing attribute.
207 ///
208 /// The URL gate rejects all of these already, by resolving rather than by
209 /// matching. They are kept as a named, independent floor so that a finding
210 /// says "a script scheme reached a reader" rather than the more abstract
211 /// "the gate would have rejected this".
212 const FORBIDDEN_SCHEMES: &[&str] = &["javascript:", "vbscript:", "data:text/html"];
213
214 /// Attributes whose value a browser fetches or navigates to.
215 ///
216 /// A SUPERSET of what [`super::html_sanitizer`] gates, on purpose: see the
217 /// module docs. These are the names that make a value a live capability
218 /// rather than a string.
219 const URL_ATTRS: &[&str] = &[
220 "href",
221 "src",
222 "srcset",
223 "action",
224 "formaction",
225 "poster",
226 "xlink:href",
227 "data",
228 "codebase",
229 "background",
230 "cite",
231 "longdesc",
232 "profile",
233 "usemap",
234 ];
235
236 /// Panic if sanitized HTML violates the floor or the closed system.
237 ///
238 /// # Panics
239 ///
240 /// By design. It is an oracle, and a panic is how it reports.
241 pub fn check_html(input: &str, policy: &UrlPolicy) {
242 // Every pass must satisfy the floor, and the chain must reach a fixed
243 // point. Both halves matter and they catch different things.
244 //
245 // THE FLOOR ON EVERY PASS is the mutation-XSS property: the risk is
246 // that something downstream parses the same bytes twice and the second
247 // parse reveals markup the first pass did not emit.
248 //
249 // SETTLING is the weaker structural property, and getting its STRENGTH
250 // right took two findings from this target's first sessions (infra
251 // `6f21a29a`). It began as `sanitize_html(clean) == clean`, which is
252 // false:
253 //
254 // <a href="HtTpS://E0" al/id>case game
255 // pass 1: <a rel="nofollow ugc" id="">case game</a>
256 // pass 2: <a id="" rel="nofollow ugc">case game</a>
257 //
258 // The href is off-platform and every pass correctly drops it; what
259 // differs is ATTRIBUTE ORDER, because pass one still had an `href` in
260 // hand when ammonia ordered the attributes. No browser distinguishes
261 // the two.
262 //
263 // Convergence by the third pass -- docengine's answer to the same
264 // question, infra `15991c40` -- was then also too strong. Deeply
265 // misnested input (`<h5>` inside `<h5>` inside `<a>`, committed under
266 // `fuzz/regressions/`) has html5ever's tree builder peeling roughly one
267 // level of nesting per pass, and that case needs three. A fixed small
268 // number is not a property; it is the nesting depth of whichever input
269 // happened to be tried first.
270 //
271 // So what is asserted is what is actually true and actually worth
272 // having: **it settles**. A sanitizer that never reaches a fixed point
273 // is one where each parse sees something new, which is the real defect
274 // this guards. Nothing in this crate rewrites its own output in
275 // production anyway -- sanitization is render-time over the raw stored
276 // HTML -- so the later passes exist to model a downstream reparse
277 // rather than a code path we run.
278 //
279 // THE BOUND IS DERIVED FROM THE INPUT, not chosen. A pass that changes
280 // anything removes at least one level of the misnesting the tree
281 // builder is unwinding, and nesting depth cannot exceed the input
282 // length, so the input's own length is sufficient: blowing through it
283 // means the chain is not converging at all rather than converging
284 // slowly. The `32` floor gives tiny inputs room.
285 //
286 // A CONSTANT WAS TRIED FIRST AND WAS WRONG THREE TIMES, which is why.
287 // `twice == clean` fired on attribute order; convergence-by-the-third-
288 // pass -- docengine's answer to the same question, infra `15991c40` --
289 // fired on `<h5>` misnesting that needs three; a budget of 16 fired on
290 // nested `<pre>` that needs 17. Every time, the finding was about the
291 // constant rather than about the sanitizer.
292 //
293 // Measured over the 16,437-input corpus this target had built by then:
294 // 15,814 settle in a single pass, the tail runs 2 to 10, and 122 of
295 // them GROW on the way to their fixed point -- so "the output never
296 // gets longer" was checked as a candidate property and is false.
297 let budget = input.len().max(32);
298
299 let (mut clean, _rejections) = sanitize_html(input, policy);
300 assert_html_safe(&clean, "html", input, policy);
301
302 for pass in 2..=budget {
303 let (next, _) = sanitize_html(&clean, policy);
304 assert_html_safe(&next, &format!("html (pass {pass})"), input, policy);
305 if next == clean {
306 return;
307 }
308 clean = next;
309 }
310 panic!(
311 "sanitize_html had not settled after {budget} passes for {input:?}\n last: {clean:?}"
312 );
313 }
314
315 /// Panic if sanitized CSS violates the floor, the closed system, or scoping.
316 ///
317 /// Covers BOTH scoping entry points. [`sanitize_css`] scopes to
318 /// `.user-canvas#uc-{owner}`; [`super::sanitize_item_css`] scopes a
319 /// project's CSS to `.item-canvas#ic-{project}` for its item pages, which
320 /// have no HTML of their own and wear the parent's styling. Both are live
321 /// render paths, so both need the oracle over them.
322 ///
323 /// # Panics
324 ///
325 /// By design.
326 pub fn check_css(input: &str, owner_scope: &str, policy: &UrlPolicy) {
327 let (clean, _rejections) = sanitize_css(input, owner_scope, policy);
328 assert_css_safe(&clean, "css", input, policy);
329
330 let (item, _) = super::sanitize_item_css(input, owner_scope, policy);
331 assert_css_safe(&item, "item css", input, policy);
332
333 // Deliberately NOT a fixed-point assertion, unlike the HTML side.
334 // Scoping is a transform, not a filter: running it again legitimately
335 // prefixes the canvas selector a second time and re-appends the
336 // reduced-motion block. That is cosmetic growth, and it never happens
337 // in production because sanitization is render-time over the raw stored
338 // stylesheet, never over its own output.
339 //
340 // What must survive a second pass is safety. A sanitizer that lets
341 // something dangerous reappear when re-parsing its own output is one
342 // where the printer can construct what the parser rejected, which is
343 // the CSS analogue of the mutation-XSS class.
344 let (again, _) = sanitize_css(&clean, owner_scope, policy);
345 assert_css_safe(&again, "css (second pass)", input, policy);
346 }
347
348 /// The floor, the closed system and scoping, over one sanitized stylesheet.
349 ///
350 /// **Parses the output and walks the AST; it does not scan the text.** A
351 /// text scan cannot tell structure from content, and every shape below is
352 /// ordinary creator CSS a scan reports as a security finding:
353 ///
354 /// | input | what a scan gets wrong |
355 /// |---|---|
356 /// | `@keyframes spin{0%{opacity:0}100%{opacity:1}}` | reads the step `100%` as a selector that escaped the canvas |
357 /// | `.a{content:"}"}` | splits the sheet on a brace inside a string |
358 /// | `.a{content:"@import"}` | matches `@import` inside a string literal |
359 /// | `.a{content:"javascript:"}` | matches `javascript:` inside a string literal |
360 ///
361 /// Walking the AST makes the assertions say what they mean: `@import`
362 /// is forbidden as a RULE rather than as a string, a URL is checked by the
363 /// gate rather than by hostname, and the global-by-design rules
364 /// (`@keyframes`, `@font-face`, `@page`, `@layer` statements) are exempt
365 /// from the canvas requirement because the sanitizer deliberately leaves
366 /// them top-level.
367 ///
368 /// # Panics
369 /// If the output does not parse, carries a forbidden at-rule or function,
370 /// points a `url()` off-platform, or lets a style rule escape the canvas.
371 fn assert_css_safe(clean: &str, what: &str, input: &str, policy: &UrlPolicy) {
372 if clean.trim().is_empty() {
373 return;
374 }
375
376 // THE SAME PARSER OPTIONS THE SANITIZER USES, which is the point rather
377 // than tidiness. Reparsing with `ParserOptions::default()` is a
378 // stricter parse than the one that produced this text -- the sanitizer
379 // sets `error_recovery: true` so one malformed rule cannot discard a
380 // creator's whole sheet, and lightningcss then prints the malformed
381 // prelude back out verbatim. A default parse rejects it, and the oracle
382 // reports a difference between two parser configurations as though the
383 // sanitizer had emitted garbage. Found in the css target's first
384 // session on `@media (m=in-width: 600px) { p { color* blue } }`, whose
385 // output is exactly what error recovery is for.
386 let Ok(mut sheet) = StyleSheet::parse(clean, super::css_sanitizer::parser_options()) else {
387 panic!("{what} output does not reparse for {input:?}: {clean:?}");
388 };
389
390 // THE AT-RULE ALLOWLIST, ALWAYS. Worth asserting on whatever parsed.
391 check_rules(&sheet.rules, what, input, clean);
392
393 // SELECTOR SCOPING IS NOT ASSERTED HERE, and that is a measured
394 // decision rather than an omission. It belongs to this module's unit
395 // tests, which check it exactly, on well-formed CSS, and still fire.
396 //
397 // Five consecutive css soak sessions produced five different ways for
398 // the text and the parse tree to disagree on adversarial input (infra
399 // `bd562c12`):
400 //
401 // 1. the printer emitting a selector that no longer parses back to
402 // what it flattened -- `&x` merging into the canvas id;
403 // 2. the parser recovering a `:is()` containing a byte it will not
404 // accept as an empty `:is()`;
405 // 3. backslash escapes before `:is()` shifting where the canvas
406 // compound lands on reparse;
407 // 4. a round-trip fidelity gate written to cover (1)-(3), which was
408 // itself wrong: it compared the REPRINT to its own fixed point and
409 // never to the sanitizer's output, so a lossy first parse passed;
410 // 5. a selector list whose members are separated by what may or may
411 // not be escaped commas, where whether a member carries the canvas
412 // depends on CSS escaping semantics this oracle does not implement.
413 //
414 // Each was patched as its own case and the next arrived. An assertion
415 // that needs a new exemption every session is not measuring the
416 // property any more, it is measuring the exemption list -- and the one
417 // thing worse than an unchecked property is a check that reports
418 // correct behaviour, because that trains people to skim the tier.
419 //
420 // What is NOT claimed by removing it: that scoping holds on such input.
421 // (5) is an open question with its own GoingsOn problem and this input
422 // committed under `fuzz/regressions/` as its evidence. Deciding it
423 // needs CSS escaping semantics settled, which is a piece of work, not a
424 // patch. The sanitizer still scopes; it is the ORACLE that has stopped
425 // claiming to verify it over byte soup.
426 let mut floor = CssFloor {
427 policy,
428 what,
429 input,
430 clean,
431 };
432 // The visitor never returns Err; it panics instead, which is how an
433 // oracle reports.
434 let _: Result<(), Infallible> = sheet.visit(&mut floor);
435 }
436
437 /// At-rules no sanitized stylesheet may contain, named as the sanitizer
438 /// names them so a finding and the code that produced it use one vocabulary.
439 fn blocked_at_rule(rule: &CssRule<'_>) -> Option<&'static str> {
440 match rule {
441 CssRule::Import(_) => Some("@import"),
442 CssRule::Namespace(_) => Some("@namespace"),
443 CssRule::MozDocument(_) => Some("@-moz-document"),
444 CssRule::CustomMedia(_) => Some("@custom-media"),
445 CssRule::Property(_) => Some("@property"),
446 CssRule::Viewport(_) => Some("@viewport"),
447 CssRule::CounterStyle(_) => Some("@counter-style"),
448 CssRule::FontPaletteValues(_) => Some("@font-palette-values"),
449 CssRule::FontFeatureValues(_) => Some("@font-feature-values"),
450 CssRule::Container(_) => Some("@container"),
451 CssRule::Scope(_) => Some("@scope"),
452 CssRule::StartingStyle(_) => Some("@starting-style"),
453 CssRule::ViewTransition(_) => Some("@view-transition"),
454 CssRule::Unknown(_) => Some("an unknown at-rule"),
455 _ => None,
456 }
457 }
458
459 /// Walk the rule tree asserting the at-rule allowlist and canvas scoping.
460 fn check_rules(rules: &CssRuleList<'_>, what: &str, input: &str, clean: &str) {
461 for rule in &rules.0 {
462 if let Some(name) = blocked_at_rule(rule) {
463 panic!("{what} output kept {name} for {input:?}: {clean:?}");
464 }
465 match rule {
466 // Global by design. The sanitizer partitions these out of the
467 // scoping wrapper on purpose -- they carry no document
468 // selectors and cannot legally nest inside a style rule -- so
469 // requiring the canvas of them would be requiring the sanitizer
470 // to be wrong. Their `url()`s are still checked, by the visitor.
471 CssRule::Keyframes(_)
472 | CssRule::FontFace(_)
473 | CssRule::Page(_)
474 | CssRule::LayerStatement(_)
475 | CssRule::Ignored => {}
476
477 CssRule::Style(style) => check_rules(&style.rules, what, input, clean),
478 CssRule::Media(r) => check_rules(&r.rules, what, input, clean),
479 CssRule::Supports(r) => check_rules(&r.rules, what, input, clean),
480 CssRule::LayerBlock(r) => check_rules(&r.rules, what, input, clean),
481 _ => {}
482 }
483 }
484 }
485
486 #[cfg(test)]
487 /// Every selector in the list must be confined to the canvas.
488 ///
489 /// **Structural, because printing a selector is lossy.** This began as
490 /// `selectors.to_css_string(..).contains(".user-canvas#uc-{id}")` and the
491 /// css target's first session found the flaw in seven minutes: lightningcss
492 /// printed a selector list containing `:is(.user-canvas#uc-... .X)` as the
493 /// bare string `:is()`, dropping the contents, so a rule the sanitizer had
494 /// correctly scoped INSIDE an `:is()` -- which is how its nesting flattener
495 /// scopes things -- read as a rule that had escaped. Walking components
496 /// asks the question of the selector rather than of its rendering.
497 ///
498 /// `:not()` is deliberately not descended into. The canvas appearing inside
499 /// a negation is the opposite of scoping, and counting it would accept the
500 /// one selector shape that means "everything except the canvas".
501 fn assert_scoped(
502 list: &SelectorList<'_>,
503 what: &str,
504 input: &str,
505 clean: &str,
506 canvas_class: &str,
507 canvas_id: &str,
508 ) {
509 for selector in &list.0 {
510 assert!(
511 selector_is_scoped(selector, canvas_class, canvas_id),
512 "{what}: a rule escaped .{canvas_class}#{canvas_id} for {input:?}: {clean:?}"
513 );
514 }
515 }
516
517 #[cfg(test)]
518 /// Does this selector carry both halves of the canvas compound somewhere
519 /// that constrains what it matches?
520 fn selector_is_scoped(selector: &Selector<'_>, canvas_class: &str, canvas_id: &str) -> bool {
521 let mut has_class = false;
522 let mut has_id = false;
523 for component in selector.iter_raw_match_order() {
524 match component {
525 Component::Class(ident) if ident.0.as_ref() == canvas_class => has_class = true,
526 // PREFIX, not equality, and the difference is a documented
527 // relaxation rather than sloppiness. lightningcss's nesting
528 // flattener can print a selector whose text no longer parses
529 // back to the AST it flattened: `-- &x` scopes correctly in the
530 // tree and prints as `#uc-abcx`, the trailing type selector
531 // merging into the canvas id (infra `bd562c12`).
532 //
533 // Every shape found that way EXTENDS the identifier, which
534 // narrows the match -- `#uc-abcx` selects an element that does
535 // not exist, because the ids we render are exactly `uc-{uuid}`.
536 // So the rule is inert, and the real property (nothing outside
537 // the canvas gets styled) holds.
538 //
539 // Accepting a suffixed id is therefore safe IN OUR ID
540 // NAMESPACE, and that is the whole of why it is safe. It is
541 // recorded as an open finding rather than a settled design:
542 // a selector the printer mangles is one we are trusting an
543 // accident for. What this still catches is the thing that
544 // matters -- a rule carrying no canvas class, or an id
545 // belonging to a different canvas.
546 Component::ID(ident) if ident.0.as_ref().starts_with(canvas_id) => has_id = true,
547 // `:is`/`:where`/`:has`/`:any` constrain the match, so a canvas
548 // inside one still scopes the rule. `:not` does not, and is
549 // absent from this list on purpose.
550 // An EMPTY `:is()` matches nothing, so a selector carrying one
551 // cannot style anything at all -- inside or outside the canvas
552 // -- and is safe by construction.
553 //
554 // It is reached by round-trip loss rather than by anyone
555 // writing it. `:is()` is a forgiving selector list, so when the
556 // printer emits a selector containing a byte the parser will
557 // not accept, reparsing recovers it as empty. Measured on the
558 // input committed as regression 05, where
559 // `:is(.user-canvas#uc-... w<invalid> .X)` came back as `:is()`.
560 // A browser applies the same forgiving rule to the same text,
561 // so "matches nothing" is what actually ships, not an artifact
562 // of how the oracle happens to look at it.
563 Component::Is(list) if list.is_empty() => return true,
564 Component::Is(list) | Component::Where(list) | Component::Has(list) => {
565 if list
566 .iter()
567 .any(|s| selector_is_scoped(s, canvas_class, canvas_id))
568 {
569 return true;
570 }
571 }
572 Component::Any(_, list) => {
573 if list
574 .iter()
575 .any(|s| selector_is_scoped(s, canvas_class, canvas_id))
576 {
577 return true;
578 }
579 }
580 Component::Host(Some(inner))
581 if selector_is_scoped(inner, canvas_class, canvas_id) =>
582 {
583 return true;
584 }
585 _ => {}
586 }
587 }
588 has_class && has_id
589 }
590
591 /// The safety floor over values: every `url()` on-platform, no
592 /// `expression()`.
593 struct CssFloor<'a> {
594 policy: &'a UrlPolicy,
595 what: &'a str,
596 input: &'a str,
597 clean: &'a str,
598 }
599
600 impl<'i> Visitor<'i> for CssFloor<'_> {
601 type Error = Infallible;
602
603 fn visit_types(&self) -> VisitTypes {
604 visit_types!(URLS | FUNCTIONS)
605 }
606
607 fn visit_url(&mut self, url: &mut CssUrl<'i>) -> Result<(), Self::Error> {
608 // An EMPTY url() is the sanitizer working, not a leak: that is how
609 // it neutralizes an off-platform reference, and an empty url
610 // resolves to the current document, which is same-origin by
611 // definition. The gate itself rejects the empty string, so without
612 // this the oracle would fire on every page that referenced anything
613 // external -- the single most likely thing a real creator does.
614 if url.url.is_empty() {
615 return Ok(());
616 }
617 assert!(
618 resolve_internal_url(&url.url, self.policy, "oracle").is_ok(),
619 "{} output kept an off-platform url({:?}) for {:?}: {:?}",
620 self.what,
621 url.url,
622 self.input,
623 self.clean
624 );
625 Ok(())
626 }
627
628 fn visit_function(&mut self, function: &mut Function<'i>) -> Result<(), Self::Error> {
629 assert!(
630 !function.name.as_ref().eq_ignore_ascii_case("expression"),
631 "{} output kept expression() for {:?}: {:?}",
632 self.what,
633 self.input,
634 self.clean
635 );
636 function.visit_children(self)
637 }
638 }
639
640 /// The floor and the closed system, over one piece of sanitized markup.
641 ///
642 /// **Parses tag and attribute names; never pattern-matches the whole
643 /// string.** That distinction is the correctness of this function rather
644 /// than an optimisation. Quoted attribute values are skipped whole, so
645 /// entity-escaped content sitting inside one -- which is the sanitizer
646 /// working -- can never reach an assertion.
647 ///
648 /// Splitting on `<` is sound BECAUSE of what is under test: a sanitizer
649 /// that escapes text is the premise, so an unescaped `<` in the output can
650 /// only be a tag. If that ever stopped being true, the tag-name assertion
651 /// is what would fire, which is the right failure.
652 ///
653 /// # Panics
654 /// If any tag is a forbidden element, carries an event-handler attribute,
655 /// or points a URL-bearing attribute anywhere but on-platform.
656 fn assert_html_safe(clean: &str, what: &str, input: &str, policy: &UrlPolicy) {
657 let lower = clean.to_ascii_lowercase();
658 let bytes = lower.as_bytes();
659 let fail =
660 |msg: &str| -> ! { panic!("{what} {msg}\n input: {input:?}\n output: {clean:?}") };
661
662 let mut i = 0;
663 while let Some(off) = lower[i..].find('<') {
664 let mut p = i + off + 1;
665 if bytes.get(p) == Some(&b'/') {
666 p += 1;
667 }
668 let name_start = p;
669 while p < bytes.len()
670 && (bytes[p].is_ascii_alphanumeric() || bytes[p] == b':' || bytes[p] == b'-')
671 {
672 p += 1;
673 }
674 let tag = &lower[name_start..p];
675 if FORBIDDEN_TAGS.contains(&tag) {
676 fail(&format!("emitted a <{tag}> element"));
677 }
678
679 // Attributes, until the tag closes.
680 while p < bytes.len() && bytes[p] != b'>' {
681 if bytes[p].is_ascii_whitespace() || bytes[p] == b'/' {
682 p += 1;
683 continue;
684 }
685 let attr_start = p;
686 while p < bytes.len()
687 && !bytes[p].is_ascii_whitespace()
688 && bytes[p] != b'='
689 && bytes[p] != b'>'
690 {
691 p += 1;
692 }
693 let attr = &lower[attr_start..p];
694
695 // Event handlers, by shape rather than by name, so a handler
696 // HTML grows after this was written is still caught.
697 if attr.starts_with("on") && attr.len() > 2 {
698 fail(&format!("emitted the event handler {attr:?} on <{tag}>"));
699 }
700
701 while p < bytes.len() && bytes[p].is_ascii_whitespace() {
702 p += 1;
703 }
704 if bytes.get(p) != Some(&b'=') {
705 continue;
706 }
707 p += 1;
708 while p < bytes.len() && bytes[p].is_ascii_whitespace() {
709 p += 1;
710 }
711 let (value, next) = match bytes.get(p) {
712 Some(&q @ (b'"' | b'\'')) => {
713 let vs = p + 1;
714 let mut e = vs;
715 while e < bytes.len() && bytes[e] != q {
716 e += 1;
717 }
718 (&lower[vs..e.min(bytes.len())], (e + 1).min(bytes.len()))
719 }
720 _ => {
721 let vs = p;
722 let mut e = vs;
723 while e < bytes.len() && !bytes[e].is_ascii_whitespace() && bytes[e] != b'>'
724 {
725 e += 1;
726 }
727 (&lower[vs..e], e)
728 }
729 };
730
731 if URL_ATTRS.contains(&attr) {
732 // A browser decodes character references before it resolves
733 // a URL, so the oracle has to as well. Skipping this step
734 // is how `&#106;avascript:` reads as harmless.
735 let decoded = decode_entities(value);
736 let v = decoded.trim();
737
738 for bad in FORBIDDEN_SCHEMES {
739 if v.starts_with(bad) {
740 fail(&format!("pointed {attr}= at {bad:?} on <{tag}>"));
741 }
742 }
743
744 // The closed system, stated as the property rather than as
745 // a list of hostnames: whatever survived here must be
746 // something the URL gate accepts. `srcset` is a list, so
747 // each candidate's first token is checked separately.
748 let candidates: Vec<&str> = if attr == "srcset" {
749 v.split(',')
750 .map(str::trim)
751 .filter(|c| !c.is_empty())
752 .map(|c| c.split_whitespace().next().unwrap_or(""))
753 .collect()
754 } else {
755 vec![v]
756 };
757 for candidate in candidates {
758 if resolve_internal_url(candidate, policy, "oracle").is_err() {
759 fail(&format!(
760 "kept an off-platform {attr}={candidate:?} on <{tag}>"
761 ));
762 }
763 }
764 }
765 p = next;
766 }
767 i = p.max(i + off + 1);
768 }
769 }
770
771 /// Decode the character references a sanitizer emits, and nothing else.
772 ///
773 /// Ammonia escapes attribute values on the way out, so the oracle sees
774 /// `&quot;` where a browser sees `"`. Only the five named references HTML
775 /// serialization produces, plus numeric ones, are decoded: this exists to
776 /// read the sanitizer's own output correctly, not to reimplement html5ever's
777 /// entity table.
778 fn decode_entities(value: &str) -> String {
779 let mut out = String::with_capacity(value.len());
780 let mut rest = value;
781 while let Some(at) = rest.find('&') {
782 out.push_str(&rest[..at]);
783 let tail = &rest[at..];
784 let Some(end) = tail.find(';') else {
785 out.push_str(tail);
786 return out;
787 };
788 let entity = &tail[1..end];
789 match entity {
790 "quot" => out.push('"'),
791 "apos" => out.push('\''),
792 "amp" => out.push('&'),
793 "lt" => out.push('<'),
794 "gt" => out.push('>'),
795 _ => {
796 let numeric = entity.strip_prefix('#').and_then(|n| {
797 let code = match n.strip_prefix('x').or_else(|| n.strip_prefix('X')) {
798 Some(hex) => u32::from_str_radix(hex, 16).ok()?,
799 None => n.parse::<u32>().ok()?,
800 };
801 char::from_u32(code)
802 });
803 match numeric {
804 Some(c) => out.push(c),
805 // Not a reference this function knows. Keep it verbatim
806 // rather than guessing; an unknown entity is text.
807 None => out.push_str(&tail[..=end]),
808 }
809 }
810 }
811 rest = &tail[end + 1..];
812 }
813 out.push_str(rest);
814 out
815 }
816
817 #[cfg(test)]
818 mod html_tests {
819 //! The HTML oracle must not be vacuous.
820 //!
821 //! Every other caller of the oracle (the seed replay, the regression
822 //! replay, the fuzz target) only asserts that it does not panic, and a
823 //! function that does nothing does not panic either. Fuzzing cannot
824 //! catch that, because it only ever feeds the oracle inputs the
825 //! sanitizer handles correctly.
826 //!
827 //! These call [`assert_html_safe`] directly with hand-written output
828 //! the sanitizer would never emit, because that is the only way to
829 //! observe an assertion that correct code never trips.
830
831 use super::*;
832
833 fn policy() -> UrlPolicy {
834 UrlPolicy::new(
835 "https://u.makenot.work/alice/proj",
836 ["makenot.work".to_string(), "u.makenot.work".to_string()],
837 )
838 .unwrap()
839 }
840
841 fn check(clean: &str) {
842 assert_html_safe(clean, "test", "test input", &policy());
843 }
844
845 #[test]
846 fn accepts_what_the_sanitizer_actually_emits() {
847 check("<p>hello</p>");
848 check(r#"<a href="/local" rel="nofollow ugc">x</a>"#);
849 check(r#"<img src="https://u.makenot.work/a.png" alt="a">"#);
850 check(r#"<img srcset="/a.png 1x, /b.png 2x">"#);
851 check(r##"<a href="#frag">x</a>"##);
852 // Escaped markup inside an attribute VALUE is the sanitizer
853 // working. The oracle skips quoted values by construction, and
854 // these two cost docengine's oracle two false starts.
855 check(r#"<p title="&lt;script&gt;alert(1)&lt;/script&gt;">x</p>"#);
856 check(r#"<p title="javascript:alert(1)">x</p>"#);
857 // Prose in a text node is text, not markup.
858 check("<p>write javascript: in a sentence</p>");
859 check("<p>onerror= is not an attribute here</p>");
860 }
861
862 #[test]
863 #[should_panic(expected = "<script>")]
864 fn catches_a_script_element() {
865 check("<p>ok</p><script>alert(1)</script>");
866 }
867
868 #[test]
869 #[should_panic(expected = "<script>")]
870 fn catches_a_closing_script_element() {
871 check("</script>");
872 }
873
874 #[test]
875 #[should_panic(expected = "<iframe>")]
876 fn catches_an_iframe() {
877 check(r#"<iframe src="/x"></iframe>"#);
878 }
879
880 #[test]
881 #[should_panic(expected = "<script>")]
882 fn tag_names_are_matched_case_insensitively() {
883 check("<ScRiPt>alert(1)</ScRiPt>");
884 }
885
886 #[test]
887 #[should_panic(expected = "event handler")]
888 fn catches_an_event_handler() {
889 check(r#"<img src="/a.png" onerror="alert(1)">"#);
890 }
891
892 #[test]
893 #[should_panic(expected = "event handler")]
894 fn catches_an_event_handler_with_an_unquoted_value() {
895 check("<img src=/a.png onerror=alert(1)>");
896 }
897
898 #[test]
899 #[should_panic(expected = "event handler")]
900 fn catches_an_event_handler_with_space_before_equals() {
901 check(r#"<img src="/a.png" onerror = "alert(1)">"#);
902 }
903
904 #[test]
905 #[should_panic(expected = "javascript:")]
906 fn catches_a_javascript_href() {
907 check(r#"<a href="javascript:alert(1)">x</a>"#);
908 }
909
910 #[test]
911 #[should_panic(expected = "javascript:")]
912 fn catches_a_javascript_href_single_quoted() {
913 check("<a href='javascript:alert(1)'>x</a>");
914 }
915
916 #[test]
917 #[should_panic(expected = "javascript:")]
918 fn catches_a_scheme_hidden_behind_a_character_reference() {
919 // A browser decodes before it resolves, so the oracle has to.
920 // Without `decode_entities` this reads as harmless.
921 check(r#"<a href="&#106;avascript:alert(1)">x</a>"#);
922 }
923
924 #[test]
925 #[should_panic(expected = "vbscript:")]
926 fn catches_a_vbscript_href() {
927 check(r#"<a href="vbscript:msgbox(1)">x</a>"#);
928 }
929
930 #[test]
931 #[should_panic(expected = "off-platform")]
932 fn catches_an_off_platform_href() {
933 check(r#"<a href="https://evil.example/x">x</a>"#);
934 }
935
936 #[test]
937 #[should_panic(expected = "off-platform")]
938 fn catches_an_off_platform_image() {
939 check(r#"<img src="https://evil.example/px.png">"#);
940 }
941
942 #[test]
943 #[should_panic(expected = "off-platform")]
944 fn catches_one_bad_candidate_in_a_srcset() {
945 // The whole attribute is untrustworthy if any candidate is: a
946 // responsive image set cannot be partly on-platform.
947 check(r#"<img srcset="/a.png 1x, https://evil.example/b.png 2x">"#);
948 }
949
950 #[test]
951 #[should_panic(expected = "off-platform")]
952 fn catches_a_protocol_relative_reference() {
953 check(r#"<a href="//evil.example/x">x</a>"#);
954 }
955
956 #[test]
957 #[should_panic(expected = "off-platform")]
958 fn catches_an_unquoted_off_platform_value() {
959 check("<a href=https://evil.example/x>x</a>");
960 }
961 }
962
963 #[cfg(test)]
964 mod tests {
965 //! The oracle must not be vacuous.
966 //!
967 //! Every assertion it makes is loose enough not to report correct
968 //! behaviour as a finding, and every such loosening is a chance to have
969 //! loosened it into checking nothing. These feed hand-written output
970 //! straight to [`assert_css_safe`], bypassing the sanitizer, which
971 //! would never produce it, and assert that it still fires.
972
973 use super::*;
974
975 const CANVAS_CLASS: &str = "user-canvas";
976 const CANVAS_ID: &str = "uc-abc";
977
978 fn policy() -> UrlPolicy {
979 UrlPolicy::new(
980 "https://u.makenot.work/alice/proj",
981 ["makenot.work".to_string(), "u.makenot.work".to_string()],
982 )
983 .unwrap()
984 }
985
986 fn check(clean: &str) {
987 assert_css_safe(clean, "test", "test input", &policy());
988 }
989
990 /// Selector scoping, asserted directly.
991 ///
992 /// The fuzz path does not check this, so these tests are where the
993 /// property lives. They are written in well-formed CSS, which is the
994 /// input the check is sound over.
995 fn check_scoped(clean: &str) {
996 check_scoped_as(clean, CANVAS_CLASS, CANVAS_ID);
997 }
998
999 fn check_scoped_as(clean: &str, canvas_class: &str, canvas_id: &str) {
1000 let sheet = StyleSheet::parse(clean, super::super::css_sanitizer::parser_options())
1001 .expect("the fixture parses");
1002 walk(&sheet.rules, canvas_class, canvas_id);
1003
1004 fn walk(rules: &CssRuleList<'_>, canvas_class: &str, canvas_id: &str) {
1005 for rule in &rules.0 {
1006 match rule {
1007 CssRule::Style(s) => {
1008 assert_scoped(
1009 &s.selectors,
1010 "test",
1011 "test input",
1012 "",
1013 canvas_class,
1014 canvas_id,
1015 );
1016 walk(&s.rules, canvas_class, canvas_id);
1017 }
1018 CssRule::Media(r) => walk(&r.rules, canvas_class, canvas_id),
1019 CssRule::Supports(r) => walk(&r.rules, canvas_class, canvas_id),
1020 CssRule::LayerBlock(r) => walk(&r.rules, canvas_class, canvas_id),
1021 _ => {}
1022 }
1023 }
1024 }
1025 }
1026
1027 #[test]
1028 fn accepts_properly_scoped_output() {
1029 check(".user-canvas#uc-abc .a{color:red}");
1030 check("@keyframes spin{0%{opacity:0}100%{opacity:1}}");
1031 check("@font-face{font-family:x;src:url(/f.woff2)}");
1032 check(".user-canvas#uc-abc .a{content:\"@import\"}");
1033 check(".user-canvas#uc-abc .a{background:url(https://u.makenot.work/y.png)}");
1034 // The neutralized form the sanitizer actually emits.
1035 check(".user-canvas#uc-abc .a{background:url()}");
1036 }
1037
1038 #[test]
1039 fn accepts_a_canvas_inside_is() {
1040 // The shape lightningcss's nesting flattener actually emits, and
1041 // the one a printed-string check misread as an escape because
1042 // printing rendered it `:is()`.
1043 check_scoped("a b :is(.user-canvas#uc-abc .x){color:red}");
1044 }
1045
1046 #[test]
1047 #[should_panic(expected = "escaped")]
1048 fn a_canvas_inside_not_does_not_scope() {
1049 // `:not(.user-canvas#uc-abc)` selects everything OUTSIDE the
1050 // canvas, so counting it would accept the exact inversion of the
1051 // property.
1052 check_scoped(":not(.user-canvas#uc-abc){color:red}");
1053 }
1054
1055 #[test]
1056 #[should_panic(expected = "escaped")]
1057 fn half_the_canvas_compound_is_not_the_canvas() {
1058 // The class alone is not the canvas: another creator's canvas
1059 // carries the same class and a different id.
1060 check_scoped(".user-canvas .a{color:red}");
1061 }
1062
1063 #[test]
1064 #[should_panic(expected = "escaped")]
1065 fn catches_a_rule_outside_the_canvas() {
1066 check_scoped(".somewhere-else{color:red}");
1067 }
1068
1069 #[test]
1070 #[should_panic(expected = "escaped")]
1071 fn catches_a_rule_outside_the_canvas_inside_media() {
1072 check_scoped("@media print{.somewhere-else{color:red}}");
1073 }
1074
1075 #[test]
1076 #[should_panic(expected = "@import")]
1077 fn catches_an_import_rule() {
1078 check("@import url(https://u.makenot.work/x.css);");
1079 }
1080
1081 #[test]
1082 #[should_panic(expected = "off-platform")]
1083 fn catches_an_off_platform_url() {
1084 check(".user-canvas#uc-abc .a{background:url(https://evil.com/y.png)}");
1085 }
1086
1087 #[test]
1088 #[should_panic(expected = "off-platform")]
1089 fn catches_an_off_platform_url_in_font_face() {
1090 check("@font-face{font-family:x;src:url(https://evil.com/f.woff2)}");
1091 }
1092
1093 #[test]
1094 #[should_panic(expected = "escaped")]
1095 fn item_canvas_is_not_the_user_canvas() {
1096 // Guards the pairing rather than the parser: asserting the item
1097 // sheet against the user canvas must fail, or `check_css` could
1098 // check one sheet twice and report nothing.
1099 check_scoped_as(".item-canvas#ic-abc .a{color:red}", CANVAS_CLASS, CANVAS_ID);
1100 }
1101 }
1102 }
1103