Files
site-builder/craft/src/components/basic/HtmlBlock.security.test.ts
T
shadowdaoandClaude Opus 5 156c5bae35 fix(site-builder): widen Custom HTML block sanitiser allow-list
A customer's broad HTML fixture showed 38% of it silently deleted by
the shipped DOMPurify config: colspan/rowspan/scope, <dl>, <sub>/<sup>,
<details>/<summary>, inline <svg>, <video>/<audio>, lang/dir/role, and
<ol start/reversed> were all stripped. The site owner's call: be
generous, this block is an explicit escape hatch, allow forms too.

Widens PURIFY_CONFIG in HtmlBlock.tsx (45->119 tags, 16->108 attrs;
form/input/button/select/textarea removed from FORBID_TAGS) while
keeping the four non-negotiables intact: no <script>, no on*, no
javascript: URLs, iframes stay sandboxed. <style> stays blocked
(separate task adds scoped support later), including inside the newly
allowed inline SVG. SVG support is an explicit tag list mirroring
DOMPurify's own SVG vocabulary rather than USE_PROFILES, which turned
out to silently discard ALLOWED_ATTR entirely and pull in unaudited
tags (dialog, template, marquee, ...) not in scope here.

Fixture survival goes from 61.6% (9,739/15,815 bytes) to 94.2%
(14,899/15,815 bytes). Adds a fixture-driven regression + security
test file (HtmlBlock.security.test.ts) plus a checked-in copy of the
reference fixture, loaded via Vite's ?raw import so tests need no new
dependencies and can't silently drift from the thing being tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:36:01 -07:00

261 lines
12 KiB
TypeScript

