fix(site-builder): address Task 25 review findings on <style> scoping

Four issues from adversarial review of the block-scoped <style> feature:

1. (Critical) transformBlock() recursed once per @media/@supports/@container
   nesting level with no cap -- ~7000 nested rules blew the call stack, and
   nothing between a Custom HTML block's toHtml() and the publish pipeline
   catches exceptions, so this took down the whole page's publish and
   crashed the live editor on every keystroke. Added MAX_NESTING_DEPTH=20
   (pass the body through unscoped beyond it) and wrapped scopeCss() so it
   never throws on any input, matching repairOrphanNodes's existing
   contract. Caught and fixed a variable-shadowing bug in my own first pass
   at this: the new depth parameter was silently shadowed by a pre-existing
   `let depth` used for brace-matching in the same block, which would have
   defeated the cap with no type error.

2. (Important) FORCE_BODY: true was unconditional, but it isn't a no-op for
   style-free input: it also changes how the parser preserves whitespace
   after a LEADING html comment, which this repo's own fixture starts with.
   Verified via a raw byte-diff against HtmlBlock.tsx@6a9b227 (extracted
   verbatim, run standalone against real dompurify+jsdom) that the fixture
   gained bytes. Fixed by applying FORCE_BODY only when the input has a
   real (non-comment) <style> tag to rescue -- confirmed empirically that
   this is a true no-op for every other input. Pinned the old output as a
   checked-in regression fixture and added a raw toBe() diff test.

3. (Important) scopeStyleBlocks() wasn't idempotent -- pasting previously
   published/exported output into a fresh block nested a second wrapper
   and re-prefixed every selector. Added isAlreadyScoped(), which detects
   a lone root wrapper whose <style> content is already a no-op under
   scopeCss for that wrapper's own class (reusing scopeCss's own
   idempotency guarantee) and leaves it untouched.

4. (Minor) Documented, not fixed: the 32-bit scope-id hash is
   brute-forceable (CSS-only impact, same trust tier as other accepted
   risks here), and DOMPurify's SAFE_FOR_XML silently drops an entire
   <style> block when its content merely looks tag-like (e.g.
   content: "<Read More>").

