feat(site-builder): add block-scoped <style> support to Custom HTML block

<style> was previously in FORBID_TAGS and stripped entirely. It's now
allowed, but its CSS is rewritten by a new hand-rolled scoper
(src/utils/scope-css.ts) so a customer's rules only match inside their own
block's wrapper -- never leak out and restyle the rest of the page. The
wrapper div (class="whp-html-<hash>") is only emitted when a block actually
has surviving <style> content, so blocks that don't use it stay
byte-identical to before this change.

Key findings, both covered by tests:
- DOMPurify's body-only serialization silently drops a <style> tag that
  appears before any other content in a block (the HTML5 parser implicitly
  places it in <head>, which DOMPurify never looks at). Fixed with
  FORCE_BODY: true.
- DOMPurify does not sanitize CSS declaration values at all (expression(),
  behavior:, url() to any host all pass through verbatim) -- @import is
  stripped explicitly by scopeCss() since it's the one CSS-level
  exfiltration/fetch vector in scope here.

Scope identifier reuses the existing djb2 stableHash() from utils/escape.ts
(already used for this exact class of problem) over the block's own `code`
string -- deterministic, no node id, no Math.random/Date.now.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5 committed 2026-08-09 17:10:57 -07:00
1 parent 6a9b227dda
commit 32f4092156
6 files changed
+831 -10

No files matched your search

