Merge PR #27: webhook destination controls for contact forms
Adds the webhook destination option to the contact form. Legacy markers stay byte-identical; the raw secret never reaches a Craft prop. Pairs with the whp-side branch of the same name.
This commit was merged in pull request #27.
This commit is contained in:
@@ -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.' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user