1155/1155 tests passing (was 1141), tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 17:36:12 -07:00
co-authored by Claude Opus 5
parent 32f4092156
commit 916a568e9f
5 changed files with 828 additions and 30 deletions
+99
View File
@@ -250,3 +250,102 @@ describe('scopeCss -- misc/edge cases', () => {
expect(scopeCss(input, SCOPE)).toBe(input);
});
});
/** Wraps `inner` in `depth` levels of nested `@media`, each with a trivial
* always-true-shaped condition. Used to probe/prove the recursion depth cap. */
function nestMedia(inner: string, depth: number): string {
let css = inner;
for (let i = 0; i < depth; i++) css = `@media (min-width: 1px) {${css}}`;
return css;
}
describe('scopeCss -- review finding: bounded recursion depth (was: unbounded, crashed on ~7000 nested @media)', () => {
test('nesting comfortably under the cap: the innermost selector IS scoped', () => {
const input = nestMedia('h1{color:red}', 5);
const out = scopeCss(input, SCOPE);
expect(out).toContain(`${SCOPE} h1{color:red}`);
});
test('nesting far past the cap does not throw, and stops scoping beyond the cap (unscoped fallback, not a crash)', () => {
const input = nestMedia('h1{color:red}', 1000);
expect(() => scopeCss(input, SCOPE)).not.toThrow();
const out = scopeCss(input, SCOPE);
// The innermost rule sits far beyond MAX_NESTING_DEPTH -- it must come
// through UNSCOPED (the documented fallback), not silently dropped and
// not scoped from some unexpected point.
expect(out).not.toContain(SCOPE);
expect(out).toContain('h1{color:red}');
});
test('the exact review repro: ~7000 nested @media, ~190KB-shaped input, does not throw', () => {
const input = nestMedia('h1{color:red}', 7000);
expect(() => scopeCss(input, SCOPE)).not.toThrow();
// Structural integrity: every opened @media brace is still closed --
// the cap changes WHAT gets scoped, never the brace structure/count.
const out = scopeCss(input, SCOPE);
const opens = (out.match(/\{/g) || []).length;
const closes = (out.match(/\}/g) || []).length;
expect(opens).toBe(closes);
expect(opens).toBe(7001); // 7000 @media wrapper braces + the innermost rule's own brace pair
});
});
describe('scopeCss -- review finding: never throws, on any input (property test over malformed/adversarial strings)', () => {
// Deterministic pseudo-random generator (mulberry32) -- NOT Math.random.
// A property test that can flake between CI runs is worse than no
// property test: a failure must be reproducible from the fixed seed
// below, every time, so it can actually be debugged.
function mulberry32(seed: number): () => number {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const ALPHABET = ['{', '}', '(', ')', ';', ',', '"', "'", '@', '/', '*', ':', 'a', 'h1', ' ', '\n', '\\', '<', '>'];
function randomGarbageCss(rand: () => number, length: number): string {
let out = '';
while (out.length < length) {
out += ALPHABET[Math.floor(rand() * ALPHABET.length)];
}
return out;
}
test('1000 random malformed CSS strings (unbalanced braces, dangling quotes/comments, stray @/,/:) never throw', () => {
const rand = mulberry32(42);
for (let i = 0; i < 1000; i++) {
const garbage = randomGarbageCss(rand, 1 + Math.floor(rand() * 200));
expect(() => scopeCss(garbage, SCOPE)).not.toThrow();
}
});
test('specific known-nasty malformed inputs never throw', () => {
const nasty = [
'{{{{{{{{{{',
'}}}}}}}}}}',
'{'.repeat(5000),
'/*'.repeat(2000),
'"'.repeat(2000),
'@media'.repeat(2000),
'h1'.repeat(50000), // pathologically long single token, no braces at all
'',
' ',
'',
'@media (min-width: 1px) {'.repeat(3000), // unbalanced: opens with no closes
];
for (const input of nasty) {
expect(() => scopeCss(input, SCOPE)).not.toThrow();
}
});
test('scopeCss never throws even if given a pathologically deep input AND a pathological scope selector', () => {
const input = nestMedia('h1, h2, h3 { color: red; }', 500);
const weirdScope = '.' + 'x'.repeat(10000);
expect(() => scopeCss(input, weirdScope)).not.toThrow();
});
});
+73 -11
View File
@@ -35,8 +35,41 @@
* - Any other at-rule with a `{ }` body (`@page`, `@layer`, ...) is copied
* through untouched -- deliberately conservative rather than guessing at
* a selector-scoping rule for at-rules this task doesn't enumerate.
*
* Robustness (review finding, Task 25 follow-up): `transformBlock` recurses
* once per nesting level of `@media`/`@supports`/`@container`. A
* pathological block -- ~7000 nested `@media` rules reproduced through the
* real `purifyHtml()` call, ~190KB of CSS -- blew the call stack
* (`RangeError: Maximum call stack size exceeded`), and there is NO error
* handling anywhere between a Custom HTML block's `toHtml()` and the
* publish pipeline (`html-export.ts`'s `renderNode()` calls
* `component.toHtml()` with no try/catch in its own recursion), nor an
* `ErrorBoundary` around the editor canvas -- so one malformed or malicious
* block would have taken down an entire page's publish, and crashed the
* live editor on every keystroke. Two independent defenses, matching this
* codebase's existing contract for best-effort transforms (see
* `repairOrphanNodes` in `orphan-repair.ts`, which never throws either):
* 1. `MAX_NESTING_DEPTH` caps the recursion; beyond it, a `@media`/
* `@supports`/`@container` body is passed through UNSCOPED rather than
* recursed into further -- unscoped CSS inside an absurdly-deep at-rule
* is a far better failure than a dead publish.
* 2. `scopeCss` itself is wrapped so it can never throw on ANY input --
* if anything unexpected still slips past (1), the original CSS is
* returned unscoped rather than propagating the exception. A page must
* never fail to render because a best-effort transform couldn't parse
* something.
*/
/**
* Real-world CSS nesting (the cases this file explicitly supports --
* `@media`/`@supports`/`@container`) is almost always 1 level deep, rarely
* 2-3. 20 is generous headroom for any legitimate customer stylesheet while
* still stopping a multi-thousand-rule pathological/malicious input well
* short of the JS engine's real call-stack limit (which varies by engine
* and is not something this code should ever get close to testing).
*/
const MAX_NESTING_DEPTH = 20;
/**
* If a CSS comment (`/* ... *\/`) or a single/double-quoted string starts at
* `css[i]`, returns the index immediately after it. Otherwise returns `i`
@@ -152,9 +185,10 @@ const IMPORT_RE = /^@import\b/i;
* `selector { declarations }` rules and/or at-rules. Used both for the
* top-level stylesheet and, recursively, for the body of a `@media`/
* `@supports`/`@container` block (whose contents are themselves a nested
* sequence of rules).
* sequence of rules). `depth` counts how many `@media`/`@supports`/
* `@container` levels deep this call already is -- see `MAX_NESTING_DEPTH`.
*/
function transformBlock(css: string, scopeSelector: string): string {
function transformBlock(css: string, scopeSelector: string, depth: number): string {
let result = '';
let i = 0;
const n = css.length;
@@ -213,23 +247,30 @@ function transformBlock(css: string, scopeSelector: string): string {
// css[j] === '{' : find the matching close brace for THIS block,
// comment/string aware, counting nested braces (a @media block's body
// contains further `{ }` rule blocks).
// contains further `{ }` rule blocks). Named `braceDepth` -- deliberately
// NOT `depth` -- to avoid shadowing the outer `depth` parameter (the
// @media/@supports/@container NESTING depth used by MAX_NESTING_DEPTH
// below): a `let depth` here would be block-scoped to this same `while`
// body and silently shadow the parameter for the rest of this iteration,
// making the recursion-depth check below always compare against this
// brace-matching counter (which is always 0 by the time control reaches
// it) instead -- defeating the cap entirely without any type error.
const blockContentStart = j + 1;
let depth = 1;
let braceDepth = 1;
let k = blockContentStart;
while (k < n && depth > 0) {
while (k < n && braceDepth > 0) {
const skip = skipCommentOrString(css, k);
if (skip !== k) {
k = skip;
continue;
}
const ch = css[k];
if (ch === '{') depth += 1;
else if (ch === '}') depth -= 1;
if (ch === '{') braceDepth += 1;
else if (ch === '}') braceDepth -= 1;
k += 1;
}
// k is now just past the matching '}' (or end of string if unterminated).
const blockContentEnd = depth === 0 ? k - 1 : k;
const blockContentEnd = braceDepth === 0 ? k - 1 : k;
const body = css.slice(blockContentStart, blockContentEnd);
if (KEYFRAMES_RE.test(trimmedPrelude) || FONT_FACE_RE.test(trimmedPrelude)) {
@@ -240,8 +281,13 @@ function transformBlock(css: string, scopeSelector: string): string {
result += prelude + '{' + body + '}';
} else if (RECURSE_RE.test(trimmedPrelude)) {
// @media/@supports/@container: keep the condition prelude as-is,
// recursively scope the selectors nested inside.
result += prelude + '{' + transformBlock(body, scopeSelector) + '}';
// recursively scope the selectors nested inside -- UNLESS we've hit
// MAX_NESTING_DEPTH, in which case stop recursing and pass the body
// through unscoped rather than risk a stack overflow. Unscoped CSS
// nested this deep is an extreme edge case (or a hostile input) --
// a far better failure than crashing the publish pipeline / editor.
const inner = depth >= MAX_NESTING_DEPTH ? body : transformBlock(body, scopeSelector, depth + 1);
result += prelude + '{' + inner + '}';
} else if (trimmedPrelude.startsWith('@')) {
// Any other braced at-rule (@page, @layer, ...): conservatively leave
// untouched rather than guess at a scoping rule this task doesn't
@@ -264,8 +310,24 @@ function transformBlock(css: string, scopeSelector: string): string {
* Custom HTML block's own wrapper element). Pure function: same
* `(css, scopeSelector)` in, same string out, every time -- see the module
* doc comment above for the exact at-rule/selector rules applied.
*
* NEVER THROWS, on any input -- same defensive contract as
* `repairOrphanNodes` elsewhere in this codebase. `MAX_NESTING_DEPTH` above
* should make a stack overflow unreachable in practice, but this is the
* last line of defense: if `transformBlock` throws for any reason this
* function did not anticipate, the original CSS is returned completely
* unscoped rather than letting the exception reach `purifyHtml()` and, from
* there, either the live editor's render or the publish pipeline (which has
* no error handling of its own around a component's `toHtml()`). Unscoped
* CSS leaking page-wide is a real but bounded failure (CSS can misstyle,
* never execute); a thrown exception here is unbounded (dead publish, dead
* editor canvas) -- so this function must always prefer the former.
*/
export function scopeCss(css: string, scopeSelector: string): string {
if (!css) return '';
return transformBlock(css, scopeSelector);
try {
return transformBlock(css, scopeSelector, 0);
} catch {
return css;
}
}