import { describe, test, expect } from 'vitest';
import { purifyHtml } from './HtmlBlock';
// Vite/Vitest `?raw` import -- ships the exact bytes of the file as a
// string, declared by node_modules/vite/client.d.ts. This is a checked-in
// copy of the reference acceptance fixture used for Task 24 (widening the
// Custom HTML block's sanitiser allow-list); keep it byte-identical to the
// external fixture used to drive this task so these tests cannot silently
// drift from the thing they are supposed to be testing against.
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
/**
* Task 24: the site owner tested a broad HTML fixture against the shipped
* sanitiser config and found 38% of it silently deleted -- merged table
* cells collapsing (colspan/rowspan/scope stripped), <dl>/<sub>/<details>/
* inline <svg>/<video>/<audio> dropped wholesale, lang/dir/role stripped
* (breaking RTL rendering), <ol start/reversed> flattened. The fix widens
* ALLOWED_TAGS/ALLOWED_ATTR in HtmlBlock.tsx. These tests run the *actual*
* reference fixture through the *actual* purifyHtml() and assert the
* previously-broken constructs now survive with their meaningful
* attributes intact, while re-confirming (with attack payloads spliced
* into the newly-widened surface -- forms, media, inline svg) that the
* four non-negotiable security properties still hold.
*/
describe('purifyHtml -- Task 24 fixture regression (formerly-dropped constructs survive)', () => {
const out = purifyHtml(fixtureHtml);
test('table merged cells keep colspan/rowspan/scope', () => {
expect(out).toContain('<td colspan="2">');
expect(out).toContain('<td rowspan="2">');
expect(out).toContain('<th scope="col">');
expect(out).toContain('<th scope="row">');
});
test('definition list keeps its dl/dt/dd structure (was flattened to "TermDef")', () => {
expect(out).toMatch(/<dl>[\s\S]*<dt>Term one<\/dt>[\s\S]*<dd>Definition of the first term\.<\/dd>[\s\S]*<\/dl>/);
});
test('menu list survives with nested buttons', () => {
expect(out).toMatch(/<menu>[\s\S]*<button type="button">Copy<\/button>[\s\S]*<\/menu>/);
});
test('sub/sup survive (was flattened to "H2O")', () => {
expect(out).toContain('H<sub>2</sub>O');
expect(out).toContain('x<sup>2</sup>');
});
test('details/summary survive with the open attribute (was flattened)', () => {
expect(out).toContain('<summary>Collapsed disclosure</summary>');
expect(out).toContain('<details open="">');
});
test('hgroup survives', () => {
expect(out).toMatch(/<hgroup>[\s\S]*<h2>Grouped heading<\/h2>/);
});
test('inline svg survives with its shape children and role/aria-label (was deleted entirely)', () => {
expect(out).toMatch(/<svg[^>]*role="img"[^>]*aria-label="Two shapes"[^>]*>/);
expect(out).toMatch(/<rect[^>]*fill="none"[^>]*stroke="currentColor"[^>]*>/);
expect(out).toMatch(/<circle[^>]*cx="135"[^>]*cy="45"[^>]*r="40"[^>]*>/);
expect(out).toMatch(/<text[^>]*text-anchor="middle"[^>]*>svg<\/text>/);
});
test('picture/source with media+srcset survive', () => {
expect(out).toContain('<source media="(min-width: 800px)" srcset="wide.png">');
expect(out).toContain('<source media="(min-width: 400px)" srcset="medium.png">');
});
test('video/audio survive with source/track children (was deleted entirely)', () => {
expect(out).toMatch(/<video[^>]*controls=""[^>]*poster="poster\.jpg"[^>]*>/);
expect(out).toContain('<source src="clip.webm" type="video/webm">');
expect(out).toContain('<track kind="captions" src="captions.vtt" srclang="en" label="English">');
expect(out).toMatch(/<audio[^>]*controls=""[^>]*>/);
});
test('canvas survives with its fallback text', () => {
expect(out).toContain('<canvas width="200" height="60">Canvas fallback text</canvas>');
});
test('mark/small/del/ins survive as distinct elements (was flattened to "msdi")', () => {
expect(out).toContain('<mark>mark</mark>');
expect(out).toContain('<small>small</small>');
expect(out).toContain('<del>del</del>');
expect(out).toContain('<ins>ins</ins>');
});
test('lang/dir preserved for RTL text (was stripped, breaking Arabic/Hebrew rendering)', () => {
expect(out).toContain('lang="ar" dir="rtl"');
expect(out).toContain('lang="he" dir="rtl"');
});
test('role attribute preserved alongside aria-* (role was stripped)', () => {
expect(out).toMatch(/<nav aria-label="Primary">/);
expect(out).toMatch(/role="img"/);
});
test('ol start/reversed preserved (was flattened to plain <ol>)', () => {
expect(out).toContain('<ol start="5" reversed="">');
});
test('text semantics survive: abbr/cite/q/time/data/kbd/samp/var/dfn/address/bdi/bdo/ruby', () => {
expect(out).toContain('<abbr title="HyperText Markup Language">HTML</abbr>');
expect(out).toContain('<kbd>Ctrl</kbd>');
expect(out).toContain('<samp>output text</samp>');
expect(out).toContain('<var>variable</var>');
expect(out).toContain('<dfn>definition term</dfn>');
expect(out).toContain('<address>');
expect(out).toContain('<bdi>');
expect(out).toContain('<bdo dir="rtl">');
expect(out).toContain('<ruby>');
expect(out).toContain('<rt>kan</rt>');
expect(out).toContain('<time datetime="2026-08-09">');
expect(out).toContain('<data value="42">');
});
test('wbr survives (word-break opportunity)', () => {
expect(out).toContain('super<wbr>cali<wbr>fragilistic');
});
test('hidden attribute survives', () => {
expect(out).toContain('<p hidden="">');
});
test('forms survive end-to-end: fieldset/legend/label/select/optgroup/option/textarea/datalist/output/progress/meter', () => {
expect(out).toContain('<form action="#" method="get">');
expect(out).toContain('<fieldset>');
expect(out).toContain('<legend>Text inputs</legend>');
expect(out).toContain('<label for="f-text">Text</label>');
expect(out).toContain('<input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled">');
expect(out).toContain('<input id="f-email" type="email" required="">');
expect(out).toContain('<input id="f-num" type="number" min="0" max="100" step="5" value="25">');
expect(out).toContain('<input id="f-ro" type="text" value="read only" readonly="">');
expect(out).toContain('<input id="f-dis" type="text" value="disabled" disabled="">');
expect(out).toContain('<input type="checkbox" name="c" value="1" checked="">');
expect(out).toContain('<select id="f-select" name="select">');
expect(out).toContain('<optgroup label="Group one">');
expect(out).toContain('<option value="1" selected="">One</option>');
expect(out).toContain('<select id="f-multi" multiple="">');
expect(out).toContain('<datalist id="suggestions">');
expect(out).toContain('<textarea id="f-area" rows="4" cols="40">');
expect(out).toContain('<output name="result" for="f-num f-range">');
expect(out).toContain('<progress id="f-prog" value="0.6">');
expect(out).toContain('<meter id="f-meter" min="0" max="100" value="72">');
expect(out).toContain('<button type="submit">Submit</button>');
});
test('fixture byte survival crosses 90% (was 61.6% -- 9739/15815 -- before Task 24)', () => {
expect(out.length).toBeGreaterThan(fixtureHtml.length * 0.9);
});
});
describe('purifyHtml -- Task 24: things in the fixture that must still be dropped', () => {
const out = purifyHtml(fixtureHtml);
test('style tag never survives', () => {
expect(out).not.toMatch(/<style[\s>]/i);
});
test('script tag never survives', () => {
expect(out).not.toMatch(/<script[\s>]/i);
});
test('dialog/template stay excluded (not in the widened allow-list)', () => {
expect(out).not.toContain('<dialog');
expect(out).not.toContain('<template');
});
test('no on* handler survives anywhere in the widened output, including inside the dialog fallback content', () => {
expect(out).not.toMatch(/\son[a-z]+\s*=/i);
// The fixture's dialog/close buttons carry onclick specifically to
// prove this; their text content should still come through once the
// handler is stripped and (for dialog) the wrapping tag is dropped.
expect(out).toContain('Open dialog');
});
});
describe('purifyHtml -- Task 24: security properties on newly-allowed elements', () => {
test('script inside a newly-allowed <form> still never survives', () => {
const out = purifyHtml('<form><script>alert(1)</script></form>');
expect(out).not.toContain('<script');
});
test('on* handlers never survive on newly-allowed form controls', () => {
const out = purifyHtml('<input onfocus="alert(1)" value="x">');
expect(out).not.toMatch(/onfocus/i);
const out2 = purifyHtml('<select onchange="alert(1)"><option>x</option></select>');
expect(out2).not.toMatch(/onchange/i);
});
test('javascript: blocked in <form action>', () => {
const out = purifyHtml('<form action="javascript:alert(1)"><button type="submit">go</button></form>');
expect(out).not.toContain('javascript:');
});
test('formaction is not in the allow-list at all -- dropped regardless of value', () => {
const out = purifyHtml('<button formaction="javascript:alert(1)">go</button>');
expect(out).not.toContain('formaction');
expect(out).not.toContain('javascript:');
});
test('javascript: blocked on svg <a xlink:href> (xlink:href is not allow-listed at all)', () => {
const out = purifyHtml('<svg><a xlink:href="javascript:alert(1)">click</a></svg>');
expect(out).not.toContain('javascript:');
expect(out).not.toContain('xlink:href');
});
test('javascript: blocked in newly-allowed media URL attributes (poster, source src)', () => {
const out = purifyHtml('<video poster="javascript:alert(1)"><source src="javascript:alert(2)"></video>');
expect(out).not.toContain('javascript:');
});
test('javascript: still blocked in plain href alongside the widened surface', () => {
const out = purifyHtml('<a href="javascript:alert(1)"><svg><text>x</text></svg></a>');
expect(out).not.toContain('javascript:');
});
test('iframe still gets the forced restrictive sandbox + referrerpolicy alongside the widened surface', () => {
const out = purifyHtml('<form><input></form><iframe src="https://example.com/"></iframe>');
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
const sandbox = out.match(/sandbox="([^"]*)"/)![1];
expect(sandbox).not.toMatch(/allow-top-navigation/);
expect(out).toContain('referrerpolicy="no-referrer"');
});
test('on* on an iframe is still stripped even though iframe now sits among many more allowed siblings', () => {
const out = purifyHtml('<iframe src="https://example.com/" onload="alert(1)"></iframe>');
expect(out).not.toMatch(/onload/i);
});
test('style tag stays blocked even nested inside the newly-allowed inline svg', () => {
const out = purifyHtml('<svg><style>svg{color:red}</style><rect width="1" height="1"></rect></svg>');
expect(out).not.toMatch(/<style/i);
expect(out).toContain('<rect');
});
test('contenteditable does not smuggle an event handler in alongside it', () => {
const out = purifyHtml('<div contenteditable="true" onblur="alert(1)">x</div>');
expect(out).not.toMatch(/onblur/i);
expect(out).toContain('contenteditable="true"');
});
test('dialog stays excluded even with an attack payload; its inert children still render', () => {
const out = purifyHtml('<dialog onclick="alert(1)"><p>hi</p></dialog>');
expect(out).not.toContain('<dialog');
expect(out).not.toMatch(/onclick/i);
expect(out).toContain('<p>hi</p>');
});
test('javascript: blocked via data: smuggling on newly-allowed poster/cite/action attributes', () => {
// data: is only allow-listed for data:image/*;base64, -- confirm the
// regex is not accidentally satisfied by a text/html or bare data:
// payload on any of the newly URI-checked attributes.
const out = purifyHtml(
'<video poster="data:text/html,<script>alert(1)</script>"></video>' +
'<blockquote cite="data:text/html,x">q</blockquote>' +
'<form action="data:text/html,x"></form>',
);
expect(out).not.toContain('data:text/html');
});
});