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