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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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: & < > " ' © ® ™ — … € 😀</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&another=2&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 & combining: 👋🏽 👨👩👧👦 🇺🇸 é vs é (precomposed vs combining)</p>
|
||||||
|
<p>Zero-width chars between letters: abc</p>
|
||||||
|
|
||||||
|
<p>Escaped tag text: <script>alert(1)</script></p>
|
||||||
|
<p>Attribute with quotes: <span title="He said "hello"">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>
|
||||||
@@ -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
|
||||||
|
'',
|
||||||
|
' ',
|
||||||
|
' | ||||||