diff --git a/craft/src/components/basic/HtmlBlock.test.ts b/craft/src/components/basic/HtmlBlock.test.ts index d35d786..4c46b9e 100644 --- a/craft/src/components/basic/HtmlBlock.test.ts +++ b/craft/src/components/basic/HtmlBlock.test.ts @@ -2,6 +2,16 @@ import { describe, test, expect } from 'vitest'; import { purifyHtml } from './HtmlBlock'; import { stableHash } from '../../utils/escape'; 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', () => { test('strips script tags', () => { @@ -294,3 +304,97 @@ describe('purifyHtml -- Task 25: security properties of the newly-allowed

x

`; + } + + 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('

x

'); + 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(/^
/); + }); +}); + +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 = '

Hi

'; + const once = purifyHtml(code); + const twice = purifyHtml(once); + expect(twice).toBe(once); + // Specifically: no second wrapper div, no double-prefixed selector. + expect((twice.match(/
{ + const code = '

Hi

'; + const gen1 = purifyHtml(code); + const gen2 = purifyHtml(gen1); + const gen3 = purifyHtml(gen2); + expect(gen3).toBe(gen1); + }); +}); diff --git a/craft/src/components/basic/HtmlBlock.tsx b/craft/src/components/basic/HtmlBlock.tsx index 1215c5a..b99129e 100644 --- a/craft/src/components/basic/HtmlBlock.tsx +++ b/craft/src/components/basic/HtmlBlock.tsx @@ -78,6 +78,21 @@ const PURIFY_CONFIG = { // DOMPurify runs, so it can only match inside this block's own wrapper // element. See the FORCE_BODY comment below and scopeStyleBlocks() for // 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 ` -- a very plausible - // paste, style-before-markup is a common snippet shape -- would vanish - // with no error anywhere, despite ` -- a very +// plausible paste, style-before-markup is a common snippet shape -- would +// vanish with no error anywhere, despite ...
` 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
, whose class matches our own + * naming convention, and whose `