From da860e2dae561cc87fa282ed3d5bc98670dd800a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 10:15:04 -0800 Subject: [PATCH] sitesmith cleaner: drop images.pexels.com from the AI image host allow-list Stock photos are imported into the site's assets, never hotlinked, so the only external image host model output may use is placehold.co. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0185ukXgk6UunFCKyXqLe2Cq --- craft/scripts/e2e-sitesmith-cleaned.mjs | 2 +- craft/src/sitesmith/clean-ai-response.test.ts | 13 ++++++------- craft/src/sitesmith/clean-ai-response.ts | 7 ++++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/craft/scripts/e2e-sitesmith-cleaned.mjs b/craft/scripts/e2e-sitesmith-cleaned.mjs index f9865ac..9a51c95 100644 --- a/craft/scripts/e2e-sitesmith-cleaned.mjs +++ b/craft/scripts/e2e-sitesmith-cleaned.mjs @@ -28,7 +28,7 @@ fs.rmSync(tmp, { force: true }); const SITE = 'http://site.test'; // The published page's own head resources (fonts, Font Awesome) + the cleaner's image hosts. -const ALLOWED_HOSTS = new Set(['site.test', 'fonts.googleapis.com', 'fonts.gstatic.com', 'cdnjs.cloudflare.com', 'images.pexels.com', 'placehold.co']); +const ALLOWED_HOSTS = new Set(['site.test', 'fonts.googleapis.com', 'fonts.gstatic.com', 'cdnjs.cloudflare.com', 'placehold.co']); const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); let pass = 0; let fail = 0; diff --git a/craft/src/sitesmith/clean-ai-response.test.ts b/craft/src/sitesmith/clean-ai-response.test.ts index f43b025..e075742 100644 --- a/craft/src/sitesmith/clean-ai-response.test.ts +++ b/craft/src/sitesmith/clean-ai-response.test.ts @@ -31,7 +31,7 @@ describe('allowUrl: tricky link forms', () => { 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)]) { + for (const v of ['//placehold.co/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(); } @@ -39,11 +39,10 @@ describe('allowUrl: tricky link forms', () => { }); describe('allowUrl: image and video props', () => { - test('images: own assets and the supported stock/placeholder hosts only', () => { + test('images: own assets and the placeholder host only (stock photos are imported to assets/, never hotlinked)', () => { 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']) { + for (const bad of ['https://example.com/a.png', 'http://placehold.co/a.jpg', 'https://images.pexels.com/photos/1/a.jpeg', 'https://cdn.pixabay.com/photo/a.jpg', 'https://pixabay.com/get/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(); } }); @@ -151,7 +150,7 @@ describe('patches are cleaned with the TARGET component\'s rules (review finding test('ButtonLink href and ImageBlock src', () => { expect(propsOf(cleanAiResponse(patch({ href: 'https://example.com/' }), as('ButtonLink')))).toEqual({ href: 'https://example.com/' }); expect(propsOf(cleanAiResponse(patch({ href: 'javascript:x' }), as(undefined)))).toEqual({}); - const px = 'https://images.pexels.com/photos/1/a.jpeg'; + const px = 'https://placehold.co/600x400?text=x'; expect(propsOf(cleanAiResponse(patch({ src: px }), as('ImageBlock')))).toEqual({ src: px }); expect(propsOf(cleanAiResponse(patch({ src: px }), as(undefined)))).toEqual({}); expect(propsOf(cleanAiResponse(patch({ src: 'https://example.com/a.png' }), as('ImageBlock')))).toEqual({}); @@ -161,7 +160,7 @@ describe('patches are cleaned with the TARGET component\'s rules (review finding .toEqual({ logoUrl: '/', logoImage: 'assets/logo.png', links: [{ text: 'A', href: '#a' }, { text: 'B' }] }); }); test('a componentOf that throws is treated as unresolvable (strict)', () => { - expect(propsOf(cleanAiResponse(patch({ code: 'x', src: 'https://images.pexels.com/a.jpg' }), () => { throw new Error('gone'); }))).toEqual({}); + expect(propsOf(cleanAiResponse(patch({ code: 'x', src: 'https://placehold.co/a.jpg' }), () => { throw new Error('gone'); }))).toEqual({}); }); test('replace_node / insert_* trees carry their component names', () => { const r = cleanAiResponse({ type: 'patch', message: 'm', ops: [ @@ -208,7 +207,7 @@ describe('placehold.co backgrounds are label-free', () => { const img = 'https://placehold.co/800x600/eeeeee/333333?text=Team+photo'; expect(cleanAiProps({ src: img }, 'ImageBlock').src).toBe(img); expect(cleanAiProps({ bgImage: 'assets/hero.jpg' }, 'BackgroundSection').bgImage).toBe('assets/hero.jpg'); - expect(cleanAiProps({ bgImage: 'https://images.pexels.com/photos/1/a.jpg?text=x' }, 'BackgroundSection').bgImage).toBe('https://images.pexels.com/photos/1/a.jpg?text=x'); + expect(cleanAiProps({ bgImage: 'https://images.pexels.com/photos/1/a.jpg' }, 'BackgroundSection').bgImage).toBeUndefined(); // stock hosts are no longer allowed }); test('round 6 (live case 4): the label is a zero-width space, so placehold.co draws NO glyphs at all (measured: same-colour "W x H" glyphs still antialias into a ghost label; ?text=%E2%80%8B returns a bare )', () => { const out = cleanAiProps({ bgImage: LIVE }, 'BackgroundSection').bgImage as string; diff --git a/craft/src/sitesmith/clean-ai-response.ts b/craft/src/sitesmith/clean-ai-response.ts index 52e3868..c858b53 100644 --- a/craft/src/sitesmith/clean-ai-response.ts +++ b/craft/src/sitesmith/clean-ai-response.ts @@ -27,9 +27,10 @@ import { LABEL_FREE_TEXT, placeholderBoxHex } from './placeholder-bg'; 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']; +/** Image hosts model output may reference: only the placeholder host the Sitesmith prompt prescribes. + * Stock photos are never hotlinked (Pixabay forbids permanent hotlinking): they are imported into the + * site's own `assets/` by the Stock tab, so no stock-provider host is allowed here. */ +export const AI_IMAGE_HOSTS: readonly string[] = ['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'];