Site builder: security & data-loss hardening + unified asset picker + audit backlog #3

Merged
jknapp merged 61 commits from builder-hardening-2026-07 into main 2026-07-13 01:13:28 +00:00
8 changed files with 149 additions and 15 deletions
Showing only changes of commit a036843728 - Show all commits
@@ -6,12 +6,12 @@ const toHtml = (ContactForm as any).toHtml;
describe('ContactForm.toHtml relay wiring', () => { describe('ContactForm.toHtml relay wiring', () => {
test('with recipientEmail: emits marker, placeholder action, honeypot', () => { test('with recipientEmail: emits marker, placeholder action, honeypot', () => {
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, ''); const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '');
expect(html).toMatch(/<!--WHP-FORM id="F[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/); expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
expect(html).toMatch(/action="__WHP_FORM_ACTION__F[0-9a-z]+__"/); expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
expect(html).toContain('method="POST"'); expect(html).toContain('method="POST"');
expect(html).toContain('name="_gotcha"'); expect(html).toContain('name="_gotcha"');
// marker id and action id match // marker id and action id match
const mid = html.match(/id="(F[0-9a-z]+)"/)![1]; const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`); expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
}); });
@@ -64,6 +64,28 @@ describe('ContactForm.toHtml successMessage', () => {
}); });
}); });
describe('ContactForm.toHtml relay marker deterministic + unique via node id (no Math.random)', () => {
test('same node id -> identical marker+placeholder ids across two calls', () => {
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
expect(html1).toBe(html2);
});
test('marker id always equals the placeholder id it pairs with', () => {
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
const mid = html.match(/<!--WHP-FORM id="([^"]+)"/)![1];
expect(html).toContain(`action="__WHP_FORM_ACTION__${mid}__"`);
});
test('two different node ids -> different fids', () => {
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf2');
const mid1 = html1.match(/<!--WHP-FORM id="([^"]+)"/)![1];
const mid2 = html2.match(/<!--WHP-FORM id="([^"]+)"/)![1];
expect(mid1).not.toBe(mid2);
});
});
describe('ContactForm.toHtml accessibility (F2.1)', () => { describe('ContactForm.toHtml accessibility (F2.1)', () => {
const fields = [ const fields = [
{ type: 'text' as const, label: 'Name', name: 'name', placeholder: 'Your name', required: true }, { type: 'text' as const, label: 'Name', name: 'name', placeholder: 'Your name', required: true },
+2 -2
View File
@@ -167,7 +167,7 @@ ContactForm.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => { (ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string, nodeId?: string) => {
const formStyle = cssPropsToString({ const formStyle = cssPropsToString({
padding: '32px', padding: '32px',
display: 'flex', display: 'flex',
@@ -212,7 +212,7 @@ ContactForm.craft = {
alignSelf: 'flex-start', alignSelf: 'flex-start',
}); });
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction); const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction, nodeId);
// The form-sender relay delivers success via a full-page 303 redirect // 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 // (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
@@ -6,14 +6,14 @@ const toHtml = (FormContainer as any).toHtml;
describe('FormContainer.toHtml relay wiring', () => { describe('FormContainer.toHtml relay wiring', () => {
test('with recipientEmail: marker + placeholder action + honeypot, forces POST', () => { test('with recipientEmail: marker + placeholder action + honeypot, forces POST', () => {
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', method: 'GET' }, '<input name="email">'); const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', method: 'GET' }, '<input name="email">');
expect(html).toMatch(/<!--WHP-FORM id="F[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/); expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
expect(html).toMatch(/action="__WHP_FORM_ACTION__F[0-9a-z]+__"/); expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
expect(html).toContain('method="POST"'); // relay forces POST even though method=GET expect(html).toContain('method="POST"'); // relay forces POST even though method=GET
expect(html).toContain('name="_gotcha"'); expect(html).toContain('name="_gotcha"');
// honeypot precedes the form's children // honeypot precedes the form's children
expect(html.indexOf('_gotcha')).toBeLessThan(html.indexOf('name="email"')); expect(html.indexOf('_gotcha')).toBeLessThan(html.indexOf('name="email"'));
// marker id === action id // marker id === action id
const mid = html.match(/id="(F[0-9a-z]+)"/)![1]; const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`); expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
}); });
@@ -24,4 +24,18 @@ describe('FormContainer.toHtml relay wiring', () => {
expect(html).toContain('action="/legacy"'); expect(html).toContain('action="/legacy"');
expect(html).toContain('<input name="email">'); expect(html).toContain('<input name="email">');
}); });
test('same node id -> identical marker+placeholder ids across two calls', () => {
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
expect(html1).toBe(html2);
});
test('two different node ids -> different fids', () => {
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc2');
const mid1 = html1.match(/<!--WHP-FORM id="([^"]+)"/)![1];
const mid2 = html2.match(/<!--WHP-FORM id="([^"]+)"/)![1];
expect(mid1).not.toBe(mid2);
});
}); });
+2 -2
View File
@@ -68,12 +68,12 @@ FormContainer.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(FormContainer as any).toHtml = (props: FormContainerProps, childrenHtml: string) => { (FormContainer as any).toHtml = (props: FormContainerProps, childrenHtml: string, nodeId?: string) => {
const styleStr = cssPropsToString({ const styleStr = cssPropsToString({
padding: '24px', padding: '24px',
...props.style, ...props.style,
}); });
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action); const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId);
const method = useRelay ? 'POST' : (props.method || 'POST'); // relay requires POST const method = useRelay ? 'POST' : (props.method || 'POST'); // relay requires POST
const body = honeypot + childrenHtml; // honeypot as first child const body = honeypot + childrenHtml; // honeypot as first child
return { return {
@@ -0,0 +1,49 @@
import { describe, test, expect } from 'vitest';
import { NumberCounter } from './NumberCounter';
const toHtml = (NumberCounter as any).toHtml;
const counters = [
{ number: 150, suffix: '+', label: 'Projects' },
{ number: 50, suffix: '+', label: 'Clients' },
];
describe('NumberCounter.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
test('same node id -> identical output across calls (deterministic)', () => {
const { html: html1 } = toHtml({ counters }, '', 'node-nc1');
const { html: html2 } = toHtml({ counters }, '', 'node-nc1');
expect(html1).toBe(html2);
});
test('different node ids -> distinct, non-colliding nc_ scopes (identical props, no collision)', () => {
const { html: html1 } = toHtml({ counters }, '', 'node-nc1');
const { html: html2 } = toHtml({ counters }, '', 'node-nc2');
const wrapId1 = html1.match(/<div id="(nc_[^"]+)"/)![1];
const wrapId2 = html2.match(/<div id="(nc_[^"]+)"/)![1];
expect(wrapId1).not.toBe(wrapId2);
});
test('wrapper id, per-counter ids, and inline script agree on the same uid', () => {
const { html } = toHtml({ counters }, '', 'node-nc1');
const wrapId = html.match(/<div id="(nc_[^"]+)"/)![1];
expect(html).toContain(`id="${wrapId}_n0"`);
expect(html).toContain(`id="${wrapId}_n1"`);
expect(html).toContain(`var uid="${wrapId}"`);
expect(html).toContain('document.getElementById(uid)');
expect(html).toContain('document.getElementById(uid+"_n"+i)');
});
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
const { html: html1 } = toHtml({ counters }, '');
const { html: html2 } = toHtml({ counters }, '');
expect(html1).toBe(html2);
});
test('two different node ids never collide even with default (no counters override) props', () => {
const { html: html1 } = toHtml({}, '', 'node-a');
const { html: html2 } = toHtml({}, '', 'node-b');
const wrapId1 = html1.match(/<div id="(nc_[^"]+)"/)![1];
const wrapId2 = html2.match(/<div id="(nc_[^"]+)"/)![1];
expect(wrapId1).not.toBe(wrapId2);
});
});
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
interface Counter { interface Counter {
number: number; number: number;
@@ -109,7 +109,7 @@ NumberCounter.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string) => { (NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => {
const { const {
counters = defaultCounters, counters = defaultCounters,
columns = 4, columns = 4,
@@ -120,7 +120,13 @@ NumberCounter.craft = {
} = props; } = props;
const items = counters.length > 0 ? counters : defaultCounters; const items = counters.length > 0 ? counters : defaultCounters;
const uid = 'nc_' + Math.random().toString(36).slice(2, 8);
// Deterministic AND unique id for this counter instance's wrapper/span
// ids and getElementById() calls inside its inline script -- scoped on
// the Craft node id so two NumberCounter instances (e.g. both left at
// default props) don't collide and end up animating each other's digits.
const seed = items.map((c) => `${c.number}${c.suffix}::${c.label}`).join('|');
const uid = scopeId(nodeId, seed, 'nc');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
padding: '60px 20px', padding: '60px 20px',
+38
View File
@@ -0,0 +1,38 @@
import { describe, test, expect } from 'vitest';
import { relayFormWiring } from './form-relay-wiring';
describe('relayFormWiring deterministic + unique fid (thread node id, no Math.random)', () => {
test('no recipient: no relay, no marker, no fid needed', () => {
const wiring = relayFormWiring(undefined, undefined, '/legacy', 'node-1');
expect(wiring.useRelay).toBe(false);
expect(wiring.marker).toBe('');
});
test('same node id -> identical marker + placeholder ids across two calls (deterministic)', () => {
const w1 = relayFormWiring('a@b.com', '/thx', undefined, 'node-form1');
const w2 = relayFormWiring('a@b.com', '/thx', undefined, 'node-form1');
expect(w1.marker).toBe(w2.marker);
expect(w1.actionAttr).toBe(w2.actionAttr);
});
test('marker id and placeholder id always match each other', () => {
const w = relayFormWiring('a@b.com', '/thx', undefined, 'node-form1');
const mid = w.marker.match(/id="([^"]+)"/)![1];
expect(w.actionAttr).toBe(`__WHP_FORM_ACTION__${mid}__`);
});
test('different node ids -> different fids (no collision)', () => {
const w1 = relayFormWiring('a@b.com', '/thx', undefined, 'node-form1');
const w2 = relayFormWiring('a@b.com', '/thx', undefined, 'node-form2');
const mid1 = w1.marker.match(/id="([^"]+)"/)![1];
const mid2 = w2.marker.match(/id="([^"]+)"/)![1];
expect(mid1).not.toBe(mid2);
});
test('no nodeId (legacy call): still deterministic across repeated calls, not random', () => {
const w1 = relayFormWiring('a@b.com', '/thx', undefined);
const w2 = relayFormWiring('a@b.com', '/thx', undefined);
expect(w1.marker).toBe(w2.marker);
expect(w1.actionAttr).toBe(w2.actionAttr);
});
});
+7 -2
View File
@@ -9,7 +9,7 @@
* HTML — see PR #47 review). * HTML — see PR #47 review).
*/ */
import { escapeAttr, safeUrl } from './escape'; import { escapeAttr, safeUrl, scopeId } from './escape';
export interface RelayWiring { export interface RelayWiring {
/** true when a recipient is set (relay path); false = legacy formAction fallback */ /** true when a recipient is set (relay path); false = legacy formAction fallback */
@@ -26,16 +26,21 @@ export interface RelayWiring {
* @param recipientEmail the "Send submissions to" address (empty/undefined = no relay) * @param recipientEmail the "Send submissions to" address (empty/undefined = no relay)
* @param thankYouUrl optional post-submit redirect (blank = hosted thank-you page) * @param thankYouUrl optional post-submit redirect (blank = hosted thank-you page)
* @param fallbackAction the form's existing action to use when no recipient is set * @param fallbackAction the form's existing action to use when no recipient is set
* @param nodeId the Craft node id of the calling form component, used to scope
* 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).
*/ */
export function relayFormWiring( export function relayFormWiring(
recipientEmail: string | undefined, recipientEmail: string | undefined,
thankYouUrl: string | undefined, thankYouUrl: string | undefined,
fallbackAction: string | undefined, fallbackAction: string | undefined,
nodeId?: string,
): RelayWiring { ): RelayWiring {
if (!recipientEmail) { if (!recipientEmail) {
return { useRelay: false, marker: '', actionAttr: escapeAttr(safeUrl(fallbackAction || '#')), honeypot: '' }; return { useRelay: false, marker: '', actionAttr: escapeAttr(safeUrl(fallbackAction || '#')), honeypot: '' };
} }
const fid = 'F' + Math.random().toString(36).slice(2, 8); const fid = scopeId(nodeId, `${recipientEmail}::${thankYouUrl || ''}::${fallbackAction || ''}`, 'F');
return { return {
useRelay: true, useRelay: true,
marker: `<!--WHP-FORM id="${fid}" recipient="${escapeAttr(recipientEmail)}" thankyou="${escapeAttr(thankYouUrl || '')}"-->`, marker: `<!--WHP-FORM id="${fid}" recipient="${escapeAttr(recipientEmail)}" thankyou="${escapeAttr(thankYouUrl || '')}"-->`,