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>
This commit is contained in:
@@ -0,0 +1,260 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,8 +17,12 @@ describe('purifyHtml', () => {
|
|||||||
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
|
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
|
||||||
expect(out).toContain('youtube.com/embed/abc');
|
expect(out).toContain('youtube.com/embed/abc');
|
||||||
});
|
});
|
||||||
test('strips form/input', () => {
|
test('allows form/input (Task 24: forms are a deliberate escape-hatch addition) but still strips on*/script inside them', () => {
|
||||||
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form');
|
const out = purifyHtml('<form><input name="x" onfocus="bad()"><script>alert(1)</script></form>');
|
||||||
|
expect(out).toContain('<form');
|
||||||
|
expect(out).toContain('<input name="x">');
|
||||||
|
expect(out).not.toContain('onfocus');
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,21 @@ interface HtmlBlockProps {
|
|||||||
node_id?: string;
|
node_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task 24: widening the allow-list after a customer's broad HTML fixture
|
||||||
|
// showed 38% of it silently deleted (tables losing colspan/rowspan/scope,
|
||||||
|
// <dl>/<sub>/<details>/inline <svg>/<video>/<audio> dropped wholesale,
|
||||||
|
// lang/dir/role stripped, <ol start/reversed> flattened). The owner's call:
|
||||||
|
// be generous -- this block is an explicit escape hatch and customers
|
||||||
|
// reasonably expect it to render ordinary HTML, including forms. The four
|
||||||
|
// non-negotiables (no <script>, no on*, no javascript: URLs, iframes stay
|
||||||
|
// sandboxed) are unaffected by the widening and are covered by dedicated
|
||||||
|
// tests in HtmlBlock.test.ts / HtmlBlock.security.test.ts.
|
||||||
const PURIFY_CONFIG = {
|
const PURIFY_CONFIG = {
|
||||||
ALLOWED_TAGS: [
|
ALLOWED_TAGS: [
|
||||||
'a','p','br','hr','div','span','section','article',
|
'a','p','br','hr','div','span','section','article',
|
||||||
'header','footer','main','aside','nav',
|
'header','footer','main','aside','nav',
|
||||||
'ul','ol','li',
|
'ul','ol','li',
|
||||||
'h1','h2','h3','h4','h5','h6',
|
'h1','h2','h3','h4','h5','h6','hgroup',
|
||||||
'em','strong','b','i','u','s',
|
'em','strong','b','i','u','s',
|
||||||
'blockquote','code','pre',
|
'blockquote','code','pre',
|
||||||
'img','figure','figcaption',
|
'img','figure','figcaption',
|
||||||
@@ -22,6 +31,38 @@ const PURIFY_CONFIG = {
|
|||||||
// Tables: pasted content commonly includes these; dropping them
|
// Tables: pasted content commonly includes these; dropping them
|
||||||
// silently ate customer-pasted tables (see C1 review finding).
|
// silently ate customer-pasted tables (see C1 review finding).
|
||||||
'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col',
|
'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col',
|
||||||
|
// Text semantics (Task 24).
|
||||||
|
'sub','sup','small','mark','del','ins','abbr','cite','q','time','data',
|
||||||
|
'kbd','samp','var','dfn','address','bdi','bdo','ruby','rt','rp','wbr',
|
||||||
|
// Lists (Task 24).
|
||||||
|
'dl','dt','dd','menu',
|
||||||
|
// Disclosure widget (Task 24). Note: <dialog> and <template> are
|
||||||
|
// deliberately NOT added -- the fixture exercises them wrapped in
|
||||||
|
// on*= handlers specifically to prove they still get neutralized/
|
||||||
|
// dropped by staying outside the allow-list.
|
||||||
|
'details','summary',
|
||||||
|
// Media (Task 24). All URL-bearing attributes on these (src, poster,
|
||||||
|
// srcset...) go through the same ALLOWED_URI_REGEXP gate as everything
|
||||||
|
// else -- see _isValidAttribute in dompurify, which URI-checks every
|
||||||
|
// allowed attribute value except a small fixed "inert" list (alt,
|
||||||
|
// class, id, style, title, ...) that never includes src/poster/srcset.
|
||||||
|
'picture','source','video','audio','track','canvas',
|
||||||
|
// Forms (Task 24). Site owner's explicit decision: allow the full
|
||||||
|
// ordinary form surface. No on*= survives (FORBID_ATTR below), and
|
||||||
|
// action/formaction-style URLs are gated by ALLOWED_URI_REGEXP the
|
||||||
|
// same as href/src, so `javascript:` still cannot survive here either.
|
||||||
|
'form','input','button','select','option','optgroup','textarea',
|
||||||
|
'label','fieldset','legend','datalist','output','progress','meter',
|
||||||
|
// Inline SVG (Task 24) -- see the block comment on IFRAME_SANDBOX_HOOK's
|
||||||
|
// neighbor below for why this is an explicit tag list rather than
|
||||||
|
// DOMPurify's USE_PROFILES svg profile. Deliberately excludes <use> and
|
||||||
|
// <image> (both need xlink:href, an external-reference vector DOMPurify
|
||||||
|
// itself excludes from its own SVG defaults) and <a>/<foreignObject>
|
||||||
|
// (not needed by the fixture; foreignObject can embed arbitrary HTML).
|
||||||
|
'svg','g','defs','symbol','title','desc','rect','circle','ellipse',
|
||||||
|
'line','polyline','polygon','path','text','tspan',
|
||||||
|
'lineargradient','radialgradient','stop','clippath','mask','marker',
|
||||||
|
'pattern','switch','view',
|
||||||
],
|
],
|
||||||
// NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute
|
// NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute
|
||||||
// allowlist rather than extending it, so anything the product needs
|
// allowlist rather than extending it, so anything the product needs
|
||||||
@@ -32,9 +73,39 @@ const PURIFY_CONFIG = {
|
|||||||
'width','height','class','id','style',
|
'width','height','class','id','style',
|
||||||
'allowfullscreen','allow','frameborder',
|
'allowfullscreen','allow','frameborder',
|
||||||
'sandbox','referrerpolicy',
|
'sandbox','referrerpolicy',
|
||||||
|
// Task 24 additions.
|
||||||
|
'colspan','rowspan','scope','headers','span','start','reversed',
|
||||||
|
'type','value','name','placeholder','required','disabled','readonly',
|
||||||
|
'checked','selected','multiple','min','max','step','minlength',
|
||||||
|
'maxlength','pattern','rows','cols','accept','action','method','for',
|
||||||
|
'list','label','datetime','cite','lang','dir','role','srcset','media',
|
||||||
|
'sizes','loading','controls','poster','loop','muted','autoplay',
|
||||||
|
'preload','playsinline','kind','srclang','default','open','download',
|
||||||
|
'hidden','contenteditable',
|
||||||
|
// SVG presentation attributes (explicit route -- see ALLOWED_TAGS
|
||||||
|
// comment on the SVG tag list). Covers the fixture's <svg viewBox
|
||||||
|
// role>/<rect>/<circle>/<text> block plus the common presentation
|
||||||
|
// attributes for the shapes/gradients allowed above. Deliberately
|
||||||
|
// excludes xlink:href (no <use>/<image> allowed, so it has nothing
|
||||||
|
// legitimate to attach to) and the SMIL/animation attributes (begin,
|
||||||
|
// dur, repeatCount, ...) which DOMPurify's own SVG defaults exclude
|
||||||
|
// for the same reason on* handlers are excluded.
|
||||||
|
'viewbox','cx','cy','r','rx','ry','x','y','x1','y1','x2','y2',
|
||||||
|
'points','d','fill','stroke','stroke-width','stroke-linecap',
|
||||||
|
'stroke-linejoin','stroke-dasharray','fill-rule','clip-rule','opacity',
|
||||||
|
'fill-opacity','stroke-opacity','text-anchor','dominant-baseline',
|
||||||
|
'font-family','font-size','font-weight','transform','offset',
|
||||||
|
'stop-color','stop-opacity','gradientunits','gradienttransform',
|
||||||
|
'preserveaspectratio',
|
||||||
],
|
],
|
||||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||||
FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'],
|
// 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'],
|
||||||
FORBID_ATTR: [/^on/i],
|
FORBID_ATTR: [/^on/i],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
<!-- ============================================================
|
||||||
|
HTML test fixture — everything below goes inside <body>
|
||||||
|
Unstyled on purpose. No external assets (SVG/data URIs only)
|
||||||
|
except the media/iframe block, which is intentionally broken
|
||||||
|
so you can see fallback behavior.
|
||||||
|
============================================================ -->
|
||||||
|
|
||||||
|
<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">
|
||||||
|
|
||||||
|
<!-- ========== HEADINGS ========== -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- ========== TEXT & INLINE ========== -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- ========== LISTS ========== -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- ========== TABLES ========== -->
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== FORMS ========== -->
|
||||||
|
<section id="forms">
|
||||||
|
<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 size="4">
|
||||||
|
<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" low="30" high="80" optimum="90" 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>
|
||||||
|
|
||||||
|
<!-- ========== MEDIA & EMBEDS ========== -->
|
||||||
|
<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" src="about:blank" width="300" height="120" sandbox loading="lazy"></iframe>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== INTERACTIVE / SEMANTIC ========== -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<dialog id="test-dialog">
|
||||||
|
<p>Non-modal dialog content.</p>
|
||||||
|
<button type="button" onclick="this.closest('dialog').close()">Close</button>
|
||||||
|
</dialog>
|
||||||
|
<button type="button" onclick="document.getElementById('test-dialog').show()">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>
|
||||||
|
|
||||||
|
<template id="tpl">
|
||||||
|
<p>Template content — must not render until cloned.</p>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== EDGE CASES ========== -->
|
||||||
|
<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: a​b​c</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>
|
||||||
Reference in New Issue
Block a user