Sitesmith: allow-list cleaner for model responses before they reach the canvas; export strips attack-only CSS (G8)

clean-ai-response.ts runs first in useApplyAiResponse().apply() and in
applyRegeneratedSection(): link props allow ''|#anchor|relative|mailto:|tel:|
https:|http: (never //host, backslash, whitespace/control chars, > 2048);
images only assets/<file> or https on images.pexels.com / placehold.co;
videos assets/ or YouTube/Vimeo; the model can never set form destinations,
relay settings or head code (patches keep the user's values); style names
must be CSS identifiers and values may not carry url() (except assets/),
@import, expression(, behavior, -moz-binding, javascript:, < > { } ; \;
anchor ids [A-Za-z0-9_-]. HtmlBlock code keeps its existing sanitisers.
Export boundary: stripLegacyCssVectors() in cssValue/cssPropsToString
(byte-identical for ordinary content: OLS fixture + default design measured).
Server-side mirror not implemented (deferred).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 11:31:42 -08:00
1 parent 93b3cc35af
commit 2b8b0557b2
8 files changed
+408 -3

No files matched your search

@@ -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}', '</style>', '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: '<meta>', 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: '<p>Hello</p>' }, 'HtmlBlock')).toEqual({ code: '<p>Hello</p>' });
});
});
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);
});
});
+204
View File
@@ -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/<file>` or https on AI_IMAGE_HOSTS -- anything else is dropped.
* - video props (videoUrl, bgVideo, and VideoBlock url/src): `assets/<file>` 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/<file>`), @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<string> = 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<string, unknown> | undefined {
if (!style || typeof style !== 'object' || Array.isArray(style)) return undefined;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(style as Record<string, unknown>)) {
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<string, unknown> | undefined {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return undefined;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
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<string, unknown> {
const p = (props && typeof props === 'object' && !Array.isArray(props)) ? (props as Record<string, unknown>) : {};
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<string, unknown>).code === 'string') props.code = (op.props as Record<string, unknown>).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;
}
@@ -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');
});
});
+6
View File
@@ -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;
+17
View File
@@ -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')");
});
});
+12 -1
View File
@@ -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, '');
}
+2
View File
@@ -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');
+2 -2
View File
@@ -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:<mime>;base64,<payload>`), so this must never be
// applied there.
const sanitizeBreakoutChars = (s: string): string =>
s.replace(/;/g, '').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
stripLegacyCssVectors(s).replace(/;/g, '').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/**
* Sanitizes a single CSS declaration value so it can never terminate the