<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>
70 lines
3.1 KiB
TypeScript
70 lines
3.1 KiB
TypeScript
import { describe, test, expect } from 'vitest';
|
|
import { HtmlBlock, purifyHtml } from './HtmlBlock';
|
|
|
|
const toHtml = (HtmlBlock as any).toHtml;
|
|
|
|
describe('HtmlBlock.toHtml sanitizes raw code (A4.1)', () => {
|
|
test('strips <script> and on-handlers from exported output', () => {
|
|
const { html } = toHtml({ code: '<script>alert(1)</script><p onclick="x">hi</p>' }, '');
|
|
expect(html).not.toContain('<script');
|
|
expect(html).not.toContain('onclick');
|
|
expect(html).toContain('<p>hi</p>');
|
|
});
|
|
|
|
test('does not wrap output in an unsanitized element carrying the style prop raw', () => {
|
|
// toHtml only ever returns the sanitized `code` blob -- there is no
|
|
// wrapper <div style="..."> in the exported HTML, so a malicious
|
|
// `style` prop (e.g. an attacker-controlled object with a breakout
|
|
// toString()) has nothing to splice into.
|
|
const malicious = { toString: () => 'color:red" onmouseover="alert(1)' } as any;
|
|
const { html } = toHtml({ code: '<p>hi</p>', style: malicious }, '');
|
|
expect(html).not.toMatch(/onmouseover/);
|
|
expect(html).not.toMatch(/<div/);
|
|
expect(html).toBe('<p>hi</p>');
|
|
});
|
|
});
|
|
|
|
test('toHtml never emits the style prop (the other half of the render/export contract)', () => {
|
|
const out = (HtmlBlock as any).toHtml(
|
|
{ code: '<p>hi</p>', style: { backgroundColor: '#ff0000', padding: '40px' } },
|
|
'',
|
|
);
|
|
expect(out.html).toBe('<p>hi</p>');
|
|
expect(out.html).not.toContain('background');
|
|
expect(out.html).not.toContain('40px');
|
|
});
|
|
|
|
describe('HtmlBlock.toHtml markup path (C1 review finding)', () => {
|
|
test('a style attribute inside `code` (e.g. from the toolbar colour picker) reaches exported output', () => {
|
|
const { html } = toHtml({ code: '<p style="color: #ff0000">red text</p>' }, '');
|
|
expect(html).toBe('<p style="color: #ff0000">red text</p>');
|
|
});
|
|
|
|
test('a table inside `code` reaches exported output', () => {
|
|
const code = '<table><tbody><tr><td>Cell</td></tr></tbody></table>';
|
|
const { html } = toHtml({ code }, '');
|
|
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');
|
|
});
|
|
});
|