@@ -227,9 +227,18 @@ describe('purifyHtml -- Task 24: security properties on newly-allowed elements',
expect(out).not.toMatch(/onload/i);
});
test('style tag stays blocked even nested inside the newly-allowed inline svg', () => {
test('Task 25: style tag nested inside the newly-allowed inline svg now survives, scoped', () => {
// Was "style tag stays blocked" pre-Task-25 -- <style> is now a
// deliberate escape hatch (see HtmlBlock.tsx's ALLOWED_TAGS/Task 25
// comment), including copies nested inside inline SVG:
// querySelectorAll('style') in scopeStyleBlocks() doesn't care about
// namespace/nesting depth, because CSS itself doesn't respect SVG
// subtree boundaries -- an unscoped <style> inside <svg> would still
// apply page-wide, so it needs the same scoping as a top-level one.
const out = purifyHtml('<svg><style>svg{color:red}</style><rect width="1" height="1"></rect></svg>');
expect(out).not.toMatch(/<style/i);
expect(out).toMatch(/<style/i);
expect(out).not.toContain('<style>svg{color:red}</style>'); // rewritten, not verbatim
expect(out).toMatch(/\.whp-html-\w+ svg\{color:red\}/);
expect(out).toContain('<rect');
});
@@ -1,5 +1,7 @@
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';
describe('purifyHtml', () => {
test('strips script tags', () => {
@@ -103,3 +105,192 @@ describe('purifyHtml iframe sandboxing (M-6)', () => {
expect(out).toContain('<p>hi</p>');
});
});
describe('purifyHtml -- Task 25: block-scoped <style> support', () => {
test('a block with no <style> at all is untouched: no wrapper div added', () => {
const out = purifyHtml('<p>hello</p>');
expect(out).toBe('<p>hello</p>');
expect(out).not.toContain('<div');
});
test('blocks WITHOUT <style> are byte-identical to pre-Task-25 output (no wrapper regression)', () => {
// Same representative inputs the Task 24 suite already pins to an exact
// string -- re-asserted here under the Task 25 name so a future change
// that starts wrapping every block (not just style-bearing ones) fails
// loudly and obviously, not just as a Task 24 side-effect.
expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
const table = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
expect(purifyHtml(table)).toBe(table);
expect(purifyHtml('<a href="/x">x</a>')).toBe('<a href="/x">x</a>');
});
test('the full Task 24 fixture (no <style> in it) produces no wrapper and is unaffected', () => {
// The fixture is the broadest real-world stand-in this repo has for
// "a customer's actual pasted block". It contains no <style>, so this
// is the closest thing to a real before/after diff over a large,
// realistic input: the only lever Task 25 pulled (allowing <style> +
// FORCE_BODY) must produce PRECISELY the same output as before for
// content that never touches that lever.
const out = purifyHtml(fixtureHtml);
expect(out).not.toContain('<div class="whp-html-');
expect(out).not.toMatch(/<style[\s>]/i); // still no bare <style> in this fixture
});
test('an empty <style></style> (no CSS content) does not trigger a wrapper', () => {
const out = purifyHtml('<p>hi</p><style></style>');
expect(out).not.toContain('<div class="whp-html-');
});
test('a whitespace-only <style> does not trigger a wrapper', () => {
const out = purifyHtml('<p>hi</p><style> \n </style>');
expect(out).not.toContain('<div class="whp-html-');
});
test('a block WITH real <style> content gets wrapped in a scope-class div', () => {
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
expect(out).toContain('<h1>Hi</h1>');
});
test('the style content is rewritten to only match inside the wrapper (the actual leak-prevention property)', () => {
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
expect(out).toContain(`.${scopeClass} h1 { color: red; }`);
// The bare, unscoped rule must not appear anywhere in the output --
// that's exactly the leak this feature exists to close.
expect(out).not.toContain('<style>h1 { color: red; }</style>');
});
test('scope class is deterministic: the SAME code produces the SAME class across repeated calls', () => {
const code = '<style>p { color: blue; }</style><p>x</p>';
const out1 = purifyHtml(code);
const out2 = purifyHtml(code);
expect(out1).toBe(out2);
const class1 = out1.match(/class="(whp-html-[0-9a-z]+)"/)![1];
const class2 = out2.match(/class="(whp-html-[0-9a-z]+)"/)![1];
expect(class1).toBe(class2);
});
test('pinned scope class for a known input -- guards against silent hash-function drift', () => {
// If this ever needs to change, it means the hash function itself
// changed -- which would silently churn every stored site's HTML on
// next save and desync already-published pages from a fresh Preview.
// That should be a loud, deliberate decision, not a side-effect of an
// unrelated refactor -- hence pinning the literal output here.
const code = '<style>h1{color:red}</style>';
expect(stableHash(code)).toBe('5fwbyn');
const out = purifyHtml(code);
expect(out).toContain('class="whp-html-5fwbyn"');
});
test('scope class is a pure function of `code` -- does not depend on Craft node id or call order', () => {
// purifyHtml's signature only ever takes the code string -- there is no
// node id parameter it could even reach for. This test documents that
// invariant so a future refactor threading a node id through here (as
// html-export.ts's renderNode already does for OTHER components, see
// its `scopeId` comment) doesn't silently get wired into this path too.
const codeA = '<style>h1 { color: red; }</style><h1>same content</h1>';
const codeB = '<style>h1 { color: red; }</style><h1>same content</h1>';
expect(codeA).toBe(codeB); // sanity: truly identical strings
const outA = purifyHtml(codeA);
const outB = purifyHtml(codeB);
expect(outA).toBe(outB);
});
test('FORCE_BODY regression: a block whose ENTIRE code is a leading <style> (nothing before it) still survives', () => {
// Without FORCE_BODY, DOMPurify parses `code` via DOMParser as a mini
// HTML document and serializes only <body>. Per the HTML5 parsing
// algorithm, a <style> tag with nothing before it is implicitly placed
// in the parser's <head>, which DOMPurify's body-only serialization
// never looks at -- the whole block would silently vanish. Confirmed
// empirically against dompurify+jsdom directly before this fix existed.
const out = purifyHtml('<style>h1{color:red}</style>');
expect(out).toContain('<style>');
expect(out).toContain('color:red');
});
test('FORCE_BODY regression: leading <style> immediately followed by markup, both survive', () => {
const out = purifyHtml('<style>h1{color:red}</style><h1>Hi</h1>');
expect(out).toContain('<h1>Hi</h1>');
expect(out).toMatch(/<style>[\s\S]*color:\s*red/);
});
test(':root / html / body inside a block map to the block wrapper itself (end-to-end through purifyHtml)', () => {
const out = purifyHtml('<style>:root { --brand: red; } body { margin: 0; }</style><p>x</p>');
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
expect(out).toContain(`.${scopeClass} { --brand: red; }`);
expect(out).toContain(`.${scopeClass} { margin: 0; }`);
});
test('@import is stripped end-to-end (network-fetch/exfiltration channel)', () => {
const out = purifyHtml('<style>@import url("https://evil.example/x.css"); h1{color:red}</style><h1>x</h1>');
expect(out).not.toContain('@import');
expect(out).not.toContain('evil.example');
expect(out).toContain('color:red');
});
test('@keyframes body is not scoped (animation would otherwise break) -- end-to-end through purifyHtml', () => {
const out = purifyHtml(
'<style>@keyframes spin { from { opacity: 0; } to { opacity: 1; } }</style><h1>x</h1>',
);
expect(out).toContain('@keyframes spin');
expect(out).toMatch(/@keyframes spin\s*\{\s*from\s*\{\s*opacity:\s*0;?\s*\}\s*to\s*\{\s*opacity:\s*1;?\s*\}\s*\}/);
});
test('multiple <style> blocks in one Custom HTML block are each scoped under the SAME class', () => {
const out = purifyHtml('<style>h1{color:red}</style><h1>A</h1><style>p{color:blue}</style><p>B</p>');
const classes = [...out.matchAll(/class="(whp-html-[0-9a-z]+)"/g)].map((m) => m[1]);
expect(classes.length).toBeGreaterThanOrEqual(1);
expect(new Set(classes).size).toBe(1); // same block -> same scope class everywhere
});
});
describe('purifyHtml -- Task 25: security properties of the newly-allowed <style>', () => {
test('</style> inside a CSS comment cannot break out into executable markup', () => {
const out = purifyHtml(
'<style>/* </style><script>alert(1)</script> */ h1{color:red}</style><p>hi</p>',
);
expect(out).not.toContain('<script');
expect(out).not.toMatch(/on[a-z]+\s*=/i);
});
test('</style> inside a CSS string cannot break out into executable markup', () => {
const out = purifyHtml(
'<style>h1::before{content:"</style><script>alert(1)</script>"}</style><p>hi</p>',
);
expect(out).not.toContain('<script');
});
test('script/on*/javascript: are still stripped from markup sitting alongside a styled block', () => {
const out = purifyHtml(
'<style>h1{color:red}</style><p onclick="alert(1)">x</p><script>alert(2)</script><a href="javascript:alert(3)">y</a>',
);
expect(out).not.toMatch(/onclick/i);
expect(out).not.toContain('<script');
expect(out).not.toContain('javascript:');
});
test('iframe sandboxing still applies alongside a styled block', () => {
const out = purifyHtml('<style>h1{color:red}</style><iframe src="https://example.com/"></iframe>');
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
});
test(
'documented reality: DOMPurify does not sanitize CSS declaration values -- ' +
'expression()/behavior/-moz-binding pass through untouched (dead in modern browsers, ' +
'not exploitable there, but not filtered by this pipeline either)',
() => {
const out = purifyHtml(
'<style>div{width:expression(alert(1));behavior:url(evil.htc);-moz-binding:url(evil.xml#x)}</style><div>x</div>',
);
expect(out).toContain('expression(alert(1))');
expect(out).toContain('behavior:url(evil.htc)');
expect(out).toContain('-moz-binding:url(evil.xml#x)');
},
);
test('documented reality: url() to a remote host survives (legitimate for background-image, but a known CSS-exfiltration channel already accepted elsewhere in this config)', () => {
const out = purifyHtml('<style>div{background:url(https://tracker.example/pixel.png)}</style><div>x</div>');
expect(out).toContain('tracker.example');
});
});
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'vitest';
import { HtmlBlock } from './HtmlBlock';
import { HtmlBlock, purifyHtml } from './HtmlBlock';
const toHtml = (HtmlBlock as any).toHtml;
@@ -46,3 +46,24 @@ describe('HtmlBlock.toHtml markup path (C1 review finding)', () => {
expect(html).toBe(code);
});
});
describe('HtmlBlock.toHtml -- Task 25: block-scoped <style>, and editor/export byte-parity', () => {
test('a <style>-bearing block exports the same scoped wrapper purifyHtml() would produce in the editor canvas', () => {
// The editor canvas (HtmlBlock component) and toHtml() (Preview +
// Published export) both call the exact same purifyHtml(code) -- this
// is the byte-parity invariant this project treats as a hard
// requirement. Proven here by calling purifyHtml directly (as the
// canvas's useMemo does) and toHtml (as export does) on the identical
// code string and asserting the two never diverge.
const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
const { html } = toHtml({ code }, '');
expect(html).toBe(purifyHtml(code));
});
test('a <style>-free block still exports byte-identical to pre-Task-25 output (no wrapper regression) via toHtml', () => {
const code = '<p>hello</p>';
const { html } = toHtml({ code }, '');
expect(html).toBe('<p>hello</p>');
expect(html).not.toContain('<div');
});
});
+84 -7
View File
@@ -1,6 +1,8 @@
import React, { CSSProperties, useMemo } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import DOMPurify from 'dompurify';
import { stableHash } from '../../utils/escape';
import { scopeCss } from '../../utils/scope-css';
interface HtmlBlockProps {
code: string;
@@ -70,6 +72,13 @@ const PURIFY_CONFIG = {
'line','polyline','polygon','path','text','tspan',
'lineargradient','radialgradient','stop','clippath','mask','marker',
'pattern','switch','view',
// Task 25: block-scoped <style> support. Formerly in FORBID_TAGS
// (stripped entirely). Now allowed through sanitisation -- its CSS is
// rewritten by scopeStyleBlocks()/scopeCss() below, immediately after
// 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.
'style',
],
// NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute
// allowlist rather than extending it, so anything the product needs
@@ -120,13 +129,31 @@ const PURIFY_CONFIG = {
// be dangerous -- is correctly not in that DOMPurify list).
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|data:image\/[a-z]+;base64,|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
// form/input/button/select/textarea removed from FORBID_TAGS (Task 24) --
// they are now deliberately allowed above. style/script/object/embed/
// link/meta stay forbidden; <style> in particular stays blocked even
// inside the newly-allowed inline <svg> (a separate task is adding
// scoped <style> support later -- see HtmlBlock.security.test.ts for the
// svg><style> regression check).
FORBID_TAGS: ['script','style','object','embed','link','meta'],
// they are now deliberately allowed above. script/object/embed/link/meta
// stay forbidden. <style> (Task 25) is now allowed too -- see ALLOWED_TAGS
// comment above and scopeStyleBlocks() below; it survives sanitisation
// here but its CSS gets scoped afterwards, including copies nested inside
// the newly-allowed inline <svg> (querySelectorAll('style') in
// scopeStyleBlocks() doesn't care about namespace/nesting depth).
FORBID_TAGS: ['script','object','embed','link','meta'],
FORBID_ATTR: [/^on/i],
// Task 25: without this, DOMPurify parses `input` as a full (mini) HTML
// document via DOMParser and only serializes <body>'s contents. Per the
// HTML5 parsing algorithm, a tag that can only legally appear in <head>
// -- 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
// 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
// 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
// <body> where DOMPurify's body-only serialization actually looks.
// Confirmed empirically against dompurify+jsdom directly (not just this
// app's behavior) -- see the "leading <style> with nothing before it"
// test in HtmlBlock.test.ts.
FORCE_BODY: true,
};
// M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case)
@@ -146,6 +173,55 @@ const IFRAME_SANDBOX_HOOK = (node: Element): void => {
}
};
/**
* Task 25: rewrite any surviving `<style>` element(s) in `sanitized` (the
* DOMPurify output) so their CSS only matches inside this block's own
* wrapper element, then wrap the whole thing in that wrapper.
*
* Deliberately does the LEAST work possible when there's nothing to scope:
* a cheap substring check bails out before touching the DOM at all, so a
* block that doesn't use <style> -- i.e. every block saved before this task
* -- gets `sanitized` back completely unchanged (same string, no wrapper,
* no re-serialization round-trip that could subtly reformat attributes).
* That byte-for-byte identity is a hard requirement: published pages
* already contain `toHtml()` output with NO wrapper element, and adding one
* unconditionally would silently change the DOM/box-model of every
* existing customer block. See HtmlBlock.test.ts's
* "blocks without <style> are byte-identical" tests, which run this
* against real fixture content and diff the exact string.
*
* Scope identifier: `whp-html-${stableHash(rawCode)}` -- `stableHash` is
* the existing djb2 hash from utils/escape.ts (already used for this exact
* class of problem, see `scopeId` in that file), applied to `rawCode` --
* the block's own `code` prop, nothing else. Pure function of the block's
* own content: no Math.random, no Date.now, no counter, and deliberately
* NOT the Craft node id (unlike `scopeId`), because a scope identifier that
* depends on anything outside `code` would make the editor canvas preview
* (which calls purifyHtml(code) on render) and the published output (which
* calls the same purifyHtml(code) at publish time) diverge whenever that
* outside thing differs between the two call sites, and would make the
* stored HTML churn on every save even when the block's own content didn't
* change. Hashing `code` guarantees purifyHtml(code) is fully deterministic
* on its own -- same code in, byte-identical output out, every time, in
* both places it's called.
*/
function scopeStyleBlocks(sanitized: string, rawCode: string): string {
if (!sanitized.includes('<style')) return sanitized;
const container = document.createElement('div');
container.innerHTML = sanitized;
const styleEls = Array.from(container.querySelectorAll('style'));
const nonEmpty = styleEls.filter((el) => (el.textContent || '').trim() !== '');
if (nonEmpty.length === 0) return sanitized;
const scopeClass = `whp-html-${stableHash(rawCode)}`;
for (const el of nonEmpty) {
el.textContent = scopeCss(el.textContent || '', `.${scopeClass}`);
}
return `<div class="${scopeClass}">${container.innerHTML}</div>`;
}
export function purifyHtml(input: string): string {
// Hook is added immediately before sanitize() and removed immediately
// after, scoped tightly to this single call -- so it can never leak onto
@@ -154,7 +230,8 @@ export function purifyHtml(input: string): string {
// multiple copies of the same hook.
DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK);
try {
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
const sanitized = DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
return scopeStyleBlocks(sanitized, input || '');
} finally {
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
}