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
@@ -2,6 +2,16 @@ import { describe, test, expect } from 'vitest';
import { purifyHtml } from './HtmlBlock'; import { purifyHtml } from './HtmlBlock';
import { stableHash } from '../../utils/escape'; import { stableHash } from '../../utils/escape';
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw'; import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
// Ground truth "before" output: purifyHtml(fixtureHtml) computed with the
// EXACT HtmlBlock.tsx code as it stood at commit 6a9b227 (the commit
// immediately before Task 25 -- `git show
// 6a9b227:craft/src/components/basic/HtmlBlock.tsx`), run against the real
// dompurify+jsdom, not guessed at or re-derived from reading the code. See
// the "byte-diff against 6a9b227" describe block below -- this is the
// literal regression check the Task 25 review asked for, after the first
// round of `.not.toContain(...)`-style tests passed while FORCE_BODY was
// silently changing output for a comment-led, style-free fixture.
import preTask25FixtureOutput from './__fixtures__/html-block-test-body.pre-task25-output.html?raw';
describe('purifyHtml', () => { describe('purifyHtml', () => {
test('strips script tags', () => { test('strips script tags', () => {
@@ -294,3 +304,97 @@ describe('purifyHtml -- Task 25: security properties of the newly-allowed <style
expect(out).toContain('tracker.example'); expect(out).toContain('tracker.example');
}); });
}); });
describe('purifyHtml -- review finding: raw byte-diff against HtmlBlock.tsx@6a9b227 (the commit before Task 25)', () => {
// Round 1 of this task's tests used `.not.toContain(...)`/`.toContain(...)`
// assertions for the "no <style> => unchanged" guarantee. Those all
// passed while FORCE_BODY: true (applied unconditionally at the time)
// was silently changing the ACTUAL bytes for any style-free block that
// starts with a multi-line HTML comment -- including this repo's own
// fixture, which is exactly that shape. `.not.toContain` can't catch an
// extra leading newline; only a raw diff against the real old output
// can. These tests do that: `preTask25FixtureOutput` is
// `purifyHtml(fixtureHtml)` computed with the UNMODIFIED HtmlBlock.tsx
// source at 6a9b227 (via `git show 6a9b227:...`), run against the real
// dompurify+jsdom, not re-derived from reading the code -- see that
// fixture file's own header comment.
test('the fixture (comment-led, no <style>) is byte-identical to the pre-Task-25 output', () => {
expect(fixtureHtml.startsWith('<!--')).toBe(true); // sanity: this IS the comment-led shape
expect(purifyHtml(fixtureHtml)).toBe(preTask25FixtureOutput);
});
test('a short comment-led, style-free block matches pre-Task-25 output exactly (including the dropped leading whitespace quirk)', () => {
// Confirmed independently against 6a9b227's exact code: a multi-line
// leading comment followed by blank-line whitespace, with no <style>
// anywhere, produces "<p>hi</p>" -- both the comment AND the
// whitespace between it and <p> are dropped by the parser's
// "before head" insertion-mode rules (unrelated to this task; that's
// the pre-existing, unconditional behavior with FORCE_BODY off). The
// point of this test is that the NEW code must reproduce that exact
// old quirk byte-for-byte for style-free input, not "improve" on it.
const commentLed =
'<!-- ============================================================\n' +
' HTML test fixture header\n' +
' ============================================================ -->\n' +
'\n<p>hi</p>';
expect(purifyHtml(commentLed)).toBe('<p>hi</p>');
});
test('plain style-free inputs (no comment involved) still match pre-Task-25 output', () => {
expect(purifyHtml('<p>hello</p>')).toBe('<p>hello</p>');
expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
});
});
describe('purifyHtml -- review finding: never throws, even on pathological deeply-nested @media input', () => {
function buildDeeplyNestedMedia(count: number): string {
// ~7000 nested @media blocks (the review's exact repro shape) reproduced
// through the REAL purifyHtml() call, not just scopeCss() in isolation
// -- proving the fix holds end-to-end through DOMPurify + scopeStyleBlocks,
// not merely in the unit-tested function.
let css = 'h1{color:red}';
for (let i = 0; i < count; i++) css = `@media (min-width: 1px) {${css}}`;
return `<style>${css}</style><h1>x</h1>`;
}
test('~7000 levels of nested @media does not crash purifyHtml (was: RangeError: Maximum call stack size exceeded)', () => {
const code = buildDeeplyNestedMedia(7000);
expect(() => purifyHtml(code)).not.toThrow();
const out = purifyHtml(code);
expect(out).toContain('<h1>x</h1>');
expect(out).toContain('@media');
});
test('a scope class + wrapper is still produced for the pathological input (best-effort, not a silent no-op)', () => {
const code = buildDeeplyNestedMedia(7000);
const out = purifyHtml(code);
expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
});
});
describe('purifyHtml -- review finding: idempotent over its own prior output', () => {
test('running purifyHtml() twice (customer pastes previously-published output into a fresh block) does not nest a second wrapper', () => {
const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
const once = purifyHtml(code);
const twice = purifyHtml(once);
expect(twice).toBe(once);
// Specifically: no second wrapper div, no double-prefixed selector.
expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
});
test('idempotent for a block using :root/media too', () => {
const code = '<style>:root{--x:1} @media (min-width: 600px) { h1, p { color: red; } }</style><h1>Hi</h1><p>x</p>';
const once = purifyHtml(code);
const twice = purifyHtml(once);
expect(twice).toBe(once);
expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
});
test('three generations (paste published output into a block, publish again, paste THAT) stay stable', () => {
const code = '<style>h1{color:red}</style><h1>Hi</h1>';
const gen1 = purifyHtml(code);
const gen2 = purifyHtml(gen1);
const gen3 = purifyHtml(gen2);
expect(gen3).toBe(gen1);
});
});
+133 -15
View File
@@ -78,6 +78,21 @@ const PURIFY_CONFIG = {
// DOMPurify runs, so it can only match inside this block's own wrapper // DOMPurify runs, so it can only match inside this block's own wrapper
// element. See the FORCE_BODY comment below and scopeStyleBlocks() for // element. See the FORCE_BODY comment below and scopeStyleBlocks() for
// why allowing the tag alone is not sufficient. // why allowing the tag alone is not sufficient.
//
// Review note (Task 25 follow-up, documented not fixed): DOMPurify's
// SAFE_FOR_XML default (on unless a caller explicitly disables it,
// which PURIFY_CONFIG does not) silently drops an ENTIRE <style>
// element -- not just the offending part -- if its text content
// contains anything that merely LOOKS tag-like (a `<` followed by a
// word character, `/`, or `!`), as an mXSS-namespace-confusion defense
// that isn't specific to <style>. So `.x::after{content:"<Read
// More>"}` -- a plausible, entirely benign real-world CSS content
// string -- makes the whole style block vanish with no error, the same
// way a `<script>` would. This is a GOOD security property (better
// paranoid than exploitable), but it's an undocumented interaction
// with this newly-widened surface that will otherwise confuse whoever
// debugs the inevitable "my CSS just disappeared" report -- confirmed
// empirically against dompurify+jsdom directly, not guessed at.
'style', 'style',
], ],
// NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute // NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute
@@ -137,24 +152,57 @@ const PURIFY_CONFIG = {
// scopeStyleBlocks() doesn't care about namespace/nesting depth). // scopeStyleBlocks() doesn't care about namespace/nesting depth).
FORBID_TAGS: ['script','object','embed','link','meta'], FORBID_TAGS: ['script','object','embed','link','meta'],
FORBID_ATTR: [/^on/i], FORBID_ATTR: [/^on/i],
// Task 25: without this, DOMPurify parses `input` as a full (mini) HTML // NOTE: FORCE_BODY is deliberately NOT set here -- see
// document via DOMParser and only serializes <body>'s contents. Per the // needsForceBody()/purifyHtml() below. It's applied conditionally, per
// HTML5 parsing algorithm, a tag that can only legally appear in <head> // call, only when the input actually has a real <style> tag to rescue.
// -- and now that <style> is allowed, that includes <style> -- gets };
// implicitly placed in <head> when it appears before any other content,
// and is silently lost (DOMPurify never looks at <head>). A block whose // Task 25: without FORCE_BODY, DOMPurify parses `input` as a full (mini)
// entire `code` is `<style>h1{color:red}</style>` -- a very plausible // HTML document via DOMParser and only serializes <body>'s contents. Per
// paste, style-before-markup is a common snippet shape -- would vanish // the HTML5 parsing algorithm, a tag that can only legally appear in
// with no error anywhere, despite <style> sitting right there in // <head> -- and now that <style> is allowed, that includes <style> --
// gets implicitly placed in <head> when it appears before any other real
// content, and is silently lost (DOMPurify never looks at <head>). A block
// whose entire `code` is `<style>h1{color:red}</style>` -- a very
// plausible paste, style-before-markup is a common snippet shape -- would
// vanish with no error anywhere, despite <style> sitting right there in
// ALLOWED_TAGS. FORCE_BODY prepends an internal element before parsing so // ALLOWED_TAGS. FORCE_BODY prepends an internal element before parsing so
// the parser is already in body-insertion-mode by the time it reaches the // the parser is already in body-insertion-mode by the time it reaches the
// customer's first tag, keeping a leading <style> (or anything else) in // customer's first tag, keeping a leading <style> (or anything else) in
// <body> where DOMPurify's body-only serialization actually looks. // <body> where DOMPurify's body-only serialization actually looks.
// Confirmed empirically against dompurify+jsdom directly (not just this // Confirmed empirically against dompurify+jsdom directly (not just this
// app's behavior) -- see the "leading <style> with nothing before it" // app's behavior) -- see the "leading <style> with nothing before it" test
// test in HtmlBlock.test.ts. // in HtmlBlock.test.ts.
FORCE_BODY: true, //
}; // Review finding (Task 25 follow-up): FORCE_BODY is NOT a no-op for input
// that has no <style> tag at all. It also changes how the HTML parser
// treats character content sitting between a LEADING comment and the next
// real tag -- normal parsing (before <body> is established) silently drops
// pure-whitespace text runs there per the HTML5 "before head" insertion
// mode rules, while FORCE_BODY (already in body-insertion-mode from the
// first token) preserves that whitespace as a real text node. Concretely:
// a block starting with a multi-line HTML comment -- this repo's own
// ~16KB fixture does exactly that -- gained 2 extra leading bytes (a
// preserved newline) once FORCE_BODY was unconditionally on, which
// silently broke the "blocks without <style> are byte-identical to
// pre-Task-25 output" guarantee (confirmed with a raw diff against
// HtmlBlock.tsx@6a9b227 -- the commit immediately before this task -- over
// the fixture and a comment-led block; see HtmlBlock.test.ts). Fix: only
// ever set FORCE_BODY when the input has a real <style> tag to rescue --
// the one and only case that needs it -- so every other input takes
// exactly the pre-Task-25 code path, unchanged.
//
// "Real" deliberately excludes a `<style` substring that only appears
// inside an HTML comment (e.g. a customer's own code-sample text
// mentioning `<style>`) -- that text can never become an actual <style>
// element, but naively substring-matching it would still flip FORCE_BODY
// on and reintroduce the exact same whitespace-preservation side effect
// for a block that never had, and never needed, real style scoping.
const STYLE_TAG_RE = /<style[\s>/]/i;
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
function needsForceBody(input: string): boolean {
return STYLE_TAG_RE.test(input.replace(HTML_COMMENT_RE, ''));
}
// M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case) // M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case)
// but an iframe with a `src` and NO `sandbox` attribute is a clickjacking/ // but an iframe with a `src` and NO `sandbox` attribute is a clickjacking/
@@ -205,15 +253,80 @@ const IFRAME_SANDBOX_HOOK = (node: Element): void => {
* on its own -- same code in, byte-identical output out, every time, in * on its own -- same code in, byte-identical output out, every time, in
* both places it's called. * both places it's called.
*/ */
const SCOPE_CLASS_RE = /^whp-html-[0-9a-z]+$/;
/**
* Idempotency (review finding, Task 25 follow-up): `purifyHtml()` is not
* reachable-with-its-own-output through any CURRENT code path, but nothing
* stops a customer from pasting previously-published or exported HTML from
* this exact feature into a fresh Custom HTML block -- at which point
* `code` already contains our own `<div class="whp-html-OLD">...<style>
* .whp-html-OLD h1{...}</style>...</div>` wrapper. Without this check,
* `scopeStyleBlocks` would hash the NEW `code` to a NEW scope class, fail
* to recognise the embedded selectors as already scoped (they're prefixed
* for the OLD class, not the new one `scopeCss`'s own idempotency guard
* checks against), and nest a second wrapper div around the first while
* re-prefixing every selector under the new class on top of the old one.
*
* Detects "the sanitized content IS ALREADY exactly one of our own scoped
* wrappers": a single root element, a <div>, whose class matches our own
* naming convention, and whose `<style>` descendant(s) are each already a
* no-op under `scopeCss` for that div's own class -- i.e. re-scoping would
* change nothing. That last check reuses `scopeCss`'s own idempotency
* guarantee (`scopeCss(scopeCss(x, S), S) === scopeCss(x, S)`, proved in
* scope-css.test.ts) rather than re-implementing "is this CSS already
* scoped" as a second parser: if scoping again under the div's own class
* is a no-op, the CSS is already confined to that div, regardless of
* whether this app was the one that put it there -- which is the actual
* safety property this function exists to guarantee, not merely a proxy
* for it.
*/
function isAlreadyScoped(container: HTMLElement): boolean {
if (container.children.length !== 1) return false;
const root = container.children[0];
if (root.tagName !== 'DIV') return false;
const cls = root.getAttribute('class') || '';
if (!SCOPE_CLASS_RE.test(cls)) return false;
const scopeSelector = `.${cls}`;
const styleEls = Array.from(root.querySelectorAll('style'));
if (styleEls.length === 0) return false; // matches our naming by coincidence but scopes nothing -- not ours to protect
return styleEls.every((el) => {
const text = el.textContent || '';
if (text.trim() === '') return true;
return scopeCss(text, scopeSelector) === text;
});
}
function scopeStyleBlocks(sanitized: string, rawCode: string): string { function scopeStyleBlocks(sanitized: string, rawCode: string): string {
if (!sanitized.includes('<style')) return sanitized; if (!sanitized.includes('<style')) return sanitized;
const container = document.createElement('div'); const container = document.createElement('div');
container.innerHTML = sanitized; container.innerHTML = sanitized;
if (isAlreadyScoped(container)) return sanitized;
const styleEls = Array.from(container.querySelectorAll('style')); const styleEls = Array.from(container.querySelectorAll('style'));
const nonEmpty = styleEls.filter((el) => (el.textContent || '').trim() !== ''); const nonEmpty = styleEls.filter((el) => (el.textContent || '').trim() !== '');
if (nonEmpty.length === 0) return sanitized; if (nonEmpty.length === 0) return sanitized;
// Review note (Task 25 follow-up, documented not fixed): `stableHash` is
// a 32-bit djb2 hash, so it's brute-forceable in principle -- a customer
// could deliberately craft a second block's `code` to collide onto the
// same `whp-html-<hash>` class as an existing block on the same page, at
// which point the two blocks' <style> rules apply to (and override) each
// other, since they'd share one wrapper class. Impact is CSS-only --
// visual breakage, never script execution or data exposure -- the same
// trust tier as other accepted risks in this file (e.g. remote url() in
// style content, or the pre-existing DATA_URI_TAGS mimetype-blindness
// documented in HtmlBlock.security.test.ts). Not fixed here: closing it
// would mean either a wider hash (cheap, but every existing scope class
// set with THIS Task 25 code would silently reshuffle -- a similar
// "changing the hash function reshuffles stored HTML" cost the pinned
// hash test above already guards against happening BY ACCIDENT) or a
// collision-checked/salted scheme, either of which is a bigger design
// decision than a follow-up-review fix.
const scopeClass = `whp-html-${stableHash(rawCode)}`; const scopeClass = `whp-html-${stableHash(rawCode)}`;
for (const el of nonEmpty) { for (const el of nonEmpty) {
el.textContent = scopeCss(el.textContent || '', `.${scopeClass}`); el.textContent = scopeCss(el.textContent || '', `.${scopeClass}`);
@@ -230,8 +343,13 @@ export function purifyHtml(input: string): string {
// multiple copies of the same hook. // multiple copies of the same hook.
DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK); DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK);
try { try {
const sanitized = DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string; const raw = input || '';
return scopeStyleBlocks(sanitized, input || ''); // See needsForceBody()/the FORCE_BODY comment above PURIFY_CONFIG:
// applied only when there's a real <style> tag to rescue, so every
// other input takes the exact pre-Task-25 sanitize() call, unchanged.
const config = needsForceBody(raw) ? { ...PURIFY_CONFIG, FORCE_BODY: true } : PURIFY_CONFIG;
const sanitized = DOMPurify.sanitize(raw, config as any) as unknown as string;
return scopeStyleBlocks(sanitized, raw);
} finally { } finally {
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any); DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
} }
@@ -0,0 +1,415 @@
<a href="#main">Skip to content</a>
<header>
<h1>HTML Test Fixture</h1>
<p><small>A wide sample of elements for rendering, sanitizing, and parsing tests.</small></p>
<nav aria-label="Primary">
<ul>
<li><a href="#text">Text</a></li>
<li><a href="#lists">Lists</a></li>
<li><a href="#tables">Tables</a></li>
<li><a href="#forms">Forms</a></li>
<li><a href="#media">Media</a></li>
<li><a href="#edge">Edge cases</a></li>
</ul>
</nav>
</header>
<main id="main">
<section id="headings">
<h2>Headings</h2>
<h1>Heading level 1</h1>
<h2>Heading level 2</h2>
<h3>Heading level 3</h3>
<h4>Heading level 4</h4>
<h5>Heading level 5</h5>
<h6>Heading level 6</h6>
<hgroup>
<h2>Grouped heading</h2>
<p>Subtitle paragraph inside hgroup</p>
</hgroup>
</section>
<hr>
<section id="text">
<h2>Text and inline elements</h2>
<p>A normal paragraph with a fair amount of text so you can check line height, wrapping, and measure. It runs long enough to break across several lines in most containers, which is the whole point of including it here at all.</p>
<p>
<strong>strong</strong>, <b>b</b>, <em>em</em>, <i>i</i>, <u>u</u>,
<s>s</s>, <del>del</del>, <ins>ins</ins>, <mark>mark</mark>,
<small>small</small>, H<sub>2</sub>O, x<sup>2</sup>,
<code>inline code</code>, <kbd>Ctrl</kbd>+<kbd>C</kbd>,
<samp>output text</samp>, <var>variable</var>,
<abbr title="HyperText Markup Language">HTML</abbr>,
<dfn>definition term</dfn>,
<time datetime="2026-08-09">August 9, 2026</time>,
<data value="42">forty-two</data>,
<q>short inline quote</q>,
<cite>Cited Work</cite>,
<bdi>إسم</bdi>,
<bdo dir="rtl">reversed direction</bdo>,
<ruby><rt>kan</rt><rt>ji</rt></ruby>
</p>
<p>
Links:
<a href="#top">internal anchor</a> ·
<a href="https://example.com">absolute</a> ·
<a href="/relative/path">relative</a> ·
<a href="mailto:test@example.com">mailto</a> ·
<a href="tel:+15555550123">tel</a> ·
<a href="https://example.com" target="_blank" rel="noopener noreferrer">new tab</a> ·
<a href="#" download="">download attr</a>
</p>
<blockquote cite="https://example.com/source">
<p>A block quotation. It contains its own paragraph and a nested quote so you can check indentation stacking.</p>
<blockquote><p>Nested block quotation.</p></blockquote>
<footer><cite>Someone, Somewhere</cite></footer>
</blockquote>
<pre><code>#!/usr/bin/env bash
set -euo pipefail
for i in {1..3}; do
printf 'iteration %d\n' "$i"
done
# a deliberately long line to force horizontal overflow: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
</code></pre>
<p>Line break here,<br>after the break.</p>
<p>Word break opportunity: super<wbr>cali<wbr>fragilistic<wbr>expiali<wbr>docious</p>
<address>
Contact: <a href="mailto:admin@example.com">admin@example.com</a><br>
123 Nowhere St, Somewhere
</address>
<p>Entities: &amp; &lt; &gt; " ' © ® ™ &nbsp; — … € 😀</p>
</section>
<hr>
<section id="lists">
<h2>Lists</h2>
<h3>Unordered, nested</h3>
<ul>
<li>First item</li>
<li>Second item
<ul>
<li>Nested item
<ul><li>Deeply nested item</li></ul>
</li>
<li>Another nested item</li>
</ul>
</li>
<li>Third item with a longer body of text so that it wraps onto more than one line and you can confirm the hanging indent behaves.</li>
</ul>
<h3>Ordered variants</h3>
<ol>
<li>Default numbering</li>
<li>Second
<ol type="a"><li>Lower alpha</li><li>Second alpha</li></ol>
</li>
</ol>
<ol start="5" reversed="">
<li>Reversed, starting at 5</li>
<li>Next</li>
<li>Next</li>
</ol>
<h3>Description list</h3>
<dl>
<dt>Term one</dt>
<dd>Definition of the first term.</dd>
<dt>Term two</dt>
<dt>Term two, alias</dt>
<dd>Definition covering both terms above.</dd>
</dl>
<h3>Menu</h3>
<menu>
<li><button type="button">Copy</button></li>
<li><button type="button">Paste</button></li>
</menu>
</section>
<hr>
<section id="tables">
<h2>Tables</h2>
<table>
<caption>Quarterly figures with spans and a footer</caption>
<colgroup>
<col span="1">
<col span="2">
<col>
</colgroup>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
<th scope="col">Notes</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>1,204</td>
<td>1,391</td>
<td rowspan="2">Shared note spanning two rows</td>
</tr>
<tr>
<th scope="row">South</th>
<td>988</td>
<td>1,022</td>
</tr>
<tr>
<th scope="row">East</th>
<td colspan="2">Merged across two quarters</td>
<td></td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td>2,192</td>
<td>2,413</td>
<td></td>
</tr>
</tfoot>
</table>
<h3>Wide table (horizontal overflow)</h3>
<table>
<tbody><tr><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>F</th><th>G</th><th>H</th><th>I</th><th>J</th><th>K</th><th>L</th></tr>
<tr><td>value-1</td><td>value-2</td><td>value-3</td><td>value-4</td><td>value-5</td><td>value-6</td><td>value-7</td><td>value-8</td><td>value-9</td><td>value-10</td><td>value-11</td><td>value-12</td></tr>
</tbody></table>
</section>
<hr>
<section>
<h2>Forms</h2>
<form action="#" method="get">
<fieldset>
<legend>Text inputs</legend>
<p><label for="f-text">Text</label> <input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled"></p>
<p><label for="f-search">Search</label> <input id="f-search" type="search" list="suggestions"></p>
<datalist id="suggestions">
<option value="alpha"></option>
<option value="beta"></option>
<option value="gamma"></option>
</datalist>
<p><label for="f-email">Email</label> <input id="f-email" type="email" required=""></p>
<p><label for="f-url">URL</label> <input id="f-url" type="url"></p>
<p><label for="f-tel">Tel</label> <input id="f-tel" type="tel" pattern="[0-9-+ ]+"></p>
<p><label for="f-pass">Password</label> <input id="f-pass" type="password" minlength="8"></p>
<p><label for="f-num">Number</label> <input id="f-num" type="number" min="0" max="100" step="5" value="25"></p>
<p><label for="f-area">Textarea</label><br><textarea id="f-area" rows="4" cols="40">Multiline
content
here</textarea></p>
<p><label for="f-ro">Readonly</label> <input id="f-ro" type="text" value="read only" readonly=""></p>
<p><label for="f-dis">Disabled</label> <input id="f-dis" type="text" value="disabled" disabled=""></p>
</fieldset>
<fieldset>
<legend>Date, time, color, range, file</legend>
<p><label for="f-date">Date</label> <input id="f-date" type="date" value="2026-08-09"></p>
<p><label for="f-time">Time</label> <input id="f-time" type="time" value="13:45"></p>
<p><label for="f-dtl">Datetime-local</label> <input id="f-dtl" type="datetime-local"></p>
<p><label for="f-month">Month</label> <input id="f-month" type="month"></p>
<p><label for="f-week">Week</label> <input id="f-week" type="week"></p>
<p><label for="f-color">Color</label> <input id="f-color" type="color" value="#336699"></p>
<p><label for="f-range">Range</label> <input id="f-range" type="range" min="0" max="10" value="7"></p>
<p><label for="f-file">File</label> <input id="f-file" type="file" multiple="" accept=".txt,.md"></p>
</fieldset>
<fieldset>
<legend>Choices</legend>
<p>
<label><input type="checkbox" name="c" value="1" checked=""> Checked</label>
<label><input type="checkbox" name="c" value="2"> Unchecked</label>
<label><input type="checkbox" name="c" value="3" disabled=""> Disabled</label>
</p>
<p>
<label><input type="radio" name="r" value="a" checked=""> Option A</label>
<label><input type="radio" name="r" value="b"> Option B</label>
</p>
<p>
<label for="f-select">Select</label>
<select id="f-select" name="select">
<option value="">— choose —</option>
<optgroup label="Group one">
<option value="1" selected="">One</option>
<option value="2">Two</option>
</optgroup>
<optgroup label="Group two" disabled="">
<option value="3">Three</option>
</optgroup>
</select>
</p>
<p>
<label for="f-multi">Multi-select</label><br>
<select id="f-multi" multiple="">
<option>Red</option><option selected="">Green</option><option>Blue</option><option>Violet</option>
</select>
</p>
</fieldset>
<fieldset>
<legend>Output and buttons</legend>
<p><label for="f-prog">Progress</label> <progress id="f-prog" value="0.6">60%</progress></p>
<p><label for="f-meter">Meter</label> <meter id="f-meter" min="0" max="100" value="72">72</meter></p>
<p><output name="result" for="f-num f-range">Computed output</output></p>
<p>
<button type="submit">Submit</button>
<button type="reset">Reset</button>
<button type="button">Plain button</button>
<button type="button" disabled="">Disabled button</button>
<input type="submit" value="Input submit">
<input type="button" value="Input button">
</p>
<input type="hidden" name="csrf" value="hidden-value">
</fieldset>
</form>
</section>
<hr>
<section id="media">
<h2>Media and embeds</h2>
<h3>Inline SVG</h3>
<svg width="180" height="90" viewBox="0 0 180 90" role="img" aria-label="Two shapes">
<rect x="5" y="5" width="80" height="80" fill="none" stroke="currentColor" stroke-width="3"></rect>
<circle cx="135" cy="45" r="40" fill="none" stroke="currentColor" stroke-width="3"></circle>
<text x="45" y="50" text-anchor="middle" font-size="14" fill="currentColor">svg</text>
</svg>
<h3>Figure with data-URI image</h3>
<figure>
<img alt="Small red square" width="64" height="64" src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'64'%20height%3D'64'%3E%3Crect%20width%3D'64'%20height%3D'64'%20fill%3D'%23c0392b'%2F%3E%3C%2Fsvg%3E">
<figcaption>Figure caption describing the image above.</figcaption>
</figure>
<h3>Broken image (alt-text fallback test)</h3>
<img src="does-not-exist.png" alt="This alt text should render because the source is missing" width="200" height="100">
<h3>Picture element</h3>
<picture>
<source media="(min-width: 800px)" srcset="wide.png">
<source media="(min-width: 400px)" srcset="medium.png">
<img src="narrow.png" alt="Responsive image fallback" width="150" height="80">
</picture>
<h3>Video and audio (sources intentionally missing)</h3>
<video controls="" width="320" poster="poster.jpg">
<source src="clip.webm" type="video/webm">
<source src="clip.mp4" type="video/mp4">
<track kind="captions" src="captions.vtt" srclang="en" label="English">
Your browser does not support the video element.
</video>
<audio controls="">
<source src="tone.ogg" type="audio/ogg">
<source src="tone.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
<h3>Canvas and iframe</h3>
<canvas width="200" height="60">Canvas fallback text</canvas>
<iframe title="Sandboxed iframe" width="300" height="120" sandbox="allow-scripts allow-same-origin allow-popups allow-forms" loading="lazy" referrerpolicy="no-referrer"></iframe>
</section>
<hr>
<section id="interactive">
<h2>Interactive and semantic containers</h2>
<details>
<summary>Collapsed disclosure</summary>
<p>Hidden content revealed on toggle.</p>
</details>
<details open="">
<summary>Open disclosure</summary>
<ul><li>With a list inside</li><li>Second item</li></ul>
</details>
<p>Non-modal dialog content.</p>
<button type="button">Close</button>
<button type="button">Open dialog</button>
<article>
<header><h3>Article header</h3></header>
<p>Article body content.</p>
<aside><p>An aside nested inside the article.</p></aside>
<footer><p>Article footer.</p></footer>
</article>
<p><span contenteditable="true">Editable inline region</span></p>
<p hidden="">This paragraph has the hidden attribute and should not render.</p>
</section>
<hr>
<section id="edge">
<h2>Edge cases</h2>
<p>Very long unbroken token (overflow test):</p>
<p>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</p>
<p>Long URL: https://example.com/a/very/long/path/segment/that/keeps/going/and/going?query=1&amp;another=2&amp;third=3#fragment-identifier</p>
<p lang="ar" dir="rtl">هذا نص عربي لاختبار الاتجاه من اليمين إلى اليسار.</p>
<p lang="he" dir="rtl">זהו טקסט עברי לבדיקה.</p>
<p lang="ja">日本語のテキストです。改行と折り返しの確認用。</p>
<p lang="de">Straßenverkehrsordnung — Grüße aus München</p>
<p>Emoji &amp; combining: 👋🏽 👨‍👩‍👧‍👦 🇺🇸 é vs é (precomposed vs combining)</p>
<p>Zero-width chars between letters: abc</p>
<p>Escaped tag text: &lt;script&gt;alert(1)&lt;/script&gt;</p>
<p>Attribute with quotes: <span title="He said &quot;hello&quot;">hover me</span></p>
<p>Empty elements follow:</p>
<div></div>
<p></p>
<ul></ul>
<table></table>
<p>Deep nesting:</p>
<div><div><div><div><div><div><div><p>Seven levels deep.</p></div></div></div></div></div></div></div>
<p>Inline element stress:
<strong><em><u><s><mark>all five at once</mark></s></u></em></strong>
</p>
<p style="color: teal;">Inline style attribute (teal).</p>
<p class="custom-class another-class" data-test-id="edge-1" data-value="42">Element with classes and data attributes.</p>
</section>
</main>
<footer>
<p><small>End of fixture — <time datetime="2026-08-09">2026-08-09</time></small></p>
</footer>
+99
View File
@@ -250,3 +250,102 @@ describe('scopeCss -- misc/edge cases', () => {
expect(scopeCss(input, SCOPE)).toBe(input); 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 * - Any other at-rule with a `{ }` body (`@page`, `@layer`, ...) is copied
* through untouched -- deliberately conservative rather than guessing at * through untouched -- deliberately conservative rather than guessing at
* a selector-scoping rule for at-rules this task doesn't enumerate. * 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 * If a CSS comment (`/* ... *\/`) or a single/double-quoted string starts at
* `css[i]`, returns the index immediately after it. Otherwise returns `i` * `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 * `selector { declarations }` rules and/or at-rules. Used both for the
* top-level stylesheet and, recursively, for the body of a `@media`/ * top-level stylesheet and, recursively, for the body of a `@media`/
* `@supports`/`@container` block (whose contents are themselves a nested * `@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 result = '';
let i = 0; let i = 0;
const n = css.length; 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, // css[j] === '{' : find the matching close brace for THIS block,
// comment/string aware, counting nested braces (a @media block's body // 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; const blockContentStart = j + 1;
let depth = 1; let braceDepth = 1;
let k = blockContentStart; let k = blockContentStart;
while (k < n && depth > 0) { while (k < n && braceDepth > 0) {
const skip = skipCommentOrString(css, k); const skip = skipCommentOrString(css, k);
if (skip !== k) { if (skip !== k) {
k = skip; k = skip;
continue; continue;
} }
const ch = css[k]; const ch = css[k];
if (ch === '{') depth += 1; if (ch === '{') braceDepth += 1;
else if (ch === '}') depth -= 1; else if (ch === '}') braceDepth -= 1;
k += 1; k += 1;
} }
// k is now just past the matching '}' (or end of string if unterminated). // 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); const body = css.slice(blockContentStart, blockContentEnd);
if (KEYFRAMES_RE.test(trimmedPrelude) || FONT_FACE_RE.test(trimmedPrelude)) { if (KEYFRAMES_RE.test(trimmedPrelude) || FONT_FACE_RE.test(trimmedPrelude)) {
@@ -240,8 +281,13 @@ function transformBlock(css: string, scopeSelector: string): string {
result += prelude + '{' + body + '}'; result += prelude + '{' + body + '}';
} else if (RECURSE_RE.test(trimmedPrelude)) { } else if (RECURSE_RE.test(trimmedPrelude)) {
// @media/@supports/@container: keep the condition prelude as-is, // @media/@supports/@container: keep the condition prelude as-is,
// recursively scope the selectors nested inside. // recursively scope the selectors nested inside -- UNLESS we've hit
result += prelude + '{' + transformBlock(body, scopeSelector) + '}'; // 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('@')) { } else if (trimmedPrelude.startsWith('@')) {
// Any other braced at-rule (@page, @layer, ...): conservatively leave // Any other braced at-rule (@page, @layer, ...): conservatively leave
// untouched rather than guess at a scoping rule this task doesn't // 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 * Custom HTML block's own wrapper element). Pure function: same
* `(css, scopeSelector)` in, same string out, every time -- see the module * `(css, scopeSelector)` in, same string out, every time -- see the module
* doc comment above for the exact at-rule/selector rules applied. * 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 { export function scopeCss(css: string, scopeSelector: string): string {
if (!css) return ''; if (!css) return '';
return transformBlock(css, scopeSelector); try {
return transformBlock(css, scopeSelector, 0);
} catch {
return css;
}
} }