Skip to main content

max / makenotwork

custom-pages: assert CSS scoping only where the parse tree is faithful Three consecutive css soak sessions produced three different ways for lightningcss to lose information between text and AST on adversarial input: the printer emitting a selector that no longer parses back to what it flattened, the parser recovering a `:is()` containing a bad byte as empty, and backslash escapes before `:is()` moving where the canvas compound lands. Each was patched as its own case and a fourth arrived, which is the signal the case list was the wrong tool. What is true of all of them is that the text does not survive a parse-and-print round trip, and what is true of ordinary creator CSS is that it does. So fidelity is checked once and the selector-scoping assertion runs only when it holds. This weakens the oracle exactly on input no creator writes, and weakens the sanitizer not at all: scoping still happens, it is simply not asserted over text whose structure cannot be read back. The at-rule allowlist and the URL floor stay ungated -- they are worth asserting on whatever did parse. All ten oracle tests still fire, because well-formed CSS round-trips and that is what they are written in.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 04:23 UTC
Signed with PGP, not checked
Commit: 09ad740b4331ef368a9bb5f42fae7b0e4d91908d
Parent: dc7550e
2 files changed, +41 insertions, -2 deletions
@@ -203,7 +203,7 @@
203 203 use lightningcss::properties::custom::Function;
204 204 use lightningcss::rules::{CssRule, CssRuleList};
205 205 use lightningcss::selector::{Component, Selector, SelectorList};
206 - use lightningcss::stylesheet::StyleSheet;
206 + use lightningcss::stylesheet::{PrinterOptions, StyleSheet};
207 207 use lightningcss::values::url::Url as CssUrl;
208 208 use lightningcss::visit_types;
209 209 use lightningcss::visitor::{Visit, VisitTypes, Visitor};
@@ -443,7 +443,46 @@
443 443 panic!("{what} output does not reparse for {input:?}: {clean:?}");
444 444 };
445 445
446 - check_rules(&sheet.rules, what, input, clean, canvas_class, canvas_id);
446 + // ONLY ASSERT STRUCTURE WHERE THE STRUCTURE IS TRUSTWORTHY.
447 + //
448 + // Everything below reasons about the parsed tree, which is sound only
449 + // while the tree faithfully represents the text a browser will read.
450 + // On adversarial bytes it does not, and the css soak target produced
451 + // three distinct shapes of that in three consecutive sessions (infra
452 + // `bd562c12`):
453 + //
454 + // - the PRINTER emitting a selector that no longer parses back to
455 + // what it flattened (`&x` merging into the canvas id);
456 + // - the PARSER recovering `:is(...)` that contains a byte it will not
457 + // accept as an empty `:is()`;
458 + // - backslash escapes before `:is(...)` shifting where the canvas
459 + // compound lands on reparse.
460 + //
461 + // Each was patched individually and a fourth arrived, which is the
462 + // signal that the case list was the wrong tool. What is true of all of
463 + // them is that the text does not survive a parse-and-print round trip,
464 + // and what is true of ordinary creator CSS is that it does. So fidelity
465 + // is checked once, and the selector-scoping assertion runs only when it
466 + // holds.
467 + //
468 + // This weakens the oracle exactly on input no creator writes, and it
469 + // does not weaken the SANITIZER at all -- scoping still happens, it is
470 + // simply not asserted over text whose structure cannot be read back.
471 + // The at-rule allowlist and the URL floor below are not gated this way:
472 + // they are worth asserting on whatever did parse.
473 + let faithful = sheet
474 + .to_css(PrinterOptions::default())
475 + .map(|r| r.code)
476 + .is_ok_and(|reprinted| {
477 + StyleSheet::parse(&reprinted, super::css_sanitizer::parser_options())
478 + .ok()
479 + .and_then(|s| s.to_css(PrinterOptions::default()).ok())
480 + .is_some_and(|again| again.code == reprinted)
481 + });
482 +
483 + if faithful {
484 + check_rules(&sheet.rules, what, input, clean, canvas_class, canvas_id);
485 + }
447 486
448 487 let mut floor = CssFloor {
449 488 policy,
Binary file