Skip to main content

max / makenotwork

custom-pages: a libFuzzer target for the CSS sanitizer, and a DoS fix The harness half of infra bd562c12, plus the first real defect either soak target has found in the code rather than in its own oracle. THE DENIAL OF SERVICE. 167 bytes of creator CSS allocated 2.1 GB. Nested `&` selectors amplify about 30x per level: depth 1: 43 bytes in -> 723 bytes out depth 2: 77 bytes in -> 17,253 bytes out depth 3: 111 bytes in -> 513,153 bytes out depth 4: 145 bytes in -> 15,390,153 bytes out with zero rejections at every depth. MAX_RULES and MAX_SELECTORS exist but accumulate additively over the parsed AST and are checked before flattening, while flattening multiplies -- they were counting the wrong number on the wrong side of the expansion. Sanitization is render-time, so this is not a slow save: it is every visitor to that page allocating gigabytes, writable by any creator and reachable by any anonymous reader. projected_expansion multiplies down the nesting tree and refuses the sheet before anything is flattened, as an existing ComplexityLimit rejection. A first attempt counted selector-list length and changed nothing, because `&&&&` is ONE selector whose four nesting components each expand to the parent's text: the growth is textual. Ordinary nesting (&:hover, selector lists, three-level nesting, nesting inside @media, 500 flat rules) is untouched, and both directions are tested. THE ORACLE. Rewritten to parse the output instead of scanning it, after six false positives -- all the sanitizer working, the plainest being a two-step @keyframes whose `100%` read as a selector escaping the canvas. It now reparses with the sanitizer's own parser options, forbids @import as a rule rather than as a string, sends url() through the gate, and checks the canvas by walking selector components: into :is/:where/:has, which constrain the match, and never into :not, where a canvas selects everything outside it. It also covers sanitize_item_css, the .item-canvas#ic- path, which had no oracle over it at all. Ten unit tests assert the oracle is not vacuous. Every one of those fixes loosens an assertion, and a loosened assertion is how an oracle quietly stops checking anything. A SCOPING GUARD WAS WRITTEN AND THEN REMOVED, deliberately. lightningcss's nesting flattener can print a selector whose text no longer parses back to the scoped AST (`-- &x` prints as `#uc-abcx`). Dropping such rules meant reparsing this crate's own flattened output, which is far larger and deeper than its input -- 214 bytes flattens to 219 KB -- and parsing that overflowed a 2 MB stack, tokio's default worker size. A Rust stack overflow is not a catchable panic. That traded an inert rule for an abort of the server process, so it is gone and the gap is documented at the site. Every mis-flattened shape found narrows the match, so the rules are inert; the oracle accepts them and says exactly why it is safe to.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 04:21 UTC
Signed with PGP, not checked
Commit: dc7550e1c232526b95ca7a64892fd1d26509c8cd
Parent: b323a2f
9 files changed, +655 insertions, -44 deletions
@@ -33,3 +33,10 @@
33 33 test = false
34 34 doc = false
35 35 bench = false
36 +
37 + [[bin]]
38 + name = "css"
39 + path = "fuzz_targets/css.rs"
40 + test = false
41 + doc = false
42 + bench = false
@@ -134,6 +134,50 @@
134 134 );
135 135 }
136 136
137 + // THE CAPS ABOVE COUNT THE PARSED TREE; FLATTENING IS MULTIPLICATIVE.
138 + //
139 + // `rule_count` and `selector_count` are accumulated additively over the AST
140 + // as parsed, and they are checked before the scoping wrapper is flattened.
141 + // Nested selectors do not add, they multiply: a rule with N selectors
142 + // nested inside one with M produces N*M flattened selectors, and the caps
143 + // never see it. The css soak target found the consequence in its first
144 + // half-hour (infra `bd562c12`) as a 2.1 GB allocation from a 167-byte
145 + // stylesheet -- nested `&` selectors, roughly 30x per level:
146 + //
147 + // depth 1: 43 bytes in -> 723 bytes out
148 + // depth 2: 77 bytes in -> 17,253 bytes out
149 + // depth 3: 111 bytes in -> 513,153 bytes out
150 + // depth 4: 145 bytes in -> 15,390,153 bytes out
151 + //
152 + // and zero rejections at every depth. Sanitization is render-time, so that
153 + // is not a slow save: it is every visitor to that page allocating
154 + // gigabytes, which is a denial of service any creator could have written by
155 + // accident.
156 + //
157 + // The projection below multiplies down the nesting tree and refuses the
158 + // sheet before anything is flattened. It reuses MAX_SELECTORS and
159 + // `ComplexityLimit` rather than inventing a limit: "too complex, render the
160 + // platform default" is already this crate's designed answer, and it was
161 + // only ever measuring the wrong number.
162 + let projected = projected_expansion(&stylesheet.rules, 1);
163 + if projected > MAX_SELECTORS as u64 {
164 + tracing::warn!(
165 + projected,
166 + "custom-page CSS rejected: nested selectors project to {projected} flattened selectors (limit {MAX_SELECTORS})"
167 + );
168 + return (
169 + String::new(),
170 + vec![Rejection {
171 + kind: RejectionKind::ComplexityLimit,
172 + location: "css".into(),
173 + original_value: format!("{projected} flattened selectors"),
174 + reason: format!(
175 + "nested selectors expand to more than {MAX_SELECTORS} rules once flattened"
176 + ),
177 + }],
178 + );
179 + }
180 +
137 181 let mut rejections = sanitizer.rejections;
138 182
139 183 // Partition surviving rules: element-selecting rules get scoped; rules that
@@ -189,6 +233,26 @@
189 233 }
190 234 };
191 235
236 + // NOT VERIFIED AGAINST THE PRINTER, and that is a known gap rather than an
237 + // oversight. See infra `bd562c12`: the css soak target found that
238 + // lightningcss's nesting flattener can print a selector whose text no
239 + // longer parses back to the scoped AST -- `-- &x` prints as `#uc-abcx`, the
240 + // trailing type selector merging into the canvas id.
241 + //
242 + // A guard that reparsed this function's own printed output and dropped
243 + // unscoped rules was written, and then REMOVED, because it was worse than
244 + // what it fixed. Every mis-flattened shape found NARROWS the match (a
245 + // longer identifier matches fewer elements), so the rules it dropped were
246 + // inert. The reparse, meanwhile, ran over the FLATTENED sheet, which is far
247 + // larger and more deeply nested than the input: 214 bytes of creator CSS
248 + // flattens to 219 KB, and parsing that overflowed a 2 MB stack -- tokio's
249 + // default worker size. A Rust stack overflow is not a catchable panic, so
250 + // that traded an inert rule for an abort of the whole server process,
251 + // reachable by any visitor to the page.
252 + //
253 + // Fixing it safely needs a check that does not reparse the output, and
254 + // choosing one is a design decision rather than a mechanical fix.
255 +
192 256 // Reduced-motion override, always last (decision #3). Scoped to the canvas.
193 257 let reduced_motion = format!(
194 258 "@media (prefers-reduced-motion: reduce){{{scope_selector},{scope_selector} *{{animation:none!important;transition:none!important}}}}"
@@ -208,6 +272,53 @@
208 272 (escape_lt_for_style_element(&out), rejections)
209 273 }
210 274
275 + /// How far this rule tree expands when nesting is flattened.
276 + ///
277 + /// Not a selector count: a compound like `&&&&` is ONE selector whose four
278 + /// nesting components each expand to the parent's flattened text, so the
279 + /// growth is textual and multiplies down the tree. Counting selector-list
280 + /// length instead measures the wrong axis entirely and waves the input
281 + /// through, which is what a first attempt at this did.
282 + ///
283 + /// `factor` is what the enclosing rules already multiply by. Saturating
284 + /// throughout: the answer only has to be "past the cap", and an overflow would
285 + /// wrap to a small number and admit exactly the input this refuses.
286 + fn projected_expansion(rules: &CssRuleList<'_>, factor: u64) -> u64 {
287 + let mut worst = factor;
288 + for rule in &rules.0 {
289 + let (own, nested) = match rule {
290 + CssRule::Style(style) => {
291 + // Each nesting reference reproduces the parent selector; the
292 + // selector list multiplies on top of that.
293 + let refs: u64 = style
294 + .selectors
295 + .0
296 + .iter()
297 + .map(|s| {
298 + s.iter_raw_match_order()
299 + .filter(|c| matches!(c, Component::Nesting))
300 + .count() as u64
301 + })
302 + .sum();
303 + let width = refs.max(style.selectors.0.len() as u64).max(1);
304 + (factor.saturating_mul(width), Some(&style.rules))
305 + }
306 + CssRule::Media(r) => (factor, Some(&r.rules)),
307 + CssRule::Supports(r) => (factor, Some(&r.rules)),
308 + CssRule::LayerBlock(r) => (factor, Some(&r.rules)),
309 + _ => (factor, None),
310 + };
311 + worst = worst.max(own);
312 + if let Some(inner) = nested {
313 + worst = worst.max(projected_expansion(inner, own));
314 + }
315 + if worst > u64::from(u32::MAX) {
316 + return worst; // Already past any cap; stop walking.
317 + }
318 + }
319 + worst
320 + }
321 +
211 322 /// Make the sheet safe to inline raw inside an HTML `<style>` element.
212 323 ///
213 324 /// `<style>` is a raw-text element: the HTML tokenizer ends it at the first
@@ -226,7 +337,7 @@
226 337 css.replace('<', "\\3c ")
227 338 }
228 339
229 - fn parser_options<'o, 'i>() -> ParserOptions<'o, 'i> {
340 + pub(super) fn parser_options<'o, 'i>() -> ParserOptions<'o, 'i> {
230 341 ParserOptions {
231 342 // Nesting is standard CSS; let creators use it and let us wrap with it.
232 343 flags: ParserFlags::NESTING,
@@ -564,6 +675,63 @@
564 675
565 676 #[cfg(test)]
566 677 mod tests {
678 +
679 + #[test]
680 + fn nested_amplification_is_refused_before_it_is_flattened() {
681 + // The css soak target's second finding (infra `bd562c12`): 167 bytes of
682 + // nested `&` selectors allocated 2.1 GB, because the complexity caps
683 + // count the parsed tree additively and flattening multiplies. Measured
684 + // growth was ~30x per level -- 145 bytes in, 15 MB out, zero
685 + // rejections. Sanitization is render-time, so that is every visitor to
686 + // the page, not a slow save.
687 + let amp = "&".repeat(30);
688 + let css = format!("{amp} {{ {amp} {{ {amp} {{ color:red }} }} }}");
689 + assert!(css.len() < 200, "the point is that the input is tiny");
690 +
691 + let (out, rejections) = sanitize_css(&css, "abc", &test_policy());
692 + assert!(
693 + out.is_empty(),
694 + "an unsafe sheet renders as nothing: {out:.200}"
695 + );
696 + assert!(
697 + rejections
698 + .iter()
699 + .any(|r| matches!(r.kind, RejectionKind::ComplexityLimit)),
700 + "refusal must be recorded as a complexity limit: {rejections:?}"
701 + );
702 + }
703 +
704 + #[test]
705 + fn ordinary_nesting_is_not_refused() {
706 + // The guard is worthless if it refuses real pages. Nesting is standard
707 + // CSS and the parser enables it deliberately.
708 + for css in [
709 + ".card { color: red; &:hover { color: blue } }",
710 + "h1,h2,h3 { &:hover, &:focus { color: red } }",
711 + ".a { .b { .c { color: red } } }",
712 + "@media print { .a { &:hover { color: red } } }",
713 + ] {
714 + let (out, rejections) = sanitize_css(css, "abc", &test_policy());
715 + assert!(
716 + !rejections
717 + .iter()
718 + .any(|r| matches!(r.kind, RejectionKind::ComplexityLimit)),
719 + "ordinary nesting was refused: {css:?} -> {rejections:?}"
720 + );
721 + assert!(
722 + !out.is_empty(),
723 + "ordinary nesting produced nothing: {css:?}"
724 + );
725 + }
726 + }
727 +
728 + fn test_policy() -> UrlPolicy {
729 + UrlPolicy::new(
730 + "https://u.makenot.work/alice/proj",
731 + ["makenot.work".to_string(), "u.makenot.work".to_string()],
732 + )
733 + .unwrap()
734 + }
567 735 use super::*;
568 736
569 737 const SCOPE: &str = "11111111-1111-1111-1111-111111111111";
@@ -198,6 +198,16 @@
198 198 //! a tag or attribute added to the allowlist and never added to
199 199 //! `url_attribute`, which would carry an ungated URL to a reader.
200 200
201 + use std::convert::Infallible;
202 +
203 + use lightningcss::properties::custom::Function;
204 + use lightningcss::rules::{CssRule, CssRuleList};
205 + use lightningcss::selector::{Component, Selector, SelectorList};
206 + use lightningcss::stylesheet::StyleSheet;
207 + use lightningcss::values::url::Url as CssUrl;
208 + use lightningcss::visit_types;
209 + use lightningcss::visitor::{Visit, VisitTypes, Visitor};
210 +
201 211 use super::url_filter::resolve_internal_url;
202 212 use super::{UrlPolicy, sanitize_css, sanitize_html};
203 213
@@ -241,22 +251,6 @@
241 251 "usemap",
242 252 ];
243 253
244 - /// Substrings that must never appear in sanitized CSS.
245 - ///
246 - /// Still a scan, because CSS output is not markup and has no text nodes to
247 - /// confuse with structure. The CSS oracle is the business of infra
248 - /// `bd562c12`, the css_sanitizer soak target, which is where the same
249 - /// question -- is a substring here a property, or a seed's hostname? --
250 - /// gets asked of this list.
251 - const NEVER_IN_CSS: &[&str] = &[
252 - "javascript:",
253 - "vbscript:",
254 - "expression(",
255 - "@import",
256 - "</style",
257 - "data:text/html",
258 - ];
259 -
260 254 /// Panic if sanitized HTML violates the floor or the closed system.
261 255 ///
262 256 /// # Panics
@@ -338,27 +332,38 @@
338 332
339 333 /// Panic if sanitized CSS violates the floor, the closed system, or scoping.
340 334 ///
335 + /// Covers BOTH scoping entry points. The doors were counted for infra
336 + /// `bd562c12`: nine call sites, all in MNW server, but two entry points --
337 + /// [`sanitize_css`] scopes to `.user-canvas#uc-{owner}` and
338 + /// [`super::sanitize_item_css`] scopes a project's CSS to
339 + /// `.item-canvas#ic-{project}` for its item pages, which have no HTML of
340 + /// their own and wear the parent's styling. Only the first was asserted
341 + /// before, so the item canvas was a live render path with no oracle over
342 + /// it.
343 + ///
341 344 /// # Panics
342 345 ///
343 346 /// By design.
344 347 pub fn check_css(input: &str, owner_scope: &str, policy: &UrlPolicy) {
345 348 let (clean, _rejections) = sanitize_css(input, owner_scope, policy);
346 - assert_css_safe(&clean, "css", input);
349 + assert_css_safe(
350 + &clean,
351 + "css",
352 + input,
353 + policy,
354 + "user-canvas",
355 + &format!("uc-{owner_scope}"),
356 + );
347 357
348 - // Everything that survives is confined to the owner's canvas. A rule
349 - // that escapes it can restyle platform chrome, which is the CSS half of
350 - // what this crate exists to prevent.
351 - let canvas = format!(".user-canvas#uc-{owner_scope}");
352 - for rule in clean.split('}') {
353 - let selector = rule.split('{').next().unwrap_or_default().trim();
354 - if selector.is_empty() || selector.starts_with('@') {
355 - continue;
356 - }
357 - assert!(
358 - selector.contains(&canvas),
359 - "a rule escaped the canvas for {input:?}: {selector:?}"
360 - );
361 - }
358 + let (item, _) = super::sanitize_item_css(input, owner_scope, policy);
359 + assert_css_safe(
360 + &item,
361 + "item css",
362 + input,
363 + policy,
364 + "item-canvas",
365 + &format!("ic-{owner_scope}"),
366 + );
362 367
363 368 // Deliberately NOT a fixed-point assertion, unlike the HTML side.
364 369 // Scoping is a transform, not a filter: running it again legitimately
@@ -372,7 +377,316 @@
372 377 // where the printer can construct what the parser rejected, which is
373 378 // the CSS analogue of the mutation-XSS class.
374 379 let (again, _) = sanitize_css(&clean, owner_scope, policy);
375 - assert_css_safe(&again, "css (second pass)", input);
380 + assert_css_safe(
381 + &again,
382 + "css (second pass)",
383 + input,
384 + policy,
385 + "user-canvas",
386 + &format!("uc-{owner_scope}"),
387 + );
388 + }
389 +
390 + /// The floor, the closed system and scoping, over one sanitized stylesheet.
391 + ///
392 + /// **Parses the output and walks the AST; it does not scan the text.**
393 + /// Rewritten 2026-08-25 (infra `bd562c12`) because the scan it replaces had
394 + /// four false positives, every one of them the sanitizer working, and the
395 + /// first was not exotic at all:
396 + ///
397 + /// | input | what the scan did |
398 + /// |---|---|
399 + /// | `@keyframes spin{0%{opacity:0}100%{opacity:1}}` | read the step `100%` as a selector that escaped the canvas |
400 + /// | `.a{content:"}"}` | split the sheet on a brace inside a string |
401 + /// | `.a{content:"@import"}` | matched `@import` inside a string literal |
402 + /// | `.a{content:"javascript:"}` | matched `javascript:` inside a string literal |
403 + ///
404 + /// A multi-step animation is ordinary creator CSS, so that check would have
405 + /// reported a security finding within seconds of the target starting. The
406 + /// scan could not tell structure from content, which is the CSS form of the
407 + /// mistake the HTML oracle made with text nodes (infra `6f21a29a`) and
408 + /// docengine's made twice before that (infra `15991c40`).
409 + ///
410 + /// Walking the AST also makes the assertions say what they mean: `@import`
411 + /// is forbidden as a RULE rather than as a string, a URL is checked by the
412 + /// gate rather than by hostname, and the global-by-design rules
413 + /// (`@keyframes`, `@font-face`, `@page`, `@layer` statements) are exempt
414 + /// from the canvas requirement because the sanitizer deliberately leaves
415 + /// them top-level.
416 + ///
417 + /// # Panics
418 + /// If the output does not parse, carries a forbidden at-rule or function,
419 + /// points a `url()` off-platform, or lets a style rule escape the canvas.
420 + fn assert_css_safe(
421 + clean: &str,
422 + what: &str,
423 + input: &str,
424 + policy: &UrlPolicy,
425 + canvas_class: &str,
426 + canvas_id: &str,
427 + ) {
428 + if clean.trim().is_empty() {
429 + return;
430 + }
431 +
432 + // THE SAME PARSER OPTIONS THE SANITIZER USES, which is the point rather
433 + // than tidiness. Reparsing with `ParserOptions::default()` is a
434 + // stricter parse than the one that produced this text -- the sanitizer
435 + // sets `error_recovery: true` so one malformed rule cannot discard a
436 + // creator's whole sheet, and lightningcss then prints the malformed
437 + // prelude back out verbatim. A default parse rejects it, and the oracle
438 + // reports a difference between two parser configurations as though the
439 + // sanitizer had emitted garbage. Found in the css target's first
440 + // session on `@media (m=in-width: 600px) { p { color* blue } }`, whose
441 + // output is exactly what error recovery is for.
442 + let Ok(mut sheet) = StyleSheet::parse(clean, super::css_sanitizer::parser_options()) else {
443 + panic!("{what} output does not reparse for {input:?}: {clean:?}");
444 + };
445 +
446 + check_rules(&sheet.rules, what, input, clean, canvas_class, canvas_id);
447 +
448 + let mut floor = CssFloor {
449 + policy,
450 + what,
451 + input,
452 + clean,
453 + };
454 + // The visitor never returns Err; it panics instead, which is how an
455 + // oracle reports.
456 + let _: Result<(), Infallible> = sheet.visit(&mut floor);
457 + }
458 +
459 + /// At-rules no sanitized stylesheet may contain, named as the sanitizer
460 + /// names them so a finding and the code that produced it use one vocabulary.
461 + fn blocked_at_rule(rule: &CssRule<'_>) -> Option<&'static str> {
462 + match rule {
463 + CssRule::Import(_) => Some("@import"),
464 + CssRule::Namespace(_) => Some("@namespace"),
465 + CssRule::MozDocument(_) => Some("@-moz-document"),
466 + CssRule::CustomMedia(_) => Some("@custom-media"),
467 + CssRule::Property(_) => Some("@property"),
468 + CssRule::Viewport(_) => Some("@viewport"),
469 + CssRule::CounterStyle(_) => Some("@counter-style"),
470 + CssRule::FontPaletteValues(_) => Some("@font-palette-values"),
471 + CssRule::FontFeatureValues(_) => Some("@font-feature-values"),
472 + CssRule::Container(_) => Some("@container"),
473 + CssRule::Scope(_) => Some("@scope"),
474 + CssRule::StartingStyle(_) => Some("@starting-style"),
475 + CssRule::ViewTransition(_) => Some("@view-transition"),
476 + CssRule::Unknown(_) => Some("an unknown at-rule"),
477 + _ => None,
478 + }
479 + }
480 +
481 + /// Walk the rule tree asserting the at-rule allowlist and canvas scoping.
482 + fn check_rules(
483 + rules: &CssRuleList<'_>,
484 + what: &str,
485 + input: &str,
486 + clean: &str,
487 + canvas_class: &str,
488 + canvas_id: &str,
489 + ) {
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) => {
507 + assert_scoped(
508 + &style.selectors,
509 + what,
510 + input,
511 + clean,
512 + canvas_class,
513 + canvas_id,
514 + );
515 + check_rules(&style.rules, what, input, clean, canvas_class, canvas_id);
516 + }
517 +
518 + CssRule::Media(r) => {
519 + check_rules(&r.rules, what, input, clean, canvas_class, canvas_id);
520 + }
521 + CssRule::Supports(r) => {
522 + check_rules(&r.rules, what, input, clean, canvas_class, canvas_id);
523 + }
524 + CssRule::LayerBlock(r) => {
525 + check_rules(&r.rules, what, input, clean, canvas_class, canvas_id);
526 + }
527 + CssRule::Nesting(r) => assert_scoped(
528 + &r.style.selectors,
529 + what,
530 + input,
531 + clean,
532 + canvas_class,
533 + canvas_id,
534 + ),
535 + _ => {}
536 + }
537 + }
538 + }
539 +
540 + /// Every selector in the list must be confined to the canvas.
541 + ///
542 + /// **Structural, because printing a selector is lossy.** This began as
543 + /// `selectors.to_css_string(..).contains(".user-canvas#uc-{id}")` and the
544 + /// css target's first session found the flaw in seven minutes: lightningcss
545 + /// printed a selector list containing `:is(.user-canvas#uc-... .X)` as the
546 + /// bare string `:is()`, dropping the contents, so a rule the sanitizer had
547 + /// correctly scoped INSIDE an `:is()` -- which is how its nesting flattener
548 + /// scopes things -- read as a rule that had escaped. Walking components
549 + /// asks the question of the selector rather than of its rendering.
550 + ///
551 + /// `:not()` is deliberately not descended into. The canvas appearing inside
552 + /// a negation is the opposite of scoping, and counting it would accept the
553 + /// one selector shape that means "everything except the canvas".
554 + fn assert_scoped(
555 + list: &SelectorList<'_>,
556 + what: &str,
557 + input: &str,
558 + clean: &str,
559 + canvas_class: &str,
560 + canvas_id: &str,
561 + ) {
562 + for selector in &list.0 {
563 + assert!(
564 + selector_is_scoped(selector, canvas_class, canvas_id),
565 + "{what}: a rule escaped .{canvas_class}#{canvas_id} for {input:?}: {clean:?}"
566 + );
567 + }
568 + }
569 +
570 + /// Does this selector carry both halves of the canvas compound somewhere
571 + /// that constrains what it matches?
572 + fn selector_is_scoped(selector: &Selector<'_>, canvas_class: &str, canvas_id: &str) -> bool {
573 + let mut has_class = false;
574 + let mut has_id = false;
575 + for component in selector.iter_raw_match_order() {
576 + match component {
577 + Component::Class(ident) if ident.0.as_ref() == canvas_class => has_class = true,
578 + // PREFIX, not equality, and the difference is a documented
579 + // relaxation rather than sloppiness. lightningcss's nesting
580 + // flattener can print a selector whose text no longer parses
581 + // back to the AST it flattened: `-- &x` scopes correctly in the
582 + // tree and prints as `#uc-abcx`, the trailing type selector
583 + // merging into the canvas id (infra `bd562c12`).
584 + //
585 + // Every shape found that way EXTENDS the identifier, which
586 + // narrows the match -- `#uc-abcx` selects an element that does
587 + // not exist, because the ids we render are exactly `uc-{uuid}`.
588 + // So the rule is inert, and the real property (nothing outside
589 + // the canvas gets styled) holds.
590 + //
591 + // Accepting a suffixed id is therefore safe IN OUR ID
592 + // NAMESPACE, and that is the whole of why it is safe. It is
593 + // recorded as an open finding rather than a settled design:
594 + // a selector the printer mangles is one we are trusting an
595 + // accident for. What this still catches is the thing that
596 + // matters -- a rule carrying no canvas class, or an id
597 + // belonging to a different canvas.
598 + Component::ID(ident) if ident.0.as_ref().starts_with(canvas_id) => has_id = true,
599 + // `:is`/`:where`/`:has`/`:any` constrain the match, so a canvas
600 + // inside one still scopes the rule. `:not` does not, and is
601 + // absent from this list on purpose.
602 + // An EMPTY `:is()` matches nothing, so a selector carrying one
603 + // cannot style anything at all -- inside or outside the canvas
604 + // -- and is safe by construction.
605 + //
606 + // It is reached by round-trip loss rather than by anyone
607 + // writing it. `:is()` is a forgiving selector list, so when the
608 + // printer emits a selector containing a byte the parser will
609 + // not accept, reparsing recovers it as empty. Measured on the
610 + // input committed as regression 05, where
611 + // `:is(.user-canvas#uc-... w<invalid> .X)` came back as `:is()`.
612 + // A browser applies the same forgiving rule to the same text,
613 + // so "matches nothing" is what actually ships, not an artifact
614 + // of how the oracle happens to look at it.
615 + Component::Is(list) if list.is_empty() => return true,
616 + Component::Is(list) | Component::Where(list) | Component::Has(list) => {
617 + if list
618 + .iter()
619 + .any(|s| selector_is_scoped(s, canvas_class, canvas_id))
620 + {
621 + return true;
622 + }
623 + }
624 + Component::Any(_, list) => {
625 + if list
626 + .iter()
627 + .any(|s| selector_is_scoped(s, canvas_class, canvas_id))
628 + {
629 + return true;
630 + }
631 + }
632 + Component::Host(Some(inner))
633 + if selector_is_scoped(inner, canvas_class, canvas_id) =>
634 + {
635 + return true;
636 + }
637 + _ => {}
638 + }
639 + }
640 + has_class && has_id
641 + }
642 +
643 + /// The safety floor over values: every `url()` on-platform, no
644 + /// `expression()`.
645 + struct CssFloor<'a> {
646 + policy: &'a UrlPolicy,
647 + what: &'a str,
648 + input: &'a str,
649 + clean: &'a str,
650 + }
651 +
652 + impl<'i> Visitor<'i> for CssFloor<'_> {
653 + type Error = Infallible;
654 +
655 + fn visit_types(&self) -> VisitTypes {
656 + visit_types!(URLS | FUNCTIONS)
657 + }
658 +
659 + fn visit_url(&mut self, url: &mut CssUrl<'i>) -> Result<(), Self::Error> {
660 + // An EMPTY url() is the sanitizer working, not a leak: that is how
661 + // it neutralizes an off-platform reference, and an empty url
662 + // resolves to the current document, which is same-origin by
663 + // definition. The gate itself rejects the empty string, so without
664 + // this the oracle would fire on every page that referenced anything
665 + // external -- the single most likely thing a real creator does.
666 + if url.url.is_empty() {
667 + return Ok(());
668 + }
669 + assert!(
670 + resolve_internal_url(&url.url, self.policy, "oracle").is_ok(),
671 + "{} output kept an off-platform url({:?}) for {:?}: {:?}",
672 + self.what,
673 + url.url,
674 + self.input,
675 + self.clean
676 + );
677 + Ok(())
678 + }
679 +
680 + fn visit_function(&mut self, function: &mut Function<'i>) -> Result<(), Self::Error> {
681 + assert!(
682 + !function.name.as_ref().eq_ignore_ascii_case("expression"),
683 + "{} output kept expression() for {:?}: {:?}",
684 + self.what,
685 + self.input,
686 + self.clean
687 + );
688 + function.visit_children(self)
689 + }
376 690 }
377 691
378 692 /// The floor and the closed system, over one piece of sanitized markup.
@@ -506,17 +820,6 @@
506 820 }
507 821 }
508 822
509 - /// The floor, over one piece of sanitized CSS.
510 - fn assert_css_safe(clean: &str, what: &str, input: &str) {
511 - let lowered = clean.to_ascii_lowercase();
512 - for needle in NEVER_IN_CSS {
513 - assert!(
514 - !lowered.contains(needle),
515 - "{what} output kept {needle:?} for input {input:?}: {clean:?}"
516 - );
517 - }
518 - }
519 -
520 823 /// Decode the character references a sanitizer emits, and nothing else.
521 824 ///
522 825 /// Ammonia escapes attribute values on the way out, so the oracle sees
@@ -562,4 +865,122 @@
562 865 out.push_str(rest);
563 866 out
564 867 }
868 +
869 + #[cfg(test)]
870 + mod tests {
871 + //! The oracle must not be vacuous.
872 + //!
873 + //! Every assertion it makes was loosened at least once to stop it
874 + //! reporting correct behaviour as a finding (infra `6f21a29a`,
875 + //! `bd562c12`), and each loosening is a chance to have loosened it into
876 + //! checking nothing. These feed hand-written output straight to
877 + //! [`assert_css_safe`] -- bypassing the sanitizer, which would never
878 + //! produce it -- and assert that it still fires.
879 +
880 + use super::*;
881 +
882 + const CANVAS_CLASS: &str = "user-canvas";
883 + const CANVAS_ID: &str = "uc-abc";
884 +
885 + fn policy() -> UrlPolicy {
886 + UrlPolicy::new(
887 + "https://u.makenot.work/alice/proj",
888 + ["makenot.work".to_string(), "u.makenot.work".to_string()],
889 + )
890 + .unwrap()
891 + }
892 +
893 + fn check(clean: &str) {
894 + assert_css_safe(
895 + clean,
896 + "test",
897 + "test input",
898 + &policy(),
899 + CANVAS_CLASS,
900 + CANVAS_ID,
901 + );
902 + }
903 +
904 + #[test]
905 + fn accepts_properly_scoped_output() {
906 + check(".user-canvas#uc-abc .a{color:red}");
907 + check("@keyframes spin{0%{opacity:0}100%{opacity:1}}");
908 + check("@font-face{font-family:x;src:url(/f.woff2)}");
909 + check(".user-canvas#uc-abc .a{content:\"@import\"}");
910 + check(".user-canvas#uc-abc .a{background:url(https://u.makenot.work/y.png)}");
911 + // The neutralized form the sanitizer actually emits.
912 + check(".user-canvas#uc-abc .a{background:url()}");
913 + }
914 +
915 + #[test]
916 + fn accepts_a_canvas_inside_is() {
917 + // The shape lightningcss's nesting flattener actually emits, and
918 + // the one a printed-string check misread as an escape because
919 + // printing rendered it `:is()`.
920 + check("a b :is(.user-canvas#uc-abc .x){color:red}");
921 + }
922 +
923 + #[test]
924 + #[should_panic(expected = "escaped")]
925 + fn a_canvas_inside_not_does_not_scope() {
926 + // `:not(.user-canvas#uc-abc)` selects everything OUTSIDE the
927 + // canvas, so counting it would accept the exact inversion of the
928 + // property.
929 + check(":not(.user-canvas#uc-abc){color:red}");
930 + }
931 +
932 + #[test]
933 + #[should_panic(expected = "escaped")]
934 + fn half_the_canvas_compound_is_not_the_canvas() {
935 + // The class alone is not the canvas: another creator's canvas
936 + // carries the same class and a different id.
937 + check(".user-canvas .a{color:red}");
938 + }
939 +
Lines truncated
@@ -1,0 +1,57 @@
1 + //! Structured fuzz over the custom-pages CSS sanitizer.
2 + //!
3 + //! Row 2 of `astra-soak-overview`, and the other half of the same trust
4 + //! boundary as the `html` target: creator-authored input rendered on a public
5 + //! page. What CSS can do that HTML cannot is escape the user canvas and restyle
6 + //! platform chrome, so scoping is asserted here alongside the safety floor.
7 + //!
8 + //! ## The oracle lives in the crate, not here
9 + //!
10 + //! Everything asserted is `custom_pages::oracle::check_css`, the same function
11 + //! `tests/regressions.rs` replays on stable. A crash found here becomes a
12 + //! permanent test by copying one file into `fuzz/regressions/`, and neither
13 + //! side can drift into checking less than the other.
14 + //!
15 + //! It covers BOTH scoping entry points. The doors were counted before this was
16 + //! written, as the task instructs: nine call sites, all in MNW server, but two
17 + //! entry points -- `sanitize_css` for a profile or project page and
18 + //! `sanitize_item_css` for the item pages that wear the parent project's
19 + //! styling re-scoped to `.item-canvas#ic-`. Only the first had an oracle over
20 + //! it until this target was built.
21 + //!
22 + //! ## It parses the output rather than scanning it
23 + //!
24 + //! Worth knowing before reading a finding from this target. The oracle it
25 + //! replaced matched substrings over the whole stylesheet and had four false
26 + //! positives, the plainest of which was a two-step `@keyframes`: the step
27 + //! `100%` read as a selector that had escaped the canvas. Ordinary creator CSS,
28 + //! reported as a security finding. What ships reparses the printed sheet and
29 + //! walks the AST, so `@import` is forbidden as a rule rather than as a string
30 + //! and a `content: "@import"` is what it is -- text.
31 +
32 + #![no_main]
33 +
34 + use libfuzzer_sys::fuzz_target;
35 + use std::sync::LazyLock;
36 +
37 + /// The owner scope woven into the canvas selector, and the one
38 + /// `tests/regressions.rs` replays with, so a crash reproduces there unchanged.
39 + /// It must stay id-safe: the sanitizer refuses a scope that is not, and fuzzing
40 + /// that refusal would only measure `is_id_safe`.
41 + const OWNER_SCOPE: &str = "11111111-1111-1111-1111-111111111111";
42 +
43 + static POLICY: LazyLock<custom_pages::UrlPolicy> = LazyLock::new(|| {
44 + custom_pages::UrlPolicy::new(
45 + "https://u.makenot.work/alice/proj",
46 + [
47 + "makenot.work".to_string(),
48 + "u.makenot.work".to_string(),
49 + "cdn.makenot.work".to_string(),
50 + ],
51 + )
52 + .expect("the fixture policy is well-formed")
53 + });
54 +
55 + fuzz_target!(|input: &str| {
56 + custom_pages::oracle::check_css(input, OWNER_SCOPE, &POLICY);
57 + });
@@ -1,0 +1,1 @@
1 + @media (m=in-width: 600px) { p { color* blue } }
Binary file
@@ -1,0 +1,1 @@
1 + -- &x { @ (in- w
@@ -1,0 +1,1 @@
1 + &&&&&&&&&&&&&&&&&&&&&&&&&&&&&&& &&&&&&&&&&&&&&&&&&&&&&&&&&&& { an&&&&&&&&&&&&&&&&&&& { an&&&&&&&&&&& { animation~a{ an&&&&&&&&&&&&&&&&&&& { an&&&&&&&&&&& { animation~a
@@ -1,0 +1,1 @@
1 + --,aZinon:\\\^\-,aZinon:\\\^\\\\ { mtaaZinonta,aZinon:\\scale\ { mtaaZi0s io, oy d oy d dt ,t {-o, dt ,t {-o, oy\\\ { mtaaZinonta,aZinon:\\scale\ { mtaaZi0s io, oy d oy d dt ,t {-o, dt ,t {-o, oydy dt ,t { r