diff --git a/craft/src/components/forms/ContactForm.toHtml.test.ts b/craft/src/components/forms/ContactForm.toHtml.test.ts index d29b243..e72c4aa 100644 --- a/craft/src/components/forms/ContactForm.toHtml.test.ts +++ b/craft/src/components/forms/ContactForm.toHtml.test.ts @@ -196,3 +196,83 @@ 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('
{ + 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"'); + }); +}); diff --git a/craft/src/components/forms/ContactForm.tsx b/craft/src/components/forms/ContactForm.tsx index 2e410f6..f525efa 100644 --- a/craft/src/components/forms/ContactForm.tsx +++ b/craft/src/components/forms/ContactForm.tsx @@ -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 diff --git a/craft/src/panels/right/styles/FormStylePanel.tsx b/craft/src/panels/right/styles/FormStylePanel.tsx index bafa26c..8122c86 100644 --- a/craft/src/panels/right/styles/FormStylePanel.tsx +++ b/craft/src/panels/right/styles/FormStylePanel.tsx @@ -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,87 @@ 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' }; + +/* ---------- 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 / remove, never "view". */ +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 ( +
+ + { 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 && ( +
+ Secret stored + +
+ )} + {status === 'saving' &&

Storing…

} + {status === 'saved' &&

Secret stored.

} + {status === 'error' &&

{error}

} +

+ 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. +

+
+ ); +}; + /* ---------- FORM ---------- */ export const FormStylePanel: React.FC = ({ 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) => { actions.setProp(selectedId, (props: any) => { @@ -158,14 +234,77 @@ export const FormStylePanel: React.FC = ({ selectedId, nodeProp )} + {/* 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 && ( +
+ +
+ {[{ v: 'email', l: 'Email' }, { v: 'webhook', l: 'Webhook' }].map((o) => ( + + ))} +
+
+ )} + + {nodeProps.destinationType !== undefined && isWebhook && ( + <> +
+ + setProp('webhookUrl', e.target.value)} + placeholder="https://hooks.example.com/..." + style={inputStyle} + /> +

+ Must be an absolute https:// URL. Each submission is POSTed as JSON; + failures are retried, then emailed to the fallback address below. +

+
+
+ + +
+ 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 && (
- + setProp('recipientEmail', e.target.value)} placeholder="you@example.com" style={inputStyle} /> -

- 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. +

+ {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."}

)} diff --git a/craft/src/panels/right/styles/FormStylePanel.webhook.test.tsx b/craft/src/panels/right/styles/FormStylePanel.webhook.test.tsx new file mode 100644 index 0000000..c3fde75 --- /dev/null +++ b/craft/src/panels/right/styles/FormStylePanel.webhook.test.tsx @@ -0,0 +1,222 @@ +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 } 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 = {}) { + 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(); + expect(testid('webhook-url')).toBeNull(); + + click(testid('destination-webhook')); + expect(lastProps.destinationType).toBe('webhook'); + + rerender(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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 Remove action', () => { + lastProps = contactFormProps({ destinationType: 'webhook', webhookSecretId: 'whs_42_deadbeefdeadbeef' }); + render(); + 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(); + await blur(testid('webhook-secret-input')!); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/craft/src/utils/form-relay-wiring.test.ts b/craft/src/utils/form-relay-wiring.test.ts index eea770c..91b8516 100644 --- a/craft/src/utils/form-relay-wiring.test.ts +++ b/craft/src/utils/form-relay-wiring.test.ts @@ -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 = + /^$/; + +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(/^$/); + }); + + 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"bd\'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"'); + }); +}); diff --git a/craft/src/utils/form-relay-wiring.ts b/craft/src/utils/form-relay-wiring.ts index d37ea3f..98c706f 100644 --- a/craft/src/utils/form-relay-wiring.ts +++ b/craft/src/utils/form-relay-wiring.ts @@ -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(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: + * + * // + * + * (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: ``, + marker: ``, actionAttr: `__WHP_FORM_ACTION__${fid}__`, honeypot: ``, }; diff --git a/craft/src/utils/form-webhook-secret.test.ts b/craft/src/utils/form-webhook-secret.test.ts new file mode 100644 index 0000000..ec4ae08 --- /dev/null +++ b/craft/src/utils/form-webhook-secret.test.ts @@ -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(); + }); +}); diff --git a/craft/src/utils/form-webhook-secret.ts b/craft/src/utils/form-webhook-secret.ts new file mode 100644 index 0000000..2de007f --- /dev/null +++ b/craft/src/utils/form-webhook-secret.ts @@ -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__`. */ + 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 { + 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.' }; + } +}