Skip to main content

max / makenotwork

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