Compare commits
10
Commits
69e61ab4b2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab01021be | ||
|
|
a684d51996 | ||
|
|
422697acec | ||
|
|
d1c57db967 | ||
|
|
071f3447fd | ||
|
|
98f2ebf118 | ||
|
|
916a568e9f | ||
|
|
32f4092156 | ||
|
|
6a9b227dda | ||
|
|
156c5bae35 |
@@ -0,0 +1 @@
|
||||
cloud-hosting-platform/site-builder
|
||||
@@ -0,0 +1,332 @@
|
||||
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="" size="4">');
|
||||
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" low="30" high="80" optimum="90" value="72">');
|
||||
expect(out).toContain('<button type="submit">Submit</button>');
|
||||
});
|
||||
|
||||
test('bug fix: <select size> and <meter low/high/optimum> survive (both tags were already allowed, only these four attrs were missing)', () => {
|
||||
expect(out).toContain('<select id="f-multi" multiple="" size="4">');
|
||||
expect(out).toContain('low="30" high="80" optimum="90"');
|
||||
});
|
||||
|
||||
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('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).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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
test('review fix: data:image/*;base64, URIs now actually survive on poster/cite/href (dead-code regex bug)', () => {
|
||||
// ALLOWED_URI_REGEXP used to put the data:image arm inside the group
|
||||
// that gets a trailing `:` appended to every alternative, requiring a
|
||||
// second colon after the one already in "base64," -- which no real
|
||||
// data URI has, so the clause could never match anything. Confirm the
|
||||
// fixed regex actually allows a real base64 image data URI through on
|
||||
// ordinary URI-checked attributes (not just the DATA_URI_TAGS-covered
|
||||
// src ones tested below).
|
||||
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||
const out = purifyHtml(
|
||||
`<video poster="data:image/png;base64,${b64}"></video>` +
|
||||
`<blockquote cite="data:image/png;base64,${b64}">q</blockquote>` +
|
||||
`<a href="data:image/png;base64,${b64}">img</a>`,
|
||||
);
|
||||
expect(out).toContain(`poster="data:image/png;base64,${b64}"`);
|
||||
expect(out).toContain(`cite="data:image/png;base64,${b64}"`);
|
||||
expect(out).toContain(`href="data:image/png;base64,${b64}"`);
|
||||
});
|
||||
|
||||
test('documented reality: data: on img/video/audio/source src is mimetype-blind (DOMPurify DATA_URI_TAGS bypasses ALLOWED_URI_REGEXP)', () => {
|
||||
// This is NOT gated by ALLOWED_URI_REGEXP at all -- DOMPurify has its
|
||||
// own internal DATA_URI_TAGS allow-list (img, video, audio, source,
|
||||
// image, track) that accepts ANY data: URI on the `src` attribute of
|
||||
// those tags regardless of declared mimetype, before our regex is ever
|
||||
// consulted. Acceptable because none of those tags execute their src
|
||||
// as a document/script context in mainstream browsers -- the sink
|
||||
// doesn't execute. Pinned here so a future DOMPurify version change to
|
||||
// DATA_URI_TAGS shows up as a failing test, not a surprise in
|
||||
// production. <iframe> -- the one tag where this WOULD be dangerous --
|
||||
// is correctly not in DOMPurify's DATA_URI_TAGS list, so its src still
|
||||
// goes through the normal ALLOWED_URI_REGEXP check and gets stripped.
|
||||
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||
const imgOut = purifyHtml(`<img src="data:text/html;base64,${b64}">`);
|
||||
expect(imgOut).toContain(`src="data:text/html;base64,${b64}"`);
|
||||
|
||||
const iframeOut = purifyHtml(`<iframe src="data:text/html;base64,${b64}"></iframe>`);
|
||||
expect(iframeOut).not.toContain('data:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml -- bug fix: size/low/high/optimum were stripped despite select/meter being allowed tags', () => {
|
||||
test('<select size> survives with its value intact (multi-select row count)', () => {
|
||||
const out = purifyHtml('<select size="4"><option>a</option></select>');
|
||||
expect(out).toContain('size="4"');
|
||||
});
|
||||
|
||||
test('<input size> survives with its value intact', () => {
|
||||
const out = purifyHtml('<input type="text" size="10">');
|
||||
expect(out).toContain('size="10"');
|
||||
});
|
||||
|
||||
test('<meter low/high/optimum> survive with their values intact (threshold-based gauge colouring)', () => {
|
||||
const out = purifyHtml('<meter low="1" high="9" optimum="5" value="4" min="0" max="10"></meter>');
|
||||
expect(out).toContain('low="1"');
|
||||
expect(out).toContain('high="9"');
|
||||
expect(out).toContain('optimum="5"');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,17 @@
|
||||
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';
|
||||
// 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', () => {
|
||||
test('strips script tags', () => {
|
||||
@@ -17,8 +29,12 @@ describe('purifyHtml', () => {
|
||||
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
|
||||
expect(out).toContain('youtube.com/embed/abc');
|
||||
});
|
||||
test('strips form/input', () => {
|
||||
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form');
|
||||
test('allows form/input (Task 24: forms are a deliberate escape-hatch addition) but still strips on*/script inside them', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,3 +115,286 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -9,12 +11,21 @@ interface HtmlBlockProps {
|
||||
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 = {
|
||||
ALLOWED_TAGS: [
|
||||
'a','p','br','hr','div','span','section','article',
|
||||
'header','footer','main','aside','nav',
|
||||
'ul','ol','li',
|
||||
'h1','h2','h3','h4','h5','h6',
|
||||
'h1','h2','h3','h4','h5','h6','hgroup',
|
||||
'em','strong','b','i','u','s',
|
||||
'blockquote','code','pre',
|
||||
'img','figure','figcaption',
|
||||
@@ -22,6 +33,67 @@ const PURIFY_CONFIG = {
|
||||
// Tables: pasted content commonly includes these; dropping them
|
||||
// silently ate customer-pasted tables (see C1 review finding).
|
||||
'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). URL-bearing attributes on these (poster, srcset,
|
||||
// action, cite...) go through the ALLOWED_URI_REGEXP gate like
|
||||
// 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. The one exception: `src` itself on img/video/
|
||||
// audio/source/image/track is additionally covered by DOMPurify's own
|
||||
// `DATA_URI_TAGS` allow-list, which accepts any data: URI on those
|
||||
// tag/attribute pairs regardless of mimetype, bypassing this regex --
|
||||
// see the ALLOWED_URI_REGEXP comment below and
|
||||
// HtmlBlock.security.test.ts. Not a gap in the four non-negotiables:
|
||||
// none of those tags execute their src as a document.
|
||||
'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',
|
||||
// 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.
|
||||
//
|
||||
// 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',
|
||||
],
|
||||
// NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute
|
||||
// allowlist rather than extending it, so anything the product needs
|
||||
@@ -32,12 +104,114 @@ const PURIFY_CONFIG = {
|
||||
'width','height','class','id','style',
|
||||
'allowfullscreen','allow','frameborder',
|
||||
'sandbox','referrerpolicy',
|
||||
// Task 24 additions.
|
||||
'colspan','rowspan','scope','headers','span','start','reversed',
|
||||
'type','value','name','placeholder','required','disabled','readonly',
|
||||
'checked','selected','multiple','size','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',
|
||||
// Bug fix: <select size="4">/<input size> and <meter low/high/optimum>
|
||||
// were still being stripped even though <select>/<meter> are already in
|
||||
// ALLOWED_TAGS -- only these four attribute names were missing here.
|
||||
// Effect: a multi-select rendered at default height instead of the
|
||||
// requested row count, and <meter> lost its threshold-based gauge
|
||||
// colouring. Pure presentation/semantic attributes -- no URL, no
|
||||
// script, no event-handler surface -- so no security weight added.
|
||||
'low','high','optimum',
|
||||
// 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,
|
||||
FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'],
|
||||
// Review fix (Task 24 follow-up): the data:image arm used to sit inside
|
||||
// the group that gets a trailing `:` appended for every alternative
|
||||
// (`(?:https?|mailto|tel|data:image\/[a-z]+;base64,):`), so it required
|
||||
// a SECOND colon after the one already in "base64,figure" -- no real
|
||||
// data URI has that, so the clause could never match. It is now its own
|
||||
// top-level alternative. NOTE: this regex is not the only thing gating
|
||||
// data: URIs -- DOMPurify has its own internal `DATA_URI_TAGS` allow-list
|
||||
// (img/video/audio/source/image/track) that accepts ANY data: URI on
|
||||
// those tag/attribute pairs regardless of declared mimetype, bypassing
|
||||
// this regex entirely. See HtmlBlock.security.test.ts for a regression
|
||||
// test documenting that (acceptable: none of those tags execute their
|
||||
// src as a document in mainstream browsers, and <iframe> -- which would
|
||||
// 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. 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],
|
||||
// NOTE: FORCE_BODY is deliberately NOT set here -- see
|
||||
// needsForceBody()/purifyHtml() below. It's applied conditionally, per
|
||||
// call, only when the input actually has a real <style> tag to rescue.
|
||||
};
|
||||
|
||||
// Task 25: without FORCE_BODY, 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 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
|
||||
// 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.
|
||||
//
|
||||
// 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)
|
||||
// but an iframe with a `src` and NO `sandbox` attribute is a clickjacking/
|
||||
// phishing vector (DOMPurify already strips <script>/on*=, but an
|
||||
@@ -55,6 +229,120 @@ 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.
|
||||
*/
|
||||
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 {
|
||||
if (!sanitized.includes('<style')) return sanitized;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = sanitized;
|
||||
|
||||
if (isAlreadyScoped(container)) return sanitized;
|
||||
|
||||
const styleEls = Array.from(container.querySelectorAll('style'));
|
||||
const nonEmpty = styleEls.filter((el) => (el.textContent || '').trim() !== '');
|
||||
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)}`;
|
||||
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
|
||||
@@ -63,7 +351,13 @@ 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 raw = 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 {
|
||||
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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="" 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>
|
||||
|
||||
|
||||
<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>
|
||||
@@ -196,3 +196,93 @@ describe('ContactForm.craft.props includes animation/visibility defaults', () =>
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- Webhook destination (Task 10) ----------
|
||||
This file builds props inline rather than spreading a shared object, so
|
||||
`defaultProps` is introduced here for the destination cases only; every
|
||||
pre-existing test above is untouched. */
|
||||
const defaultProps = { fields: [] as any[], formAction: '#' };
|
||||
|
||||
describe('ContactForm.craft.props includes the destination defaults', () => {
|
||||
// The trap this pins: FormStylePanel renders each destination control behind
|
||||
// `nodeProps.X !== undefined`, so a prop omitted from these defaults yields an
|
||||
// invisible control and the whole feature looks like it does nothing.
|
||||
test('destinationType/webhookUrl/webhookSecretId/webhookAuthMode are all present', () => {
|
||||
expect(ContactForm.craft!.props).toMatchObject({
|
||||
destinationType: 'email',
|
||||
webhookUrl: '',
|
||||
webhookSecretId: '',
|
||||
webhookAuthMode: 'signature',
|
||||
});
|
||||
});
|
||||
|
||||
test('no craft prop holds a raw secret -- only an id', () => {
|
||||
const keys = Object.keys(ContactForm.craft!.props as object);
|
||||
expect(keys).toContain('webhookSecretId');
|
||||
expect(keys.filter((k) => /secret/i.test(k))).toEqual(['webhookSecretId']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactForm.toHtml destination marker', () => {
|
||||
test('email destination emits the legacy marker unchanged', () => {
|
||||
const out = toHtml(
|
||||
{ ...defaultProps, destinationType: 'email', recipientEmail: 'a@example.com', thankYouUrl: '' }, '');
|
||||
expect(out.html).toContain('<!--WHP-FORM');
|
||||
expect(out.html).toContain('recipient="a@example.com"');
|
||||
expect(out.html).not.toContain('type="webhook"');
|
||||
});
|
||||
|
||||
test('BYTE-IDENTITY: an email destination emits exactly what a pre-feature form emits', () => {
|
||||
// The guarantee every already-published site depends on: a marker with no
|
||||
// `type` still provisions an email endpoint, so its bytes must not drift by
|
||||
// so much as a space.
|
||||
//
|
||||
// FROZEN LITERAL, not a self-comparison. Comparing two head-revision outputs
|
||||
// to each other only catches a drift that affects ONE of them -- a uniform
|
||||
// change passes it. This string was captured from `071f3447` (the revision
|
||||
// deployed to production before this feature) and is the actual reference:
|
||||
// if it has to be edited, every already-published site's forms have changed
|
||||
// shape and that is the thing to stop, not the test.
|
||||
const FROZEN_LEGACY_MARKER =
|
||||
'<!--WHP-FORM id="F_3hodg" recipient="a@example.com" thankyou="/thx"-->';
|
||||
|
||||
const legacy = toHtml({ ...defaultProps, recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
||||
expect(legacy.html.startsWith(`${FROZEN_LEGACY_MARKER}<form `)).toBe(true);
|
||||
|
||||
// ...and the new props, set to their defaults, change nothing about it.
|
||||
const explicit = toHtml(
|
||||
{ ...defaultProps, destinationType: 'email', webhookUrl: '', webhookSecretId: '',
|
||||
webhookAuthMode: 'signature', recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
||||
expect(explicit.html).toBe(legacy.html);
|
||||
});
|
||||
|
||||
test('webhook destination emits type, url, secret id and auth mode', () => {
|
||||
const out = toHtml(
|
||||
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
|
||||
webhookSecretId: 'sec-1', webhookAuthMode: 'bearer', recipientEmail: 'fb@example.com' }, '');
|
||||
expect(out.html).toContain('type="webhook"');
|
||||
expect(out.html).toContain('url="https://hooks.example.com/x"');
|
||||
expect(out.html).toContain('secret="sec-1"');
|
||||
expect(out.html).toContain('authmode="bearer"');
|
||||
expect(out.html).toContain('recipient="fb@example.com"');
|
||||
});
|
||||
|
||||
test('a raw secret value is never emitted, only its id', () => {
|
||||
const out = toHtml(
|
||||
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
|
||||
webhookSecretId: 'sec-1', webhookSecret: 'SUPERSECRET' } as any, '');
|
||||
// Non-vacuous: the marker IS emitted (so there is something that could have
|
||||
// carried the secret) and carries the id, but not the value.
|
||||
expect(out.html).toContain('secret="sec-1"');
|
||||
expect(out.html).not.toContain('SUPERSECRET');
|
||||
});
|
||||
|
||||
test('a webhook form with no fallback email still emits a marker (never a bare formAction)', () => {
|
||||
const out = toHtml(
|
||||
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' }, '');
|
||||
expect(out.html).toContain('type="webhook"');
|
||||
expect(out.html).toContain('recipient=""');
|
||||
expect(out.html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||
expect(out.html).toContain('name="_gotcha"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,16 @@ interface ContactFormProps {
|
||||
inputBorder?: string;
|
||||
recipientEmail?: string;
|
||||
thankYouUrl?: string;
|
||||
/* ---- Submission destination (see utils/form-relay-wiring.ts) ----
|
||||
'email' (default) reproduces the legacy marker exactly. 'webhook' widens it
|
||||
with the url / secret id / auth mode below.
|
||||
There is deliberately NO raw-secret prop: craft props are serialised into
|
||||
the saved project and into published output, so the secret is POSTed to
|
||||
/api/form-webhook-secret.php and only the returned opaque id is kept. */
|
||||
destinationType?: 'email' | 'webhook';
|
||||
webhookUrl?: string;
|
||||
webhookSecretId?: string;
|
||||
webhookAuthMode?: 'signature' | 'bearer';
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
@@ -171,6 +181,12 @@ ContactForm.craft = {
|
||||
inputBorder: '#d1d5db',
|
||||
recipientEmail: '',
|
||||
thankYouUrl: '',
|
||||
// Present (not omitted) so FormStylePanel's `nodeProps.X !== undefined`
|
||||
// gates actually render the destination controls.
|
||||
destinationType: 'email',
|
||||
webhookUrl: '',
|
||||
webhookSecretId: '',
|
||||
webhookAuthMode: 'signature',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
@@ -233,7 +249,17 @@ ContactForm.craft = {
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
|
||||
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction, nodeId);
|
||||
// Only the webhook SECRET ID travels here -- there is no prop holding the raw
|
||||
// secret, by construction (see ContactFormProps).
|
||||
const { marker, actionAttr, honeypot } = relayFormWiring(
|
||||
props.recipientEmail, props.thankYouUrl, props.formAction, nodeId,
|
||||
{
|
||||
type: props.destinationType,
|
||||
url: props.webhookUrl,
|
||||
secretId: props.webhookSecretId,
|
||||
authMode: props.webhookAuthMode,
|
||||
},
|
||||
);
|
||||
|
||||
// The form-sender relay delivers success via a full-page 303 redirect
|
||||
// (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { storeWebhookSecret } from '../../../utils/form-webhook-secret';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
@@ -58,12 +59,125 @@ const SPACING_SIDE_KEYS: { side: 'top' | 'right' | 'bottom' | 'left'; suffix: 'T
|
||||
{ side: 'left', suffix: 'Left' },
|
||||
];
|
||||
|
||||
const hintStyle: React.CSSProperties = { fontSize: 10, color: '#71717a', margin: '4px 0 0' };
|
||||
|
||||
/**
|
||||
* Does this look like a webhook URL the publish step will accept?
|
||||
*
|
||||
* Mirrors the shape `FormRelayProvisioner::upsertWebhookToken()` enforces
|
||||
* (absolute https, a host, no whitespace/control characters) closely enough to
|
||||
* warn in the panel. It is a HINT, not a gate -- the server-side check is the
|
||||
* real one, and this deliberately never edits or blocks the value.
|
||||
*/
|
||||
export function isHttpsWebhookUrl(value: unknown): boolean {
|
||||
const v = typeof value === 'string' ? value.trim() : '';
|
||||
if (v === '' || /[\s\x00-\x1F\x7F]/.test(v)) return false;
|
||||
try {
|
||||
const u = new URL(v);
|
||||
return u.protocol === 'https:' && u.hostname !== '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Webhook shared secret: WRITE-ONLY field ----------
|
||||
The raw secret lives in this component's local state and nowhere else. On
|
||||
blur it is POSTed to the panel endpoint, which returns an opaque id; only
|
||||
that id is handed to `onStored` (and thence to a craft prop). The field is
|
||||
then cleared, because there is no read route and nothing to show back --
|
||||
the UI offers set / replace / clear, never "view".
|
||||
|
||||
MOUNT THIS WITH `key={selectedId}`. GuidedStyles renders <FormStylePanel>
|
||||
with no key, so a selection change re-renders this component rather than
|
||||
remounting it, and React keeps `draft`/`status`. A failed store deliberately
|
||||
RETAINS the draft (so a 429 or a network blip doesn't make the customer
|
||||
retype a pasted key) -- which means without the key, clicking a second
|
||||
contact form shows node A's raw secret in node B's field, and the next blur
|
||||
assigns the returned id to the wrong form and burns a slot against the
|
||||
per-site cap. The `status` banner leaks the same way. */
|
||||
export const WebhookSecretField: React.FC<{
|
||||
secretId: string;
|
||||
onStored: (secretId: string) => void;
|
||||
onRemoved: () => void;
|
||||
}> = ({ secretId, onStored, onRemoved }) => {
|
||||
const [draft, setDraft] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const save = async () => {
|
||||
const raw = draft.trim();
|
||||
if (raw === '' || status === 'saving') return;
|
||||
setStatus('saving');
|
||||
setError('');
|
||||
const result = await storeWebhookSecret(raw);
|
||||
if (result.ok && result.secretId) {
|
||||
// Only the id crosses this line. The raw value is dropped here and is
|
||||
// never written to a prop, to storage, or back into the input.
|
||||
onStored(result.secretId);
|
||||
setDraft('');
|
||||
setStatus('saved');
|
||||
} else {
|
||||
setStatus('error');
|
||||
setError(result.error || 'Could not store the secret.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle} htmlFor="whp-webhook-secret">Shared secret (optional)</label>
|
||||
<input
|
||||
id="whp-webhook-secret"
|
||||
data-testid="webhook-secret-input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={draft}
|
||||
onChange={(e) => { setDraft(e.target.value); if (status !== 'idle') { setStatus('idle'); setError(''); } }}
|
||||
onBlur={() => { void save(); }}
|
||||
placeholder={secretId ? 'Paste a new secret to replace' : 'Paste the secret from your receiver'}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{secretId && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
|
||||
<span data-testid="webhook-secret-status" style={{ fontSize: 10, color: '#22c55e' }}>Secret stored</span>
|
||||
{/* "Clear", not "Remove": this only drops the form's reference to the
|
||||
key. The stored key file stays on the server and still counts
|
||||
toward the per-site cap -- nothing deletes one, so a button
|
||||
labelled Remove would be telling the customer they had reclaimed
|
||||
a slot right up until the 429 that says otherwise. */}
|
||||
<button
|
||||
data-testid="webhook-secret-remove"
|
||||
onClick={onRemoved}
|
||||
style={{ ...moveBtnStyle, flex: 'none', padding: '3px 8px' }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<p style={hintStyle}>
|
||||
Clearing stops this form using the secret; the stored key stays on the server.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'saving' && <p style={hintStyle}>Storing…</p>}
|
||||
{status === 'saved' && <p data-testid="webhook-secret-saved" style={{ ...hintStyle, color: '#22c55e' }}>Secret stored.</p>}
|
||||
{status === 'error' && <p data-testid="webhook-secret-error" style={{ ...hintStyle, color: '#f87171' }}>{error}</p>}
|
||||
<p style={hintStyle}>
|
||||
Stored on the server and never shown again — paste a new one to replace it. Used to sign
|
||||
(or authorise) each delivery so your receiver can verify it came from this site.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- FORM ---------- */
|
||||
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
// ContactForm only: FormContainer/SubscribeForm have no destinationType prop,
|
||||
// so their relay controls stay exactly as they were.
|
||||
const isWebhook = nodeProps.destinationType === 'webhook';
|
||||
|
||||
const updateField = (index: number, patch: Record<string, any>) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
@@ -158,14 +272,94 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Destination: email (default, unchanged behaviour) or webhook. Gated on
|
||||
the `destinationType` default in ContactForm.craft.props -- a prop
|
||||
missing from those defaults is `undefined` here and the control would
|
||||
simply never render. */}
|
||||
{nodeProps.destinationType !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Send submissions to</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[{ v: 'email', l: 'Email' }, { v: 'webhook', l: 'Webhook' }].map((o) => (
|
||||
<button
|
||||
key={o.v}
|
||||
data-testid={`destination-${o.v}`}
|
||||
onClick={() => setProp('destinationType', o.v)}
|
||||
style={btnActiveStyle((nodeProps.destinationType || 'email') === o.v)}
|
||||
>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nodeProps.destinationType !== undefined && isWebhook && (
|
||||
<>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Webhook URL</label>
|
||||
<input
|
||||
type="text"
|
||||
data-testid="webhook-url"
|
||||
value={nodeProps.webhookUrl || ''}
|
||||
onChange={(e) => setProp('webhookUrl', e.target.value)}
|
||||
placeholder="https://hooks.example.com/..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
{/* The publish step DOES refuse a blank/non-https URL -- loudly, but
|
||||
into an error_log the customer never reads, leaving them with a
|
||||
form that just doesn't work. Warn here instead. Deliberately a
|
||||
warning only: not blanking the value and not blocking the
|
||||
publish, since either would trade a loud server-side refusal for
|
||||
a silently inert form. */}
|
||||
{!isHttpsWebhookUrl(nodeProps.webhookUrl) && (
|
||||
<p data-testid="webhook-url-warning" style={{ ...hintStyle, color: '#fbbf24' }}>
|
||||
{nodeProps.webhookUrl
|
||||
? 'This must be an absolute https:// URL — submissions to this form won\'t be delivered until it is.'
|
||||
: 'Enter the https:// URL to POST submissions to — this form won\'t deliver anything until you do.'}
|
||||
</p>
|
||||
)}
|
||||
<p style={hintStyle}>
|
||||
Must be an absolute <strong>https://</strong> URL. Each submission is POSTed as JSON;
|
||||
failures are retried, then emailed to the fallback address below.
|
||||
</p>
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Authentication</label>
|
||||
<select
|
||||
data-testid="webhook-authmode"
|
||||
value={nodeProps.webhookAuthMode || 'signature'}
|
||||
onChange={(e) => setProp('webhookAuthMode', e.target.value)}
|
||||
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="signature">Signature (HMAC-SHA256 header)</option>
|
||||
<option value="bearer">Bearer token (Authorization header)</option>
|
||||
</select>
|
||||
</div>
|
||||
<WebhookSecretField
|
||||
/* Remounts on every selection change, so a retained draft (and its
|
||||
status banner) can never follow the customer to another form --
|
||||
see the comment on WebhookSecretField. */
|
||||
key={selectedId}
|
||||
secretId={nodeProps.webhookSecretId || ''}
|
||||
onStored={(id) => setProp('webhookSecretId', id)}
|
||||
onRemoved={() => setProp('webhookSecretId', '')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Contact-form relay: where submissions are emailed. Present on ContactForm
|
||||
and FormContainer (both have recipientEmail/thankYouUrl props). */}
|
||||
and FormContainer (both have recipientEmail/thankYouUrl props). With a
|
||||
webhook destination this same address is the FALLBACK the relay uses
|
||||
when delivery is exhausted. */}
|
||||
{nodeProps.recipientEmail !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Send submissions to (email)</label>
|
||||
<label style={labelStyle}>{isWebhook ? 'Fallback email (if the webhook fails)' : 'Send submissions to (email)'}</label>
|
||||
<input type="email" value={nodeProps.recipientEmail || ''} onChange={(e) => setProp('recipientEmail', e.target.value)} placeholder="you@example.com" style={inputStyle} />
|
||||
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
|
||||
Emailed via the site's contact-form relay (an admin must enable it in Server Settings). Leave blank to use the Form Action URL instead.
|
||||
<p style={hintStyle}>
|
||||
{isWebhook
|
||||
? 'Emailed here if the webhook keeps failing after retries. Leave blank to skip the fallback.'
|
||||
: "Emailed via the site's contact-form relay (an admin must enable it in Server Settings). Leave blank to use the Form Action URL instead."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/* Same DOM harness + craftjs mock as NavStylePanel.test.tsx (no
|
||||
@testing-library/react in this repo). `lastProps` IS the node's prop bag: the
|
||||
mocked setProp mutates it exactly as Craft.js would, which is what lets the
|
||||
"no raw secret ever reaches a prop" assertion below be a real check on
|
||||
everything the panel writes rather than on a hand-picked key. */
|
||||
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
|
||||
updater(lastProps);
|
||||
});
|
||||
let lastProps: any;
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({ actions: { setProp: setPropSpy } }),
|
||||
}));
|
||||
|
||||
import { FormStylePanel, isHttpsWebhookUrl } from './FormStylePanel';
|
||||
|
||||
const RAW_SECRET = 'hunter2-SUPER-SECRET-VALUE';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
function rerender(ui: React.ReactElement) {
|
||||
act(() => { root.render(ui); });
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
}
|
||||
|
||||
function setValue(el: HTMLInputElement | HTMLSelectElement, value: string) {
|
||||
const proto = el instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype;
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
|
||||
act(() => {
|
||||
setter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function click(el: Element | null) {
|
||||
if (!el) throw new Error('element not found');
|
||||
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||
}
|
||||
|
||||
/* React 17+ implements onBlur with the native `focusout` event (which bubbles),
|
||||
not `blur`. */
|
||||
async function blur(el: Element) {
|
||||
await act(async () => {
|
||||
el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
const testid = (id: string) => container.querySelector(`[data-testid="${id}"]`);
|
||||
|
||||
function contactFormProps(over: Record<string, any> = {}) {
|
||||
return {
|
||||
fields: [],
|
||||
style: {},
|
||||
recipientEmail: '',
|
||||
thankYouUrl: '',
|
||||
destinationType: 'email',
|
||||
webhookUrl: '',
|
||||
webhookSecretId: '',
|
||||
webhookAuthMode: 'signature',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setPropSpy.mockClear();
|
||||
(window as any).WHP_CONFIG = { apiUrl: '/api/site-builder.php', csrfToken: 'tok', siteId: 42 };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (container) unmount();
|
||||
delete (window as any).WHP_CONFIG;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('FormStylePanel destination selector', () => {
|
||||
test('choosing Webhook writes destinationType and reveals the webhook fields', () => {
|
||||
lastProps = contactFormProps();
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
expect(testid('webhook-url')).toBeNull();
|
||||
|
||||
click(testid('destination-webhook'));
|
||||
expect(lastProps.destinationType).toBe('webhook');
|
||||
|
||||
rerender(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
expect(testid('webhook-url')).toBeTruthy();
|
||||
expect(testid('webhook-authmode')).toBeTruthy();
|
||||
expect(testid('webhook-secret-input')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('the URL and auth-mode controls write their props', () => {
|
||||
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
setValue(testid('webhook-url') as HTMLInputElement, 'https://hooks.example.com/x');
|
||||
expect(lastProps.webhookUrl).toBe('https://hooks.example.com/x');
|
||||
setValue(testid('webhook-authmode') as HTMLSelectElement, 'bearer');
|
||||
expect(lastProps.webhookAuthMode).toBe('bearer');
|
||||
});
|
||||
|
||||
test('the recipient field is relabelled as the fallback address for a webhook', () => {
|
||||
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
const labels = Array.from(container.querySelectorAll('label')).map((l) => l.textContent);
|
||||
expect(labels.some((t) => t?.includes('Fallback email'))).toBe(true);
|
||||
expect(labels.some((t) => t === 'Send submissions to (email)')).toBe(false);
|
||||
});
|
||||
|
||||
test('a component without destinationType (FormContainer) shows neither the selector nor the webhook fields', () => {
|
||||
lastProps = { recipientEmail: '', thankYouUrl: '', style: {} };
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
expect(testid('destination-webhook')).toBeNull();
|
||||
expect(testid('webhook-url')).toBeNull();
|
||||
const labels = Array.from(container.querySelectorAll('label')).map((l) => l.textContent);
|
||||
expect(labels).toContain('Send submissions to (email)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormStylePanel webhook secret field is WRITE-ONLY', () => {
|
||||
test('blurring the secret POSTs it and persists ONLY the returned id', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ success: true, secret_id: 'whs_42_deadbeefdeadbeef' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
const input = testid('webhook-secret-input') as HTMLInputElement;
|
||||
setValue(input, RAW_SECRET);
|
||||
await blur(input);
|
||||
|
||||
// The flow really ran (otherwise the assertions below would pass vacuously).
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body).secret).toBe(RAW_SECRET);
|
||||
expect(lastProps.webhookSecretId).toBe('whs_42_deadbeefdeadbeef');
|
||||
|
||||
// THE PROPERTY: nothing the panel wrote to the node's props contains the raw
|
||||
// secret, under any key -- craft props are serialised into the saved project
|
||||
// and into published output.
|
||||
expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
|
||||
for (const [, updater] of setPropSpy.mock.calls) {
|
||||
const probe: any = {};
|
||||
(updater as (p: any) => void)(probe);
|
||||
expect(JSON.stringify(probe)).not.toContain(RAW_SECRET);
|
||||
}
|
||||
|
||||
// ...and the input is cleared, so it isn't sitting in the DOM either.
|
||||
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe('');
|
||||
expect(testid('webhook-secret-saved')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('typing a secret without blurring writes nothing at all', () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
setValue(testid('webhook-secret-input') as HTMLInputElement, RAW_SECRET);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
|
||||
});
|
||||
|
||||
test('a failed store surfaces the endpoint message and persists no id', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false, status: 429,
|
||||
json: async () => ({ success: false, error: 'Too many webhook secrets stored for this site — please contact support.' }),
|
||||
}));
|
||||
|
||||
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
const input = testid('webhook-secret-input') as HTMLInputElement;
|
||||
setValue(input, RAW_SECRET);
|
||||
await blur(input);
|
||||
|
||||
expect(testid('webhook-secret-error')!.textContent).toContain('Too many webhook secrets stored for this site');
|
||||
expect(lastProps.webhookSecretId).toBe('');
|
||||
expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
|
||||
});
|
||||
|
||||
test('the stored secret is never shown -- only its state, with a Clear action', () => {
|
||||
lastProps = contactFormProps({ destinationType: 'webhook', webhookSecretId: 'whs_42_deadbeefdeadbeef' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
expect(testid('webhook-secret-status')!.textContent).toContain('Secret stored');
|
||||
// The field is a password input that starts empty: there is no read route to
|
||||
// populate it from, and nothing anywhere renders the value.
|
||||
const input = testid('webhook-secret-input') as HTMLInputElement;
|
||||
expect(input.type).toBe('password');
|
||||
expect(input.value).toBe('');
|
||||
|
||||
click(testid('webhook-secret-remove'));
|
||||
expect(lastProps.webhookSecretId).toBe('');
|
||||
});
|
||||
|
||||
test('blurring an empty secret field posts nothing', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
await blur(testid('webhook-secret-input')!);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('the Clear action says what it does: the stored key stays on the server', () => {
|
||||
lastProps = contactFormProps({ destinationType: 'webhook', webhookSecretId: 'whs_42_deadbeefdeadbeef' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
// "Remove" would be a lie: nothing deletes the key file, and it keeps
|
||||
// counting toward the per-site cap the customer eventually 429s against.
|
||||
expect(testid('webhook-secret-remove')!.textContent).toBe('Clear');
|
||||
expect(container.textContent).toContain('the stored key stays on the server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormStylePanel: a retained secret draft never follows the selection to another form', () => {
|
||||
/* GuidedStyles renders <FormStylePanel> with no key, so a selection change
|
||||
re-renders rather than remounts. A failed store deliberately KEEPS the
|
||||
draft, so without a remount the next form's field would open holding the
|
||||
previous form's raw secret -- and the next blur would POST it and assign
|
||||
the returned id to the wrong node. */
|
||||
test('after a failed store on node A, selecting node B shows an empty field and no status', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false, status: 429,
|
||||
json: async () => ({ success: false, error: 'Too many webhook secrets stored for this site — please contact support.' }),
|
||||
}));
|
||||
|
||||
const nodeA = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://a.example/x' });
|
||||
lastProps = nodeA;
|
||||
render(<FormStylePanel selectedId="nodeA" nodeProps={nodeA} />);
|
||||
const input = testid('webhook-secret-input') as HTMLInputElement;
|
||||
setValue(input, RAW_SECRET);
|
||||
await blur(input);
|
||||
|
||||
// Precondition: the draft really was retained on node A (otherwise this
|
||||
// test would pass for the wrong reason).
|
||||
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe(RAW_SECRET);
|
||||
expect(testid('webhook-secret-error')).toBeTruthy();
|
||||
|
||||
const nodeB = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://b.example/y' });
|
||||
lastProps = nodeB;
|
||||
rerender(<FormStylePanel selectedId="nodeB" nodeProps={nodeB} />);
|
||||
|
||||
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe('');
|
||||
expect(container.textContent).not.toContain(RAW_SECRET);
|
||||
expect(testid('webhook-secret-error')).toBeNull();
|
||||
});
|
||||
|
||||
test('a "Secret stored." banner does not follow the selection either', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ success: true, secret_id: 'whs_42_deadbeefdeadbeef' }),
|
||||
}));
|
||||
|
||||
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||
render(<FormStylePanel selectedId="nodeA" nodeProps={lastProps} />);
|
||||
const input = testid('webhook-secret-input') as HTMLInputElement;
|
||||
setValue(input, RAW_SECRET);
|
||||
await blur(input);
|
||||
expect(testid('webhook-secret-saved')).toBeTruthy();
|
||||
|
||||
const nodeB = contactFormProps({ destinationType: 'webhook' });
|
||||
lastProps = nodeB;
|
||||
rerender(<FormStylePanel selectedId="nodeB" nodeProps={nodeB} />);
|
||||
expect(testid('webhook-secret-saved')).toBeNull();
|
||||
expect(testid('webhook-secret-status')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHttpsWebhookUrl / the inline URL warning', () => {
|
||||
test.each([
|
||||
['https://hooks.example.com/x', true],
|
||||
['https://hooks.example.com/x?a=1&b=2', true],
|
||||
['http://hooks.example.com/x', false],
|
||||
['hooks.example.com/x', false],
|
||||
['/relative/path', false],
|
||||
['', false],
|
||||
[' ', false],
|
||||
['https://hooks.example.com/x\nHost: evil', false],
|
||||
['javascript:alert(1)', false],
|
||||
])('%s -> %s', (value, expected) => {
|
||||
expect(isHttpsWebhookUrl(value)).toBe(expected);
|
||||
});
|
||||
|
||||
test('a blank URL warns that nothing will be delivered', () => {
|
||||
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
expect(testid('webhook-url-warning')!.textContent).toContain("won't deliver anything");
|
||||
});
|
||||
|
||||
test('a non-https URL warns without altering the value', () => {
|
||||
lastProps = contactFormProps({ destinationType: 'webhook', webhookUrl: 'http://hooks.example.com/x' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
expect(testid('webhook-url-warning')!.textContent).toContain('absolute https:// URL');
|
||||
// Warning only -- the panel must not blank or rewrite the prop, which would
|
||||
// turn the publish step's loud refusal into a silently inert form.
|
||||
expect(lastProps.webhookUrl).toBe('http://hooks.example.com/x');
|
||||
expect((testid('webhook-url') as HTMLInputElement).value).toBe('http://hooks.example.com/x');
|
||||
});
|
||||
|
||||
test('a valid https URL shows no warning', () => {
|
||||
lastProps = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
expect(testid('webhook-url-warning')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -35,4 +35,10 @@ describe('HtmlStylePanel', () => {
|
||||
expect(container.textContent).not.toContain('Padding');
|
||||
expect(container.textContent).not.toContain('Border Radius');
|
||||
});
|
||||
|
||||
test('renders the security advisory', () => {
|
||||
render(<HtmlStylePanel selectedId="n1" nodeProps={{ code: '<p>x</p>', style: {} }} />);
|
||||
expect(container.textContent).toContain('Use this block with care.');
|
||||
expect(container.textContent).toContain('Scripts and event handlers are stripped');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,13 @@ export const HtmlStylePanel: React.FC<{ selectedId: string; nodeProps: Record<st
|
||||
Style this block inside your own markup — a wrapper set here would show
|
||||
in the editor but not on the published page.
|
||||
</p>
|
||||
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', lineHeight: 1.4, padding: '0 2px' }}>
|
||||
<strong>Use this block with care.</strong> It renders your markup as-is
|
||||
on the published site. Scripts and event handlers are stripped
|
||||
automatically, but anything that survives — forms, iframes, images —
|
||||
can still send data to wherever it points. Only paste code you
|
||||
understand or trust.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,3 +36,120 @@ describe('relayFormWiring deterministic + unique fid (thread node id, no Math.ra
|
||||
expect(w1.actionAttr).toBe(w2.actionAttr);
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- Webhook destination (Task 10) ---------- */
|
||||
|
||||
/** The publish-side parser, verbatim from web-files/libs/FormRelayRewrite.php's
|
||||
* fs_rewrite_contact_forms() -- ported to JS so a drift in the emitter's
|
||||
* attribute ORDER, NAMES or QUOTING fails here rather than at publish time,
|
||||
* where it either leaks the recipient address or refuses the publish. */
|
||||
const PUBLISH_MARKER_RE =
|
||||
/^<!--WHP-FORM id="([^"]+)"((?: [a-z]+="[^"]*")*) recipient="([^"]*)" thankyou="([^"]*)"-->$/;
|
||||
|
||||
describe('relayFormWiring email destination stays byte-identical to the legacy shape', () => {
|
||||
test('no destination arg, type undefined, and type "email" all produce the same marker', () => {
|
||||
const legacy = relayFormWiring('a@b.com', '/thx', '/act', 'n1');
|
||||
const undef = relayFormWiring('a@b.com', '/thx', '/act', 'n1', {});
|
||||
const email = relayFormWiring('a@b.com', '/thx', '/act', 'n1', { type: 'email', url: 'https://x.example/y', secretId: 'whs_1_abc', authMode: 'bearer' });
|
||||
expect(undef.marker).toBe(legacy.marker);
|
||||
expect(email.marker).toBe(legacy.marker);
|
||||
expect(email.actionAttr).toBe(legacy.actionAttr);
|
||||
expect(legacy.marker).toMatch(/^<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b\.com" thankyou="\/thx"-->$/);
|
||||
});
|
||||
|
||||
test('an email form with no recipient is still not a relay at all', () => {
|
||||
const w = relayFormWiring('', '/thx', '/legacy', 'n1', { type: 'email' });
|
||||
expect(w.useRelay).toBe(false);
|
||||
expect(w.marker).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relayFormWiring webhook marker matches the publish-side parser exactly', () => {
|
||||
const w = relayFormWiring('fb@b.com', '/thx', '#', 'n1', {
|
||||
type: 'webhook', url: 'https://hooks.example.com/x', secretId: 'whs_7_abc123', authMode: 'bearer',
|
||||
});
|
||||
|
||||
test('the whole marker parses with FormRelayRewrite.php\'s pattern', () => {
|
||||
const m = w.marker.match(PUBLISH_MARKER_RE);
|
||||
expect(m).not.toBeNull();
|
||||
expect(m![3]).toBe('fb@b.com');
|
||||
expect(m![4]).toBe('/thx');
|
||||
});
|
||||
|
||||
test('the optional attributes sit BETWEEN id and recipient, lowercase-named', () => {
|
||||
const attrs = w.marker.match(PUBLISH_MARKER_RE)![2];
|
||||
expect(attrs).toBe(' type="webhook" url="https://hooks.example.com/x" secret="whs_7_abc123" authmode="bearer"');
|
||||
});
|
||||
|
||||
test('a webhook with no recipient still emits a relay marker', () => {
|
||||
const bare = relayFormWiring('', '', '#', 'n1', { type: 'webhook', url: 'https://hooks.example.com/x' });
|
||||
expect(bare.useRelay).toBe(true);
|
||||
expect(bare.marker).toMatch(PUBLISH_MARKER_RE);
|
||||
expect(bare.marker).toContain('authmode="signature"');
|
||||
});
|
||||
|
||||
test('two webhook forms with no node id and different urls get different fids', () => {
|
||||
const a = relayFormWiring('', '', '#', undefined, { type: 'webhook', url: 'https://a.example/x' });
|
||||
const b = relayFormWiring('', '', '#', undefined, { type: 'webhook', url: 'https://b.example/x' });
|
||||
expect(a.marker).not.toBe(b.marker);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relayFormWiring escapes EVERY marker attribute value', () => {
|
||||
/* The property the publish-time strip is built on: no raw `"`, `<`, `>` or a
|
||||
literal `-->` may reach a marker attribute value. An unescaped one truncates
|
||||
the strip mid-marker and ships the recipient address in the page source, or
|
||||
trips the post-condition and refuses the publish outright. */
|
||||
const hostile = 'a"b<c>d\'e-->f';
|
||||
const w = relayFormWiring(`${hostile}@x.com`, hostile, '#', 'n1', {
|
||||
type: 'webhook', url: `https://x/${hostile}`, secretId: hostile, authMode: 'bearer',
|
||||
});
|
||||
|
||||
test('the marker still parses as ONE marker (nothing escaped out of a value)', () => {
|
||||
expect(w.marker).toMatch(PUBLISH_MARKER_RE);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['url', `https://x/${hostile}`],
|
||||
['secret', hostile],
|
||||
['recipient', `${hostile}@x.com`],
|
||||
['thankyou', hostile],
|
||||
])('%s carries no raw ", <, > or -->', (name) => {
|
||||
const value = w.marker.match(new RegExp(` ${name}="([^"]*)"`))![1];
|
||||
expect(value).not.toMatch(/["<>]/);
|
||||
expect(value).not.toContain('-->');
|
||||
expect(value).toContain('"');
|
||||
expect(value).toContain('<');
|
||||
expect(value).toContain('>');
|
||||
});
|
||||
|
||||
test('the raw hostile string never appears anywhere in the marker', () => {
|
||||
expect(w.marker).not.toContain(hostile);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relayFormWiring allowlists type and authmode instead of trusting them', () => {
|
||||
test('type is matched case-insensitively -- "Webhook" is a webhook, not a silent email', () => {
|
||||
const w = relayFormWiring('a@b.com', '', '#', 'n1', { type: 'WebHook', url: 'https://x/y' });
|
||||
expect(w.marker).toContain('type="webhook"');
|
||||
});
|
||||
|
||||
test('an unknown type falls back to the legacy email marker', () => {
|
||||
const w = relayFormWiring('a@b.com', '', '#', 'n1', { type: 'slack', url: 'https://x/y' } as any);
|
||||
expect(w.marker).not.toContain('type=');
|
||||
expect(w.marker).toBe(relayFormWiring('a@b.com', '', '#', 'n1').marker);
|
||||
});
|
||||
|
||||
test('a hostile authMode collapses to signature and cannot break out of the attribute', () => {
|
||||
const w = relayFormWiring('a@b.com', '', '#', 'n1', {
|
||||
type: 'webhook', url: 'https://x/y', authMode: 'bearer" onx="1',
|
||||
});
|
||||
expect(w.marker).toContain('authmode="signature"');
|
||||
expect(w.marker).toMatch(PUBLISH_MARKER_RE);
|
||||
});
|
||||
|
||||
test('"Bearer" is promoted to the exact literal the relay compares against', () => {
|
||||
const w = relayFormWiring('a@b.com', '', '#', 'n1', { type: 'webhook', url: 'https://x/y', authMode: ' Bearer ' });
|
||||
expect(w.marker).toContain('authmode="bearer"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,58 @@
|
||||
|
||||
import { escapeAttr, safeUrl, scopeId } from './escape';
|
||||
|
||||
/**
|
||||
* Where a form's submissions go. Optional on every call site: a form that
|
||||
* passes nothing here (or `type: 'email'`) emits the LEGACY marker, byte for
|
||||
* byte -- see `relayFormWiring` below.
|
||||
*/
|
||||
export interface FormDestination {
|
||||
/** 'webhook' (case-insensitive) selects the webhook path; anything else = email. */
|
||||
type?: string;
|
||||
/** Absolute https URL the relay POSTs to. Validated at publish time. */
|
||||
url?: string;
|
||||
/** Opaque id minted by /api/form-webhook-secret.php. NEVER the raw secret. */
|
||||
secretId?: string;
|
||||
/** 'bearer' or 'signature' (HMAC, the default). */
|
||||
authMode?: string;
|
||||
}
|
||||
|
||||
/** The two destination types this emitter knows how to describe. */
|
||||
const DESTINATION_TYPES = ['email', 'webhook'] as const;
|
||||
/** The two auth modes the relay implements (FormRelayProvisioner::upsertWebhookToken). */
|
||||
const AUTH_MODES = ['signature', 'bearer'] as const;
|
||||
|
||||
/**
|
||||
* Allowlist a destination type / auth mode rather than escaping it.
|
||||
*
|
||||
* Same reasoning as `sanitizeInputType`/`sanitizeFormMethod` in ./escape: these
|
||||
* props are declared as unions in TS but arrive raw from a deserialized saved
|
||||
* state or the AI `update_props` path, and the only legitimate values are a
|
||||
* fixed pair. Narrowing here also stops a case-drifted `"Bearer"` from being
|
||||
* silently downgraded to `signature` by the publish step (which compares
|
||||
* `=== 'bearer'` exactly) -- the customer would see unsigned deliveries with
|
||||
* nothing in the UI to explain it.
|
||||
*/
|
||||
function allowlist<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
const v = (typeof value === 'string' ? value : '').trim().toLowerCase();
|
||||
return (allowed as readonly string[]).includes(v) ? (v as T) : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one ` name="value"` marker attribute.
|
||||
*
|
||||
* THE SINGLE ESCAPING SITE for every optional marker attribute. The publish-time
|
||||
* strip (whp: web-files/libs/FormRelayRewrite.php) has been hardened six times
|
||||
* over exactly this: an unescaped `<`, `>` or a literal `-->` inside a marker
|
||||
* attribute value truncates the strip mid-marker and leaks the customer's
|
||||
* recipient address into their public page source, or trips the post-condition
|
||||
* and refuses the publish outright. `escapeAttr` removing `"` is also what keeps
|
||||
* each value inside the `[^"]*` the publish-side parser expects.
|
||||
*/
|
||||
function markerAttr(name: string, value: string): string {
|
||||
return ` ${name}="${escapeAttr(value)}"`;
|
||||
}
|
||||
|
||||
export interface RelayWiring {
|
||||
/** true when a recipient is set (relay path); false = legacy formAction fallback */
|
||||
useRelay: boolean;
|
||||
@@ -30,20 +82,63 @@ export interface RelayWiring {
|
||||
* the marker/placeholder id deterministically and uniquely --
|
||||
* see `scopeId` in ./escape. Falls back to a stable hash of the
|
||||
* recipient/thankYouUrl/fallbackAction when omitted (never random).
|
||||
* @param destination optional destination descriptor. Omitted, or `type: 'email'`,
|
||||
* yields the LEGACY narrow marker byte for byte -- every
|
||||
* already-published site depends on that shape continuing to
|
||||
* provision an email endpoint.
|
||||
*
|
||||
* The wide (webhook) marker keeps the optional attributes BETWEEN `id` and
|
||||
* `recipient`, which is where the publish-side parser looks for them:
|
||||
*
|
||||
* /<!--WHP-FORM id="([^"]+)"((?: [a-z]+="[^"]*")*) recipient="([^"]*)" thankyou="([^"]*)"-->/
|
||||
*
|
||||
* (FormRelayRewrite.php). Attribute NAMES must therefore be lowercase, and every
|
||||
* VALUE must be free of `"` -- both guaranteed here, the latter by `markerAttr`.
|
||||
*
|
||||
* A webhook marker is emitted whenever the customer selected webhook, even with a
|
||||
* blank URL: the publish step then refuses that one endpoint and logs it (the form
|
||||
* publishes inert). Falling back to the email path instead would deliver mail to a
|
||||
* customer who configured a webhook, with nothing anywhere to explain it -- the
|
||||
* exact silent degradation the publish-side `type` normalisation exists to stop.
|
||||
*/
|
||||
export function relayFormWiring(
|
||||
recipientEmail: string | undefined,
|
||||
thankYouUrl: string | undefined,
|
||||
fallbackAction: string | undefined,
|
||||
nodeId?: string,
|
||||
destination?: FormDestination,
|
||||
): RelayWiring {
|
||||
if (!recipientEmail) {
|
||||
const destType = allowlist(destination?.type, DESTINATION_TYPES, 'email');
|
||||
const isWebhook = destType === 'webhook';
|
||||
|
||||
// No destination at all: nothing to deliver to, so no relay (unchanged).
|
||||
if (!recipientEmail && !isWebhook) {
|
||||
return { useRelay: false, marker: '', actionAttr: escapeAttr(safeUrl(fallbackAction || '#')), honeypot: '' };
|
||||
}
|
||||
const fid = scopeId(nodeId, `${recipientEmail}::${thankYouUrl || ''}::${fallbackAction || ''}`, 'F');
|
||||
|
||||
// Webhook config participates in the fallback seed so two webhook forms with
|
||||
// no node id and no recipient don't collide on one fid. Appended only in the
|
||||
// webhook branch, so the legacy seed -- and therefore every legacy fid -- is
|
||||
// unchanged.
|
||||
const seed = `${recipientEmail || ''}::${thankYouUrl || ''}::${fallbackAction || ''}`
|
||||
+ (isWebhook ? `::webhook::${destination?.url || ''}::${destination?.secretId || ''}` : '');
|
||||
const fid = scopeId(nodeId, seed, 'F');
|
||||
|
||||
// The `url` value is NOT routed through `safeUrl`: it is never a live sink
|
||||
// (it lands in an HTML comment that the publish step strips), and blanking it
|
||||
// here would silently turn a mistyped destination into an inert form with no
|
||||
// log line. The publish step validates it properly -- absolute https, no
|
||||
// control characters -- and refuses loudly when it doesn't hold.
|
||||
const extraAttrs = isWebhook
|
||||
? markerAttr('type', 'webhook')
|
||||
+ markerAttr('url', destination?.url || '')
|
||||
+ markerAttr('secret', destination?.secretId || '')
|
||||
+ markerAttr('authmode', allowlist(destination?.authMode, AUTH_MODES, 'signature'))
|
||||
: '';
|
||||
|
||||
return {
|
||||
useRelay: true,
|
||||
marker: `<!--WHP-FORM id="${fid}" recipient="${escapeAttr(recipientEmail)}" thankyou="${escapeAttr(thankYouUrl || '')}"-->`,
|
||||
marker: `<!--WHP-FORM id="${fid}"${extraAttrs} recipient="${escapeAttr(recipientEmail || '')}" thankyou="${escapeAttr(thankYouUrl || '')}"-->`,
|
||||
actionAttr: `__WHP_FORM_ACTION__${fid}__`,
|
||||
honeypot: `<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { storeWebhookSecret, webhookSecretEndpoint } from './form-webhook-secret';
|
||||
|
||||
const CFG = { apiUrl: '/api/site-builder.php', csrfToken: 'tok-123', siteId: 42 };
|
||||
|
||||
beforeEach(() => {
|
||||
(window as any).WHP_CONFIG = { ...CFG };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).WHP_CONFIG;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('webhookSecretEndpoint', () => {
|
||||
test('derives the sibling endpoint from the configured API url', () => {
|
||||
expect(webhookSecretEndpoint('/api/site-builder.php')).toBe('/api/form-webhook-secret.php');
|
||||
expect(webhookSecretEndpoint('https://panel.example.com/api/site-builder'))
|
||||
.toBe('https://panel.example.com/api/form-webhook-secret.php');
|
||||
});
|
||||
|
||||
test('falls back to the absolute path when there is no configured url', () => {
|
||||
expect(webhookSecretEndpoint(undefined)).toBe('/api/form-webhook-secret.php');
|
||||
expect(webhookSecretEndpoint('')).toBe('/api/form-webhook-secret.php');
|
||||
});
|
||||
});
|
||||
|
||||
describe('storeWebhookSecret', () => {
|
||||
test('POSTs the secret with the CSRF header and returns only the id', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ success: true, secret_id: 'whs_42_abcdef0123456789' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await storeWebhookSecret('SUPERSECRET');
|
||||
|
||||
expect(result).toEqual({ ok: true, secretId: 'whs_42_abcdef0123456789' });
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/form-webhook-secret.php');
|
||||
// POST only -- the endpoint has no read route by design.
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(opts.headers['X-CSRF-Token']).toBe('tok-123');
|
||||
expect(JSON.parse(opts.body)).toEqual({ site_id: 42, secret: 'SUPERSECRET' });
|
||||
});
|
||||
|
||||
test('passes the 429 cap message through so a customer can act on it', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false, status: 429,
|
||||
json: async () => ({ success: false, error: 'Too many webhook secrets stored for this site — please contact support.' }),
|
||||
}));
|
||||
const result = await storeWebhookSecret('s');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('Too many webhook secrets stored for this site');
|
||||
});
|
||||
|
||||
test('a success:false body is a failure even with HTTP 200', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true, status: 200, json: async () => ({ success: false, error: 'Invalid CSRF token' }),
|
||||
}));
|
||||
expect(await storeWebhookSecret('s')).toEqual({ ok: false, error: 'Invalid CSRF token' });
|
||||
});
|
||||
|
||||
test('a network failure resolves with an error rather than throwing', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
||||
const result = await storeWebhookSecret('s');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.secretId).toBeUndefined();
|
||||
});
|
||||
|
||||
test('standalone mode (no WHP_CONFIG) never posts anywhere', async () => {
|
||||
delete (window as any).WHP_CONFIG;
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const result = await storeWebhookSecret('s');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/* ---------- Contact-form webhook secret: WRITE-ONLY client ----------
|
||||
Posts a raw shared secret to the panel endpoint and gets back an opaque id.
|
||||
|
||||
The raw secret is never stored anywhere on the client: it is passed to
|
||||
`storeWebhookSecret` from local component state, and only the returned
|
||||
`secret_id` is ever written to a craft prop. Craft props are serialised into
|
||||
the saved project and into published output, which is the wrong tier for a
|
||||
credential -- that is the whole reason this endpoint exists.
|
||||
|
||||
THERE IS NO READ. The panel endpoint (web-files/api/form-webhook-secret.php)
|
||||
is POST-only by design: a "show me my secret" route would re-open the exact
|
||||
problem this closes. Rotation is another POST, which mints a NEW id. So the
|
||||
UI can offer set / replace / remove, and never "view". */
|
||||
|
||||
export interface StoreSecretResult {
|
||||
ok: boolean;
|
||||
/** Present only on success -- `whs_<siteId>_<hex>`. */
|
||||
secretId?: string;
|
||||
/** Customer-facing message on failure (the endpoint's own, when it sent one). */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the secret endpoint from the configured API url, so a deployment that
|
||||
* moves the panel API (or the vite dev proxy) doesn't need a second constant
|
||||
* kept in sync: `/api/site-builder.php` -> `/api/form-webhook-secret.php`.
|
||||
*/
|
||||
export function webhookSecretEndpoint(apiUrl?: string): string {
|
||||
const base = typeof apiUrl === 'string' ? apiUrl.trim() : '';
|
||||
if (base.includes('/')) return base.replace(/[^/]*$/, 'form-webhook-secret.php');
|
||||
return '/api/form-webhook-secret.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a raw webhook secret for the current site; resolves with its opaque id.
|
||||
*
|
||||
* Never throws and never returns the secret. Errors are returned as text fit to
|
||||
* show a customer -- including the endpoint's 429 ("Too many webhook secrets
|
||||
* stored for this site"), which is actionable (contact support) and so is
|
||||
* passed through rather than flattened into a generic failure.
|
||||
*/
|
||||
export async function storeWebhookSecret(secret: string): Promise<StoreSecretResult> {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) {
|
||||
return { ok: false, error: 'Saving a webhook secret needs the builder to be open inside the control panel.' };
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(webhookSecretEndpoint(cfg.apiUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': cfg.csrfToken },
|
||||
body: JSON.stringify({ site_id: cfg.siteId, secret }),
|
||||
});
|
||||
const data = await resp.json().catch(() => null);
|
||||
if (resp.ok && data && data.success === true && typeof data.secret_id === 'string' && data.secret_id !== '') {
|
||||
return { ok: true, secretId: data.secret_id };
|
||||
}
|
||||
const message = data && typeof data.error === 'string' && data.error !== ''
|
||||
? data.error
|
||||
: `Could not store the secret (HTTP ${resp.status}).`;
|
||||
return { ok: false, error: message };
|
||||
} catch {
|
||||
return { ok: false, error: 'Could not reach the control panel to store the secret.' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { scopeCss } from './scope-css';
|
||||
|
||||
const SCOPE = '.whp-html-1a2b3c4d';
|
||||
|
||||
describe('scopeCss -- basic selector scoping', () => {
|
||||
test('a single simple selector gets prefixed', () => {
|
||||
expect(scopeCss('h1 { color: red; }', SCOPE)).toBe(`${SCOPE} h1 { color: red; }`);
|
||||
});
|
||||
|
||||
test('multiple rules each get prefixed independently', () => {
|
||||
const input = 'h1 { color: red; } p { color: blue; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toContain(`${SCOPE} h1 { color: red; }`);
|
||||
expect(out).toContain(`${SCOPE} p { color: blue; }`);
|
||||
});
|
||||
|
||||
test('a compound descendant selector is prefixed as a whole, not per-token', () => {
|
||||
expect(scopeCss('div.card > h2 { color: red; }', SCOPE)).toBe(`${SCOPE} div.card > h2 { color: red; }`);
|
||||
});
|
||||
|
||||
test('pseudo-classes/elements survive attached to their element', () => {
|
||||
expect(scopeCss('a:hover { color: red; }', SCOPE)).toBe(`${SCOPE} a:hover { color: red; }`);
|
||||
expect(scopeCss('p::before { content: "x"; }', SCOPE)).toBe(`${SCOPE} p::before { content: "x"; }`);
|
||||
});
|
||||
|
||||
test('the universal selector is prefixed', () => {
|
||||
expect(scopeCss('* { box-sizing: border-box; }', SCOPE)).toBe(`${SCOPE} * { box-sizing: border-box; }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- comma-separated selector lists (every selector must be scoped)', () => {
|
||||
test('h1, h2 > p scopes BOTH selectors, not just the first', () => {
|
||||
const out = scopeCss('h1, h2 > p { margin: 0; }', SCOPE);
|
||||
expect(out).toBe(`${SCOPE} h1, ${SCOPE} h2 > p { margin: 0; }`);
|
||||
});
|
||||
|
||||
test('a long comma list scopes every entry', () => {
|
||||
const out = scopeCss('h1, h2, h3, h4 { font-weight: bold; }', SCOPE);
|
||||
expect(out).toBe(`${SCOPE} h1, ${SCOPE} h2, ${SCOPE} h3, ${SCOPE} h4 { font-weight: bold; }`);
|
||||
});
|
||||
|
||||
test('a comma inside :not(...) is not treated as a selector-list separator', () => {
|
||||
const out = scopeCss('div:not(h1, h2) { color: red; }', SCOPE);
|
||||
expect(out).toBe(`${SCOPE} div:not(h1, h2) { color: red; }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- @media / @supports / @container recurse into the body', () => {
|
||||
test('@media keeps its condition prelude intact and scopes the selector inside', () => {
|
||||
const input = '@media (min-width: 600px) { h1 { color: red; } }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`@media (min-width: 600px) { ${SCOPE} h1 { color: red; } }`);
|
||||
});
|
||||
|
||||
test('@supports keeps its condition prelude intact and scopes the selector inside', () => {
|
||||
const input = '@supports (display: grid) { .grid { display: grid; } }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`@supports (display: grid) { ${SCOPE} .grid { display: grid; } }`);
|
||||
});
|
||||
|
||||
test('@container keeps its condition prelude intact and scopes the selector inside', () => {
|
||||
const input = '@container (min-width: 400px) { .card { padding: 8px; } }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`@container (min-width: 400px) { ${SCOPE} .card { padding: 8px; } }`);
|
||||
});
|
||||
|
||||
test('multiple rules inside one @media block are each scoped', () => {
|
||||
const input = '@media (min-width: 600px) { h1 { color: red; } p { color: blue; } }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`@media (min-width: 600px) { ${SCOPE} h1 { color: red; } ${SCOPE} p { color: blue; } }`);
|
||||
});
|
||||
|
||||
test('a comma-separated selector list inside @media is fully scoped', () => {
|
||||
const input = '@media (min-width: 600px) { h1, h2 { color: red; } }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`@media (min-width: 600px) { ${SCOPE} h1, ${SCOPE} h2 { color: red; } }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- @keyframes body is left untouched', () => {
|
||||
test('keyframe selectors (from/to/percentages) are not scoped', () => {
|
||||
const input = '@keyframes spin { from { opacity: 0; } 50% { opacity: 0.5; } to { opacity: 1; } }';
|
||||
expect(scopeCss(input, SCOPE)).toBe(input);
|
||||
});
|
||||
|
||||
test('vendor-prefixed @-webkit-keyframes body is also left untouched', () => {
|
||||
const input = '@-webkit-keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }';
|
||||
expect(scopeCss(input, SCOPE)).toBe(input);
|
||||
});
|
||||
|
||||
test('a normal rule after a @keyframes block is still scoped (parser resyncs correctly)', () => {
|
||||
const input = '@keyframes spin { from { opacity: 0; } to { opacity: 1; } } h1 { color: red; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`@keyframes spin { from { opacity: 0; } to { opacity: 1; } } ${SCOPE} h1 { color: red; }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- @font-face is left entirely alone (no selector to scope)', () => {
|
||||
test('@font-face block passes through byte-identical', () => {
|
||||
const input = "@font-face { font-family: 'Custom'; src: url(custom.woff2) format('woff2'); }";
|
||||
expect(scopeCss(input, SCOPE)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- @import is stripped', () => {
|
||||
test('a bare @import statement is removed', () => {
|
||||
const out = scopeCss('@import url("https://evil.example/x.css");', SCOPE);
|
||||
expect(out).not.toContain('@import');
|
||||
expect(out).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
test('@import surrounded by real rules: only the import is removed, the rules survive scoped', () => {
|
||||
const input = '@import url("x.css"); h1 { color: red; } p { color: blue; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).not.toContain('@import');
|
||||
expect(out).toContain(`${SCOPE} h1 { color: red; }`);
|
||||
expect(out).toContain(`${SCOPE} p { color: blue; }`);
|
||||
});
|
||||
|
||||
test('@import with a semicolon inside its quoted url is still recognized as ONE statement', () => {
|
||||
// The url itself doesn't contain a semicolon in practice, but this
|
||||
// proves the statement-terminator scan is string-aware in general: a
|
||||
// quoted string's contents (whatever they are) never end the statement
|
||||
// early.
|
||||
const input = '@import url("foo.css?x=1;y=2"); h1 { color: red; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).not.toContain('@import');
|
||||
expect(out).not.toContain('foo.css');
|
||||
expect(out).toContain(`${SCOPE} h1 { color: red; }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- :root / html / body map to the scope root itself', () => {
|
||||
test(':root custom properties target the wrapper, not a nonexistent descendant', () => {
|
||||
expect(scopeCss(':root { --brand: red; }', SCOPE)).toBe(`${SCOPE} { --brand: red; }`);
|
||||
});
|
||||
|
||||
test('html selector maps to the scope root', () => {
|
||||
expect(scopeCss('html { background: #fff; }', SCOPE)).toBe(`${SCOPE} { background: #fff; }`);
|
||||
});
|
||||
|
||||
test('body selector maps to the scope root', () => {
|
||||
expect(scopeCss('body { margin: 0; }', SCOPE)).toBe(`${SCOPE} { margin: 0; }`);
|
||||
});
|
||||
|
||||
test('case-insensitive: HTML and BODY also map to the scope root', () => {
|
||||
expect(scopeCss('HTML { color: red; }', SCOPE)).toBe(`${SCOPE} { color: red; }`);
|
||||
expect(scopeCss('BODY { color: red; }', SCOPE)).toBe(`${SCOPE} { color: red; }`);
|
||||
});
|
||||
|
||||
test(':root mixed into a comma list scopes the other entries normally', () => {
|
||||
const out = scopeCss(':root, h1 { color: red; }', SCOPE);
|
||||
expect(out).toBe(`${SCOPE}, ${SCOPE} h1 { color: red; }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- comments and strings are not treated as syntax', () => {
|
||||
test('a brace inside a comment does not confuse block matching', () => {
|
||||
const input = 'h1 { color: red; /* comment with a { brace */ }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`${SCOPE} ${input}`);
|
||||
});
|
||||
|
||||
test('a comma inside a comment does not split a selector list', () => {
|
||||
const input = 'h1 /* a, b */ , p { color: red; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`${SCOPE} h1 /* a, b */, ${SCOPE} p { color: red; }`);
|
||||
});
|
||||
|
||||
test('an @ inside a comment does not trigger at-rule handling', () => {
|
||||
// The comment sits in front of the selector text, so it stays part of
|
||||
// what gets prefixed (a CSS comment is insignificant whitespace to the
|
||||
// parser -- `.scope /* c */ h1` is equivalent to `.scope h1`). What
|
||||
// this test really guards: the leading "@import" text INSIDE the
|
||||
// comment must not make the classifier treat this as an @import
|
||||
// statement and strip the whole rule.
|
||||
const input = '/* @import fake */ h1 { color: red; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`${SCOPE} /* @import fake */ h1 { color: red; }`);
|
||||
expect(out).toContain('color: red');
|
||||
});
|
||||
|
||||
test('a brace inside a quoted content string does not confuse block matching', () => {
|
||||
const input = 'p::before { content: "{ not a brace }"; }';
|
||||
expect(scopeCss(input, SCOPE)).toBe(`${SCOPE} ${input}`);
|
||||
});
|
||||
|
||||
test('a comma inside a quoted string does not split a selector list', () => {
|
||||
const input = 'h1[data-x="a,b"], p { color: red; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`${SCOPE} h1[data-x="a,b"], ${SCOPE} p { color: red; }`);
|
||||
});
|
||||
|
||||
test('a semicolon inside a quoted string does not end an @import early', () => {
|
||||
const input = 'h1::before { content: "a;b"; } p { color: red; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`${SCOPE} h1::before { content: "a;b"; } ${SCOPE} p { color: red; }`);
|
||||
});
|
||||
|
||||
test('an unterminated comment consumes to end of string without throwing', () => {
|
||||
expect(() => scopeCss('h1 { color: red; } /* unterminated', SCOPE)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- idempotency (running twice must not double-prefix)', () => {
|
||||
test('a plain selector is not re-prefixed on a second pass', () => {
|
||||
const once = scopeCss('h1 { color: red; }', SCOPE);
|
||||
const twice = scopeCss(once, SCOPE);
|
||||
expect(twice).toBe(once);
|
||||
expect(twice.match(new RegExp(SCOPE.replace('.', '\\.'), 'g'))?.length).toBe(1);
|
||||
});
|
||||
|
||||
test(':root-mapped rule is not re-prefixed on a second pass', () => {
|
||||
const once = scopeCss(':root { --brand: red; }', SCOPE);
|
||||
const twice = scopeCss(once, SCOPE);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
|
||||
test('a comma list is not re-prefixed on a second pass', () => {
|
||||
const once = scopeCss('h1, h2 > p { margin: 0; }', SCOPE);
|
||||
const twice = scopeCss(once, SCOPE);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
|
||||
test('a rule inside @media is not re-prefixed on a second pass', () => {
|
||||
const once = scopeCss('@media (min-width: 600px) { h1 { color: red; } }', SCOPE);
|
||||
const twice = scopeCss(once, SCOPE);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- misc/edge cases', () => {
|
||||
test('empty input returns empty string', () => {
|
||||
expect(scopeCss('', SCOPE)).toBe('');
|
||||
});
|
||||
|
||||
test('whitespace-only input round-trips without throwing', () => {
|
||||
expect(() => scopeCss(' \n ', SCOPE)).not.toThrow();
|
||||
});
|
||||
|
||||
test('a relative selector starting with a combinator is scoped as a descendant of the wrapper', () => {
|
||||
// ">h1" is unusual outside CSS nesting but should not crash the scanner.
|
||||
const out = scopeCss('> h1 { color: red; }', SCOPE);
|
||||
expect(out).toBe(`${SCOPE} > h1 { color: red; }`);
|
||||
});
|
||||
|
||||
test('an unknown braced at-rule (e.g. @page) is left untouched', () => {
|
||||
const input = '@page { margin: 1in; }';
|
||||
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
|
||||
'',
|
||||
' ',
|
||||
' | ||||