diff --git a/craft/src/sitesmith/clean-ai-response.test.ts b/craft/src/sitesmith/clean-ai-response.test.ts
new file mode 100644
index 0000000..bb4b466
--- /dev/null
+++ b/craft/src/sitesmith/clean-ai-response.test.ts
@@ -0,0 +1,127 @@
+import { describe, test, expect } from 'vitest';
+import { allowUrl, cssValueAllowed, cleanAiProps, cleanAiResponse } from './clean-ai-response';
+
+describe('allowUrl: link props', () => {
+ test.each(['', '#about', '/contact', 'about.html', 'mailto:hello@example.com', 'tel:+1 555 0100', 'https://example.com/a?b=1', 'http://example.com/'])('allows %s', (v) => {
+ expect(allowUrl('link', v)).toBe(v);
+ });
+ test.each(['javascript:alert(1)', 'data:text/html,hi', 'vbscript:x', 'file:///etc/passwd', '//example.com/x', 'ftp://example.com/', 'https://user:pw@example.com/', 'a b', '#a"b'])('rejects %s', (v) => {
+ expect(allowUrl('link', v)).toBeNull();
+ });
+});
+
+describe('allowUrl: tricky link forms', () => {
+ test.each([
+ ['protocol-relative', '//example.com/path'],
+ ['protocol-relative after a slash and backslash', '/\\example.com/path'],
+ ['backslash form', '\\\\example.com\\path'],
+ ['leading space', ' //example.com'.slice(0) + ' x'],
+ ['tab inside', 'java\tscript:x'],
+ ['newline inside', '/a\nb'],
+ ['NUL', '/a\u0000b'],
+ ['mixed-case scheme', 'JavaScript:x'],
+ ['upper-case data scheme', 'DATA:text/plain,x'],
+ ['over-long', '/' + 'a'.repeat(2100)],
+ ['quote', '/a"b'],
+ ])('rejects %s', (_label, v) => {
+ expect(allowUrl('link', v)).toBeNull();
+ });
+ test('a single leading slash is relative; mixed-case https is still https', () => {
+ expect(allowUrl('link', '/about')).toBe('/about');
+ expect(allowUrl('link', 'HTTPS://example.com/')).toBe('HTTPS://example.com/');
+ });
+ test('the image and video rules reject the same tricky forms', () => {
+ for (const v of ['//images.pexels.com/a.jpg', 'HTTPS://example.com/a.jpg', 'assets/a\\b.jpg', 'assets/' + 'a'.repeat(250)]) {
+ expect(allowUrl('image', v), v).toBeNull();
+ expect(allowUrl('video', v), v).toBeNull();
+ }
+ });
+});
+
+describe('allowUrl: image and video props', () => {
+ test('images: own assets and the supported stock/placeholder hosts only', () => {
+ expect(allowUrl('image', 'assets/hero.jpg')).toBe('assets/hero.jpg');
+ expect(allowUrl('image', 'https://images.pexels.com/photos/1/a.jpeg')).toBe('https://images.pexels.com/photos/1/a.jpeg');
+ expect(allowUrl('image', 'https://placehold.co/600x400?text=Hero')).toBe('https://placehold.co/600x400?text=Hero');
+ for (const bad of ['https://example.com/a.png', 'http://images.pexels.com/a.jpg', 'data:image/png;base64,AAAA', '/api/assets-proxy/?site_id=1&filename=a.jpg', 'assets/../x.jpg', 'images/a.jpg']) {
+ expect(allowUrl('image', bad), bad).toBeNull();
+ }
+ });
+ test('videos: own assets or YouTube/Vimeo', () => {
+ expect(allowUrl('video', 'https://www.youtube.com/watch?v=abc')).not.toBeNull();
+ expect(allowUrl('video', 'https://vimeo.com/123')).not.toBeNull();
+ expect(allowUrl('video', 'assets/clip.mp4')).toBe('assets/clip.mp4');
+ expect(allowUrl('video', 'https://example.com/clip.mp4')).toBeNull();
+ });
+});
+
+describe('cssValueAllowed', () => {
+ test.each(['#112233', 'var(--wsb-primary)', 'linear-gradient(135deg, #111, #222)', '24px', 'color-mix(in srgb, var(--wsb-primary) 10%, transparent)', 'url(assets/bg.jpg)', "url('assets/bg.jpg')", 0])('allows %s', (v) => {
+ expect(cssValueAllowed(v)).toBe(true);
+ });
+ test.each(['url(https://example.com/x.png)', 'url(javascript:x)', '@import "x.css"', 'expression(alert(1))', 'red; color: blue', 'red}', '', 'behavior: url(x.htc)'])('rejects %s', (v) => {
+ expect(cssValueAllowed(v)).toBe(false);
+ });
+});
+
+describe('cleanAiProps', () => {
+ test('keeps ordinary props unchanged', () => {
+ const p = { text: 'Book a table', href: '#contact', style: { color: 'var(--wsb-text)', padding: '14px 28px' }, anchorId: 'menu' };
+ expect(cleanAiProps(p, 'ButtonLink')).toEqual(p);
+ });
+ test('drops disallowed URLs, CSS and ids; keeps the rest', () => {
+ const out = cleanAiProps({
+ text: 'Hi', href: 'javascript:alert(1)', anchorId: 'about us',
+ style: { color: 'red', background: 'url(https://example.com/x.png)', 'bad key': 'x', behavior: 'x' },
+ }, 'ButtonLink');
+ expect(out).toEqual({ text: 'Hi', style: { color: 'red' } });
+ });
+ test('nested arrays: links, images, slides', () => {
+ expect(cleanAiProps({ links: [{ text: 'A', href: '/a' }, { text: 'B', href: 'data:text/html,x' }] }, 'Navbar'))
+ .toEqual({ links: [{ text: 'A', href: '/a' }, { text: 'B' }] });
+ expect(cleanAiProps({ images: [{ src: 'assets/a.jpg', alt: 'a' }, { src: 'https://example.com/b.jpg', alt: 'b' }, 'assets/c.jpg', 'https://example.com/d.jpg'] }, 'Gallery'))
+ .toEqual({ images: [{ src: 'assets/a.jpg', alt: 'a' }, { alt: 'b' }, 'assets/c.jpg'] });
+ });
+ test('the model may not set form destinations, relay settings or head code', () => {
+ const out = cleanAiProps({ recipientEmail: 'x@example.com', webhookUrl: 'https://example.com/h', destinationType: 'webhook', thankYouUrl: 'https://example.com/', action: 'https://example.com/', headCode: '', heading: 'Contact us' }, 'ContactForm');
+ expect(out).toEqual({ heading: 'Contact us' });
+ });
+ test('event-handler-like and non-identifier prop names are dropped', () => {
+ expect(cleanAiProps({ onClick: 'x', 'data-x': 'y', text: 't' }, 'TextBlock')).toEqual({ text: 't' });
+ });
+ test('VideoBlock url, CallToAction image bgValue, icon class', () => {
+ expect(cleanAiProps({ videoUrl: 'https://example.com/v.mp4', poster: 'assets/p.jpg' }, 'VideoBlock')).toEqual({ poster: 'assets/p.jpg' });
+ expect(cleanAiProps({ bgType: 'image', bgValue: 'https://example.com/b.jpg' }, 'CallToAction')).toEqual({ bgType: 'image' });
+ expect(cleanAiProps({ bgType: 'gradient', bgValue: 'linear-gradient(90deg, #000, #fff)' }, 'CallToAction')).toEqual({ bgType: 'gradient', bgValue: 'linear-gradient(90deg, #000, #fff)' });
+ expect(cleanAiProps({ icon: 'fa-solid fa-star' }, 'Icon')).toEqual({ icon: 'fa-solid fa-star' });
+ expect(cleanAiProps({ icon: 'fa-star" x="y' }, 'Icon')).toEqual({});
+ });
+ test('HtmlBlock code passes through to its own sanitisers unchanged', () => {
+ expect(cleanAiProps({ code: '
Hello
' }, 'HtmlBlock')).toEqual({ code: 'Hello
' });
+ });
+});
+
+describe('cleanAiResponse', () => {
+ test('replace: every tree (pages, header, footer) is cleaned; input not mutated', () => {
+ const resp = {
+ type: 'replace' as const, scope: 'site' as const, message: 'm',
+ header: { tree: { type: { resolvedName: 'Navbar' }, props: { logoUrl: 'javascript:x', logoText: 'Brand' } } },
+ pages: [{ name: 'Home', tree: { type: { resolvedName: 'Container' }, props: {}, nodes: [{ type: { resolvedName: 'ImageBlock' }, props: { src: 'https://example.com/a.png', alt: 'a' } }] } }],
+ };
+ const snap = JSON.stringify(resp);
+ const out = cleanAiResponse(resp);
+ expect(JSON.stringify(resp)).toBe(snap);
+ if (out.type !== 'replace') throw new Error('type');
+ expect(out.header!.tree.props).toEqual({ logoText: 'Brand' });
+ expect(out.pages[0].tree.nodes![0].props).toEqual({ alt: 'a' });
+ });
+ test('patch: update_props cannot change the form destination (the user value stays)', () => {
+ const out = cleanAiResponse({ type: 'patch', message: 'm', ops: [{ op: 'update_props', node_id: 'f1', props: { recipientEmail: 'x@example.com', heading: 'Write to us' } }] });
+ if (out.type !== 'patch') throw new Error('type');
+ expect(out.ops[0]).toEqual({ op: 'update_props', node_id: 'f1', props: { heading: 'Write to us' } });
+ });
+ test('ask passes through', () => {
+ const a = { type: 'ask' as const, question: 'Which colours?' };
+ expect(cleanAiResponse(a)).toBe(a);
+ });
+});
diff --git a/craft/src/sitesmith/clean-ai-response.ts b/craft/src/sitesmith/clean-ai-response.ts
new file mode 100644
index 0000000..c5bc341
--- /dev/null
+++ b/craft/src/sitesmith/clean-ai-response.ts
@@ -0,0 +1,204 @@
+/**
+ * Allow-list cleaner for Sitesmith (AI) responses, run before anything the model returned reaches
+ * the canvas (utils/apply-ai-response.ts and the regenerate-section path in utils/sitesmith-profile.ts).
+ * Server-side mirror not implemented (deferred); the browser cleaner + export-boundary stripping
+ * (utils/escape.ts stripLegacyCssVectors) are the layers in place. See WHP docs/sitesmith-backend.md
+ * "Known gap / follow-up".
+ *
+ * Rules (by prop NAME, recursively through nested arrays/objects such as links[], ctas[], images[]):
+ * - link props (href, link, url, *Href, *Url, logoUrl, ...): '' | '#anchor' | relative path |
+ * mailto: | tel: | https: | http: -- anything else is dropped.
+ * - image props (src, image, imageSrc, logoImage, poster, bgImage, avatar, photo, images[], ...):
+ * the site's own `assets/` or https on AI_IMAGE_HOSTS -- anything else is dropped.
+ * - video props (videoUrl, bgVideo, and VideoBlock url/src): `assets/` or https on the
+ * YouTube/Vimeo hosts VideoBlock embeds -- anything else is dropped.
+ * - form destinations (recipient email, webhook, relay settings, form action, thank-you URL) and
+ * head code: the model may not set or change them -- the props are removed from the response,
+ * so a patch leaves the user's existing values alone and a new form starts with its defaults.
+ * - style objects (style, *Style) and colour/background props: property NAMES must be plain CSS
+ * identifiers; VALUES must not contain url() (except `assets/`), @import, expression(),
+ * behavior, -moz-binding, javascript:, or any of < > { } ; \ -- such a value is dropped.
+ * - anchor ids (anchorId, id, htmlId): [A-Za-z0-9_-]{1,64}, else dropped. Icon class: [A-Za-z0-9 _-].
+ * - prop names must be identifiers; event-handler-like names (onClick, ...) are dropped.
+ * - HtmlBlock.code keeps its existing sanitisers (server sitesmith_sanitize_html + DOMPurify in
+ * the editor and at export); it is not changed here.
+ * Pure function: never mutates its input.
+ */
+import type { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith';
+
+/** Image hosts model output may reference: the stock provider the builder imports from
+ * (SiteBuilderStock::IMAGE_HOST) and the placeholder host the Sitesmith prompt prescribes. */
+export const AI_IMAGE_HOSTS: readonly string[] = ['images.pexels.com', 'placehold.co'];
+/** Video hosts VideoBlock turns into embeds. */
+export const AI_VIDEO_HOSTS: readonly string[] = ['youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be', 'www.youtube-nocookie.com', 'vimeo.com', 'www.vimeo.com', 'player.vimeo.com'];
+
+/** Props the model may never set (form destinations / relay targets / head code). */
+export const AI_FORBIDDEN_PROPS: ReadonlySet = new Set([
+ 'recipientEmail', 'webhookUrl', 'webhookSecretId', 'webhookAuthMode', 'destinationType',
+ 'action', 'formAction', 'method', 'thankYouUrl', 'captcha',
+ 'headCode', 'customHead', 'customHeadCode', 'script', 'scripts', 'dangerouslySetInnerHTML',
+]);
+
+const IMAGE_KEYS = new Set(['src', 'image', 'imageSrc', 'imageUrl', 'logoImage', 'poster', 'bgImage', 'backgroundImageUrl', 'avatar', 'avatarUrl', 'photo', 'thumbnail', 'images']);
+const VIDEO_KEYS = new Set(['videoUrl', 'bgVideo', 'embedUrl']);
+const LINK_KEYS = new Set(['href', 'link', 'url', 'logoUrl', 'buttonHref', 'buttonUrl', 'secondaryButtonHref', 'allReviewsUrl', 'directionsUrl']);
+const ID_KEYS = new Set(['anchorId', 'id', 'htmlId']);
+const CSS_VALUE_KEY = /(color|colour|bg|background|gradientfrom|gradientto|bgvalue|shadow)$/i;
+const STYLE_KEY = /^(style|[a-z][A-Za-z]*Style)$/;
+const PROP_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
+const CSS_PROP_NAME = /^(--[A-Za-z0-9-]{1,60}|-?[A-Za-z][A-Za-z-]{0,60})$/;
+const ASSET = /^assets\/[A-Za-z0-9._%~+-]{1,200}$/;
+
+type UrlKind = 'link' | 'image' | 'video';
+
+function httpsHost(v: string, allowHttp: boolean): string | null {
+ let u: URL;
+ try { u = new URL(v); } catch { return null; }
+ if (u.protocol !== 'https:' && !(allowHttp && u.protocol === 'http:')) return null;
+ if (u.username || u.password) return null;
+ return u.hostname.toLowerCase();
+}
+
+/** The value if it is allowed for this kind of URL prop, else null (= drop the prop). */
+export function allowUrl(kind: UrlKind, raw: unknown): string | null {
+ if (typeof raw !== 'string') return null;
+ const v = raw.trim();
+ if (v === '') return '';
+ if (kind === 'link' && /^tel:\+?[0-9 ().-]{3,32}$/i.test(v)) return v;
+ if (v.length > 2048 || /[\u0000-\u0020<>"'`\\\u007f]/.test(v)) return null;
+ if (kind === 'image') {
+ if (ASSET.test(v)) return v;
+ const h = httpsHost(v, false);
+ return h !== null && AI_IMAGE_HOSTS.includes(h) ? v : null;
+ }
+ if (kind === 'video') {
+ if (ASSET.test(v)) return v;
+ const h = httpsHost(v, false);
+ return h !== null && AI_VIDEO_HOSTS.includes(h) ? v : null;
+ }
+ // link
+ if (/^#[A-Za-z0-9_-]{0,64}$/.test(v)) return v;
+ if (/^mailto:[^\s:/?#][^\s]*$/i.test(v)) return v;
+ if (/^(https?):/i.test(v)) return httpsHost(v, true) !== null ? v : null;
+ // relative: no scheme (no ':' before the first / ? #) and not protocol-relative
+ if (v.startsWith('//')) return null;
+ const head = v.split(/[/?#]/, 1)[0];
+ return head.includes(':') ? null : v;
+}
+
+/** True when a CSS value is on the allow-list of value shapes. */
+export function cssValueAllowed(raw: unknown): boolean {
+ if (typeof raw === 'number') return Number.isFinite(raw);
+ if (typeof raw !== 'string') return false;
+ const v = raw;
+ if (v.length > 400 || /[<>{};\\\u0000-\u001f]/.test(v)) return false;
+ if (/@import|expression\s*\(|behavior|-moz-binding|javascript:|vbscript:/i.test(v)) return false;
+ for (const m of v.matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)/gi)) if (!ASSET.test(m[2].trim())) return false;
+ if (/url\(/i.test(v.replace(/url\(\s*(['"]?)(.*?)\1\s*\)/gi, ''))) return false;
+ return true;
+}
+
+function cleanStyle(style: unknown): Record | undefined {
+ if (!style || typeof style !== 'object' || Array.isArray(style)) return undefined;
+ const out: Record = {};
+ for (const [k, v] of Object.entries(style as Record)) {
+ if (!CSS_PROP_NAME.test(k) || /behavior|binding/i.test(k)) continue;
+ if (cssValueAllowed(v)) out[k] = v;
+ }
+ return out;
+}
+
+function urlKindFor(key: string, component: string | undefined): UrlKind | null {
+ if (component === 'VideoBlock' && (key === 'url' || key === 'src')) return 'video';
+ if (VIDEO_KEYS.has(key)) return 'video';
+ if (IMAGE_KEYS.has(key)) return 'image';
+ if (LINK_KEYS.has(key) || /(Href|Url|Link)$/.test(key)) return 'link';
+ return null;
+}
+
+/** Clean one value under `key`; returns undefined to drop it. */
+function cleanValue(key: string, value: unknown, component: string | undefined, depth: number): unknown {
+ if (depth > 8) return undefined;
+ if (STYLE_KEY.test(key)) return cleanStyle(value);
+ const kind = urlKindFor(key, component);
+ if (kind) {
+ if (Array.isArray(value)) {
+ // e.g. images: ['assets/a.jpg', ...] or [{src, alt}, ...]
+ return value.map((item) => (typeof item === 'string' ? allowUrl(kind, item) : cleanObject(item, component, depth + 1)))
+ .filter((x) => x !== null && x !== undefined);
+ }
+ if (value && typeof value === 'object') return cleanObject(value, component, depth + 1);
+ const ok = allowUrl(kind, value);
+ return ok === null ? undefined : ok;
+ }
+ // CallToAction.bgValue is a colour/gradient, or an image URL when bgType is 'image'.
+ if (key === 'bgValue' && typeof value === 'string' && /^\s*([a-z][a-z0-9+.-]*:|\/|assets\/)/i.test(value)) {
+ const ok = allowUrl('image', value);
+ return ok === null ? undefined : ok;
+ }
+ if (ID_KEYS.has(key)) return typeof value === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(value) ? value : undefined;
+ if (key === 'icon') return typeof value === 'string' && /^[A-Za-z0-9 _-]{0,80}$/.test(value) ? value : undefined;
+ if (typeof value === 'string' && CSS_VALUE_KEY.test(key)) return cssValueAllowed(value) ? value : undefined;
+ if (Array.isArray(value)) {
+ return value.map((item) => (item && typeof item === 'object' ? cleanObject(item, component, depth + 1) : item))
+ .filter((x) => x !== undefined);
+ }
+ if (value && typeof value === 'object') return cleanObject(value, component, depth + 1);
+ return value;
+}
+
+function cleanObject(obj: unknown, component: string | undefined, depth: number): Record | undefined {
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return undefined;
+ const out: Record = {};
+ for (const [k, v] of Object.entries(obj as Record)) {
+ if (!PROP_NAME.test(k) || /^on[A-Z]/.test(k) || AI_FORBIDDEN_PROPS.has(k)) continue;
+ const c = cleanValue(k, v, component, depth);
+ if (c !== undefined) out[k] = c;
+ }
+ return out;
+}
+
+/** Clean the props of a node of type `component` (or of an update_props op, component unknown). */
+export function cleanAiProps(props: unknown, component?: string): Record {
+ const p = (props && typeof props === 'object' && !Array.isArray(props)) ? (props as Record) : {};
+ const out = cleanObject(p, component, 0) ?? {};
+ // HtmlBlock.code: unchanged here (sanitised server-side and by DOMPurify in the editor/export).
+ if (component === 'HtmlBlock' && typeof p.code === 'string') out.code = p.code;
+ return out;
+}
+
+function cleanTree(tree: SerializedTreeNode): SerializedTreeNode {
+ const name = tree?.type?.resolvedName;
+ const node: SerializedTreeNode = { type: tree.type, props: cleanAiProps(tree.props, name) as SerializedTreeNode['props'] };
+ if (Array.isArray(tree.nodes)) node.nodes = tree.nodes.filter((n) => n && typeof n === 'object').map(cleanTree);
+ return node;
+}
+
+function cleanOp(op: SitesmithPatchOp): SitesmithPatchOp {
+ switch (op.op) {
+ case 'update_props': {
+ // Component unknown here; HtmlBlock code in a patch keeps its server-side sanitising.
+ const props = cleanAiProps(op.props);
+ if (op.props && typeof (op.props as Record).code === 'string') props.code = (op.props as Record).code;
+ return { ...op, props };
+ }
+ case 'replace_node': case 'insert_after': case 'insert_before':
+ return { ...op, tree: cleanTree(op.tree) };
+ default:
+ return op;
+ }
+}
+
+/** The whole response, cleaned. 'ask' passes through (it is only shown as text). */
+export function cleanAiResponse(resp: SitesmithResponse): SitesmithResponse {
+ if (resp.type === 'replace') {
+ return {
+ ...resp,
+ pages: resp.pages.map((p) => ({ ...p, tree: cleanTree(p.tree) })),
+ ...(resp.header ? { header: { tree: cleanTree(resp.header.tree) } } : {}),
+ ...(resp.footer ? { footer: { tree: cleanTree(resp.footer.tree) } } : {}),
+ };
+ }
+ if (resp.type === 'patch') return { ...resp, ops: Array.isArray(resp.ops) ? resp.ops.map(cleanOp) : [] };
+ return resp;
+}
diff --git a/craft/src/test-utils/integration/ai-apply-response.integration.test.tsx b/craft/src/test-utils/integration/ai-apply-response.integration.test.tsx
index 2e7f1d2..ed24511 100644
--- a/craft/src/test-utils/integration/ai-apply-response.integration.test.tsx
+++ b/craft/src/test-utils/integration/ai-apply-response.integration.test.tsx
@@ -183,3 +183,41 @@ describe('AI apply-response (real @craftjs/core editor)', () => {
expect(harness.container.textContent).toContain('Anchor Heading');
});
});
+
+describe('AI apply-response: the allow-list cleaner runs before anything reaches the canvas (G8)', () => {
+ test('a section with a disallowed link and an off-list image lands without those props', async () => {
+ harness = renderEditorHarness({ initialState: INITIAL_STATE });
+ const applyRef = mountApplyFn(harness);
+ const resp: SitesmithResponse = {
+ type: 'replace', scope: 'section', message: 'm',
+ pages: [{ name: 'section', tree: { type: { resolvedName: 'Container' }, props: { tag: 'div' }, nodes: [
+ { type: { resolvedName: 'ButtonLink' }, props: { text: 'Cleaned Button', href: 'javascript:void(0)' } },
+ { type: { resolvedName: 'ImageBlock' }, props: { src: 'https://example.com/a.png', alt: 'Cleaned Image' } },
+ ] } }],
+ };
+ await harness.act(async () => { await applyRef.current!(resp); });
+ const ser = JSON.parse(harness.getSerialized());
+ const props = Object.values(ser).map((n: any) => n.props);
+ const btn = props.find((p: any) => p.text === 'Cleaned Button') as any;
+ const img = props.find((p: any) => p.alt === 'Cleaned Image') as any;
+ expect(btn).toBeDefined();
+ expect(String(btn.href ?? '')).not.toContain('javascript');
+ expect(img).toBeDefined();
+ expect(String(img.src ?? '')).not.toContain('example.com');
+ });
+
+ test('update_props cannot change a form destination; other props still apply', async () => {
+ const state = JSON.parse(INITIAL_STATE);
+ state.ROOT.nodes.push('form-1');
+ state['form-1'] = { type: { resolvedName: 'ContactForm' }, isCanvas: false, props: { node_id: 'form-1', heading: 'Old', recipientEmail: 'owner@example.com' },
+ displayName: 'Contact Form', custom: {}, hidden: false, parent: 'ROOT', nodes: [], linkedNodes: {} };
+ harness = renderEditorHarness({ initialState: JSON.stringify(state) });
+ const applyRef = mountApplyFn(harness);
+ await harness.act(async () => {
+ await applyRef.current!({ type: 'patch', message: 'm', ops: [{ op: 'update_props', node_id: 'form-1', props: { heading: 'New heading', recipientEmail: 'other@example.com' } }] });
+ });
+ const p = harness.query.node('form-1').get().data.props;
+ expect(p.heading).toBe('New heading');
+ expect(p.recipientEmail).toBe('owner@example.com');
+ });
+});
diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts
index 5a4eec7..45f032c 100644
--- a/craft/src/utils/apply-ai-response.ts
+++ b/craft/src/utils/apply-ai-response.ts
@@ -3,6 +3,7 @@ import { insertTargetOutsideInstance, isInsideInstance } from './global-sections
import { usePages } from '../state/PageContext';
import { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith';
import { sanitizeAiTree, buildNodeTree } from './craft-tree';
+import { cleanAiResponse } from '../sitesmith/clean-ai-response';
import { useSiteDesign, type SiteDesign } from '../state/SiteDesignContext';
import { repairResponseContrast, type ContrastAdjustment, type PatchContext } from './contrast-repair';
@@ -92,6 +93,11 @@ export function useApplyAiResponse() {
// 'ask' type = AI wants clarification, nothing to apply
if (resp.type === 'ask') return { ok: true };
+ // G8: allow-list cleaner (URLs, images, CSS, ids; form destinations and head code can never be
+ // set by the model). Runs first, so nothing below -- contrast repair, the canvas -- sees a
+ // value the cleaner would refuse. (Server-side mirror not implemented, deferred.)
+ resp = cleanAiResponse(resp);
+
// Contrast auto-repair (design "Settled calls" 8): fix failing text colours before they reach the canvas.
let adjustments: ContrastAdjustment[] = [];
let notChecked = 0;
diff --git a/craft/src/utils/escape.test.ts b/craft/src/utils/escape.test.ts
index feba2ee..535019c 100644
--- a/craft/src/utils/escape.test.ts
+++ b/craft/src/utils/escape.test.ts
@@ -294,3 +294,20 @@ describe('sanitizeInputType', () => {
expect(sanitizeInputType(null as any)).toBe('text');
});
});
+
+describe('stripLegacyCssVectors / CSS sinks at export (G8)', () => {
+ test('ordinary values unchanged, attack-only constructs removed', async () => {
+ const { stripLegacyCssVectors, cssValue } = await import('./escape');
+ const { cssPropsToString } = await import('./style-helpers');
+ for (const ok of ['#fff', 'linear-gradient(90deg, #000, #fff)', 'var(--wsb-primary)', '0 4px 12px rgba(0,0,0,.1)']) {
+ expect(stripLegacyCssVectors(ok)).toBe(ok);
+ expect(cssValue(ok)).toBe(ok);
+ }
+ expect(cssValue('@import x')).not.toMatch(/@import/i);
+ expect(cssValue('expression(alert(1))')).not.toMatch(/expression\s*\(/i);
+ const s = cssPropsToString({ background: 'expression(alert(1))', color: 'red', MozBinding: 'x' } as any);
+ expect(s).not.toMatch(/expression\s*\(/i);
+ expect(s).toContain('color:red');
+ expect(cssPropsToString({ width: 'calc(100% - 2px)', backgroundImage: "url('assets/a.jpg')" } as any)).toBe("width:calc(100% - 2px);background-image:url('assets/a.jpg')");
+ });
+});
diff --git a/craft/src/utils/escape.ts b/craft/src/utils/escape.ts
index 5bf6707..4382400 100644
--- a/craft/src/utils/escape.ts
+++ b/craft/src/utils/escape.ts
@@ -128,6 +128,17 @@ function normalizeForSchemeCheck(trimmed: string): string {
.replace(/:/g, '');
}
+/**
+ * Export-boundary defence in depth (G8): removes CSS constructs that only ever serve an attack --
+ * `@import`, IE `expression(`, `behavior:` (HTC) and `-moz-binding` (XBL). No legitimate site
+ * value contains them, so output for ordinary content is byte-identical. The Sitesmith apply-time
+ * cleaner (sitesmith/clean-ai-response.ts) already refuses such values from the model.
+ */
+export function stripLegacyCssVectors(v: string): string {
+ if (!/@import|expression|behavior|binding/i.test(v)) return v;
+ return v.replace(/@import/gi, '').replace(/expression\s*\(/gi, '(').replace(/behavior\s*:/gi, '').replace(/-moz-binding/gi, '');
+}
+
/**
* Neutralizes CSS-context breakout for a single design-token value (color,
* length, gradient, etc.) so it is safe to interpolate RAW into either CSS
@@ -147,7 +158,7 @@ function normalizeForSchemeCheck(trimmed: string): string {
*/
export function cssValue(v: unknown): string {
if (typeof v !== 'string') return '';
- return v
+ return stripLegacyCssVectors(v)
.replace(/[<>{};"'\\]/g, '')
.replace(/url\s*\(/gi, '');
}
diff --git a/craft/src/utils/sitesmith-profile.ts b/craft/src/utils/sitesmith-profile.ts
index 99aea60..ee85db8 100644
--- a/craft/src/utils/sitesmith-profile.ts
+++ b/craft/src/utils/sitesmith-profile.ts
@@ -1,6 +1,7 @@
import type { SitesmithResponse, SerializedTreeNode } from '../types/sitesmith';
import { isInsideInstance } from './global-sections';
import { buildNodeTree } from './craft-tree';
+import { cleanAiResponse } from '../sitesmith/clean-ai-response';
/**
* Business profile gathered by the Sitesmith onboarding wizard. Stored with the
@@ -116,6 +117,7 @@ export function applyRegeneratedSection(
resp: SitesmithResponse,
targetId: string,
): { ok: boolean; message?: string } {
+ resp = cleanAiResponse(resp); // G8: same allow-list cleaner as useApplyAiResponse().apply
let tree: SerializedTreeNode | undefined;
if (resp.type === 'patch') {
const op = resp.ops.find((o) => o.op === 'replace_node');
diff --git a/craft/src/utils/style-helpers.ts b/craft/src/utils/style-helpers.ts
index 23bf30c..e4f71ea 100644
--- a/craft/src/utils/style-helpers.ts
+++ b/craft/src/utils/style-helpers.ts
@@ -1,5 +1,5 @@
import { CSSProperties } from 'react';
-import { escapeAttr, safeImageUrl } from './escape';
+import { escapeAttr, safeImageUrl, stripLegacyCssVectors } from './escape';
const camelToKebab = (str: string): string =>
str.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
@@ -18,7 +18,7 @@ const URL_RE = /url\(\s*(['"]?)([\s\S]*?)\1\s*\)/gi;
// data-URI syntax (`data:;base64,`), so this must never be
// applied there.
const sanitizeBreakoutChars = (s: string): string =>
- s.replace(/;/g, '').replace(/"/g, '"').replace(//g, '>');
+ stripLegacyCssVectors(s).replace(/;/g, '').replace(/"/g, '"').replace(//g, '>');
/**
* Sanitizes a single CSS declaration value so it can never terminate the