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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185ukXgk6UunFCKyXqLe2Cq
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-02 10:15:04 -08:00
1 parent 94bbfd3ec9
commit da860e2dae
3 files changed
+11 -11

No files matched your search

+1 -1
View File
@@ -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;
@@ -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 <rect>)', () => {
const out = cleanAiProps({ bgImage: LIVE }, 'BackgroundSection').bgImage as string;
+4 -3
View File
@@ -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'];