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();
});
});