Sitesmith self-review capture: sanitise page HTML before it reaches the frame (inert DOMParser allow-list, fixed-point guard), CSP + script-less sandbox, real-browser hostile-page proof; UI building blocks (attachments, sites I like, asset awareness, design payload)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 10:31:29 -08:00
1 parent 0fde4dc22f
commit 197b9c46ab
14 files changed
+965 -10

No files matched your search

@@ -0,0 +1,16 @@
// Bundled by scripts/e2e-capture-security.mjs and run in real Firefox + Chromium.
import { captureHtml } from '../src/utils/self-review-capture';
import { captureCsp } from '../src/utils/sanitize-capture-html';
(window as any).captureHtml = captureHtml;
/** Defence-in-depth probe: the capture frame WITHOUT the sanitiser (sandbox + CSP only). */
(window as any).rawFrame = (html: string) => new Promise<void>((resolve) => {
const f = document.createElement('iframe');
f.setAttribute('sandbox', 'allow-same-origin');
const CSP = captureCsp(location.origin);
f.setAttribute('csp', CSP);
f.style.cssText = 'position:fixed;left:-9999px;width:800px;height:600px';
f.addEventListener('load', () => setTimeout(resolve, 800), { once: true });
f.srcdoc = html.replace('<head>', `<head><meta http-equiv="Content-Security-Policy" content="${CSP}">`);
document.body.appendChild(f);
});
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* Real-browser proof (Firefox + Chromium) that the self-review capture cannot run or leak hostile page
* markup. A malicious fixture goes through the WHOLE capture path; sentinels: window.__pwned in the
* parent, any request to /beacon or evil.example, parent cookie/localStorage reachability, frame
* navigation. A second probe feeds the SAME hostile page to the frame WITHOUT the sanitiser to show the
* sandbox + CSP alone also hold. Benign pages must still capture.
* Run: node scripts/e2e-capture-security.mjs (CHROMIUM_PATH / FIREFOX_PATH override the binaries)
*/
import { build } from 'esbuild';
import { chromium, firefox } from '@playwright/test';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const out = await build({
entryPoints: [path.join(root, 'scripts/e2e-capture-security-entry.ts')],
bundle: true, write: false, format: 'iife', platform: 'browser', logLevel: 'error',
define: { 'process.env.NODE_ENV': '"production"' },
});
const js = out.outputFiles[0].text;
const PWN = "(function(){try{parent.__pwned=1;top.__pwned=1}catch(e){}try{fetch('/beacon?js')}catch(e){}try{top.postMessage('pwned','*')}catch(e){}try{new Image().src='/beacon?img-js'}catch(e){}})()";
const EVIL = `<!doctype html><html><head>
<meta http-equiv="refresh" content="0;url=/beacon?refresh"><base href="https://evil.example/">
<link rel="import" href="/beacon?import"><link rel="stylesheet" href="/beacon?css"><link rel="preload" href="/beacon?preload" as="script">
<style>@import url(/beacon?atimport); body{background:url(https://evil.example/bg.png);} .pad{padding:8px}</style>
<script>${PWN}</script></head>
<body onload="${PWN}" style="font:16px sans-serif">
<h1 class="pad" style="color:#123">Hostile but readable</h1>
<img src="x" onerror="${PWN}"><img src="/beacon?img">
<svg width="10" height="10" onload="${PWN}"><script>${PWN}</script><a xlink:href="javascript:${PWN}"><rect width="5" height="5"/></a></svg>
<a href="javascript:${PWN}">js link</a><form action="javascript:${PWN}"><button formaction="javascript:${PWN}">b</button></form>
<iframe srcdoc="<script>${PWN}</script>"></iframe><iframe src="/beacon?iframe"></iframe>
<object data="/beacon?object"></object><embed src="/beacon?embed">
<div style="background:url(/beacon?cssurl)">x</div><video src="/beacon?video"></video>
<input autofocus onfocus="${PWN}"><details open ontoggle="${PWN}"><summary>s</summary></details>
<p style="color:#444">Visible paragraph so the capture has real content ${'lorem ipsum '.repeat(40)}</p>
</body></html>`;
const BENIGN = `<!doctype html><html><head><style>body{margin:0;font:20px sans-serif}.h{background:linear-gradient(135deg,#0f172a,#3b82f6);color:#fff;padding:40px}</style></head>
<body><div class="h"><h1>Benign page</h1><p>${'Hello world. '.repeat(30)}</p></div></body></html>`;
const beacons = [];
const srv = http.createServer((q, r) => {
const p = q.url.split('?')[0];
if (p === '/') { r.setHeader('content-type', 'text/html'); return r.end('<!doctype html><title>host</title><body>host</body>'); }
if (p === '/beacon') { beacons.push(q.url); r.statusCode = 204; return r.end(); }
r.statusCode = 404; r.end('nf');
}).listen(0);
const port = srv.address().port;
let failed = 0;
const check = (name, label, ok, extra = '') => { if (!ok) failed++; console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' ' + extra : ''}`); };
async function run(name, launcher, exe) {
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
const ctx = await browser.newContext();
const page = await ctx.newPage();
const evilReq = [];
await ctx.route(/evil\.example/, (route) => { evilReq.push(route.request().url()); route.abort(); });
// Only requests that actually reach the network count (Playwright also reports CSP-blocked ones as 'request').
let messages = 0;
await page.exposeFunction('__onMsg', () => { messages++; });
await page.goto(`http://localhost:${port}/`);
await page.evaluate(() => { document.cookie = 'sess=SECRET; path=/'; localStorage.setItem('csrf', 'SECRET'); window.addEventListener('message', (e) => { if (e.data === 'pwned') window.__onMsg(); }); });
await page.addScriptTag({ content: js });
// 1) hostile page through the whole capture path
beacons.length = 0;
const res = await page.evaluate(async (h) => { const r = await window.captureHtml(h, { settleMs: 300 }); return r.ok ? { ok: true, size: r.blob.size } : r; }, EVIL);
await page.waitForTimeout(500);
const pwned = await page.evaluate(() => window.__pwned);
check(name, 'hostile page: parent sentinel window.__pwned untouched', pwned === undefined, `(${pwned})`);
check(name, 'hostile page: no postMessage to the editor', messages === 0, `(${messages})`);
check(name, 'hostile page: no request to the panel origin /beacon', beacons.length === 0, JSON.stringify(beacons));
check(name, 'hostile page: no request to evil.example', evilReq.length === 0, JSON.stringify(evilReq));
check(name, 'hostile page: capture result is safe (ok, or refused) and frame removed', (res.ok || typeof res.reason === 'string') && (await page.evaluate(() => document.querySelectorAll('iframe').length)) === 0, JSON.stringify(res));
check(name, 'hostile page: sanitised content still captured (readable page)', res.ok === true && res.size > 3000, JSON.stringify(res));
// 2) defence in depth: same hostile page, NO sanitiser, only sandbox + CSP
beacons.length = 0; evilReq.length = 0; messages = 0;
await page.evaluate((h) => window.rawFrame(h), EVIL);
await page.waitForTimeout(500);
const pwned2 = await page.evaluate(() => window.__pwned);
check(name, 'unsanitised probe: sandbox+CSP alone stop script execution (__pwned)', pwned2 === undefined, `(${pwned2})`);
check(name, 'unsanitised probe: no postMessage', messages === 0);
// Same-origin passive GETs (img/css url) are allowed by CSP 'self'; that is why the sanitiser also restricts them. Script-initiated ones must never occur.
check(name, 'unsanitised probe: no script-initiated requests (js/img-js beacons)', !beacons.some((b) => /\?(js|img-js|refresh|import|iframe|object|embed|video)$/.test(b)), JSON.stringify(beacons));
check(name, 'unsanitised probe: no evil.example requests', evilReq.length === 0, JSON.stringify(evilReq));
await page.evaluate(() => document.querySelectorAll('iframe').forEach((f) => f.remove()));
// 3) benign page still captures
const ok = await page.evaluate(async (h) => { const r = await window.captureHtml(h, { settleMs: 300 }); return r.ok ? { ok: true, size: r.blob.size, w: r.width, h: r.height } : r; }, BENIGN);
check(name, 'benign page captures', ok.ok === true && ok.size > 3000, JSON.stringify(ok));
await browser.close();
}
await run('chromium', chromium, process.env.CHROMIUM_PATH);
await run('firefox', firefox, process.env.FIREFOX_PATH);
srv.close();
process.exit(failed ? 1 : 0);
+6
View File
@@ -61,6 +61,10 @@ export interface SitesmithSendOpts {
mockupIds?: string[];
referenceIds?: number[];
useAssets?: boolean;
/** Current site design (see utils/sitesmith-design.ts); read by the backend prompt builder. */
design?: Record<string, unknown>;
/** Uploaded assets Sitesmith may see (relative filenames). Omitted = all. Needs backend support; see report. */
assetFilenames?: string[];
onProgress?: (phase: string) => void;
signal?: AbortSignal;
}
@@ -332,6 +336,7 @@ export function useSitesmith(siteId: number) {
if (target) body.target = target;
if (profile) body.profile = profile;
if (opts?.intent) body.intent = opts.intent;
if (opts?.design && Object.keys(opts.design).length) body.design = opts.design;
let result: SendResult;
let usedStart = false;
@@ -340,6 +345,7 @@ export function useSitesmith(siteId: number) {
if (opts?.mockupIds?.length) startBody.mockup_ids = opts.mockupIds;
if (opts?.referenceIds?.length) startBody.reference_ids = opts.referenceIds;
if (opts?.useAssets !== undefined) startBody.use_assets = opts.useAssets;
if (opts?.assetFilenames) startBody.asset_filenames = opts.assetFilenames;
const p = await request(`${apiBase(whpConfig.apiUrl)}?action=start`, {
method: 'POST',
credentials: 'include',
@@ -0,0 +1,53 @@
import React from 'react';
import type { SitesmithAsset } from '../../hooks/useSitesmith';
import { note, thumb, errBox } from './Section';
interface Props {
assets: SitesmithAsset[];
loaded: boolean;
error?: string | null;
/** Filenames Sitesmith may see. */
included: Set<string>;
onToggle: (filename: string) => void;
onAll: (on: boolean) => void;
/** Public-safe display URL for a thumbnail. */
thumbUrl: (a: SitesmithAsset) => string;
visionAvailable: boolean;
disabled?: boolean;
}
/** Which uploaded images Sitesmith will see, with the captions it has, and a per-image toggle. */
export const AssetAwareness: React.FC<Props> = ({ assets, loaded, error, included, onToggle, onAll, thumbUrl, visionAvailable, disabled }) => (
<div data-testid="ss-assets">
<p style={note}>
Sitesmith can use the images you uploaded to this site and place them on the page. Untick any you do not want it to use.
{visionAvailable
? ' It looks at each picture (a small copy) and writes a short description the first time.'
: ' Image viewing is not available on this server, so Sitesmith works from file names and sizes only.'}
</p>
{error && <div style={errBox} role="alert">{error}</div>}
{!loaded && !error && <p style={note}>Loading your images…</p>}
{loaded && !error && assets.length === 0 && <p style={note}>You have not uploaded any images yet. Add some in the Assets tab and Sitesmith will be able to use them.</p>}
{assets.length > 0 && (
<>
<div style={{ marginBottom: 6, fontSize: 12 }}>
<button type="button" disabled={disabled} onClick={() => onAll(true)} style={{ background: 'none', border: 'none', color: '#a78bfa', cursor: 'pointer', padding: 0, marginRight: 12 }}>Include all</button>
<button type="button" disabled={disabled} onClick={() => onAll(false)} style={{ background: 'none', border: 'none', color: '#a78bfa', cursor: 'pointer', padding: 0 }}>Include none</button>
<span style={{ color: '#71717a', marginLeft: 12 }}>{included.size} of {assets.length} included</span>
</div>
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 220, overflowY: 'auto' }}>
{assets.map((a) => (
<li key={a.filename} data-testid="ss-asset-item" style={{ display: 'flex', gap: 8, alignItems: 'center', opacity: included.has(a.filename) ? 1 : 0.5 }}>
<input type="checkbox" checked={included.has(a.filename)} disabled={disabled} onChange={() => onToggle(a.filename)} aria-label={`Let Sitesmith use ${a.filename}`} />
<img src={thumbUrl(a)} alt="" loading="lazy" style={{ ...thumb, width: 40, height: 40 }} />
<div style={{ minWidth: 0 }}>
<div style={{ color: '#e4e4e7', overflowWrap: 'anywhere' }}>{a.filename}{a.width && a.height ? <span style={{ color: '#71717a' }}> · {a.width}×{a.height}</span> : null}</div>
<div style={{ color: '#a1a1aa', fontSize: 12 }}>{a.caption || a.alt || 'No description yet'}</div>
</div>
</li>
))}
</ul>
</>
)}
</div>
);
@@ -0,0 +1,113 @@
import React, { useRef, useState } from 'react';
import type { SitesmithRefImage } from '../../hooks/useSitesmith';
import { formatBytes } from '../../utils/sitesmith-attachments';
import { btn, btnDanger, errBox, note, thumb, warnBox } from './Section';
interface Props {
/** Mockup images already stored for this site (from refs_list). */
images: SitesmithRefImage[];
selected: string[];
onSelectedChange: (ids: string[]) => void;
/** Total image budget for one send (mockups + reference screenshots). */
maxAttach: number;
/** Images already counted by selected "Sites I like" entries. */
referenceImageCount: number;
imageUrl: (id: string) => string;
onUpload: (file: File) => Promise<{ ok: true; image?: SitesmithRefImage } | { ok: false; message: string }>;
onRemove: (id: string) => Promise<{ ok: true } | { ok: false; message: string }>;
/** Image features off: why (vision unavailable). Text-only generation is unaffected. */
unavailableReason?: string | null;
disabled?: boolean;
/** Called with the message when a file is rejected or an upload fails. */
}
/** Mockup picker: drag and drop or file picker, thumbnails, remove. Image names are rendered as text nodes only. */
export const AttachmentTray: React.FC<Props> = ({
images, selected, onSelectedChange, maxAttach, referenceImageCount, imageUrl, onUpload, onRemove, unavailableReason, disabled,
}) => {
const input = useRef<HTMLInputElement>(null);
const [drag, setDrag] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const mockups = images.filter((i) => i.kind === 'mockup');
const off = !!unavailableReason || !!disabled;
const used = selected.length + referenceImageCount;
const addFiles = async (files: FileList | File[] | null) => {
if (!files || off) return;
setErr(null);
setBusy(true);
try {
for (const f of Array.from(files)) {
if (used + 1 > maxAttach) { setErr(`You can attach up to ${maxAttach} images to one request.`); break; }
const r = await onUpload(f);
if (!r.ok) { setErr(r.message); break; }
if (r.image) onSelectedChange([...selected, r.image.id]);
}
} finally { setBusy(false); }
};
const toggle = (id: string) => {
if (selected.includes(id)) onSelectedChange(selected.filter((x) => x !== id));
else if (used < maxAttach) onSelectedChange([...selected, id]);
else setErr(`You can attach up to ${maxAttach} images to one request.`);
};
const remove = async (id: string) => {
setErr(null);
const r = await onRemove(id);
if (!r.ok) { setErr(r.message); return; }
onSelectedChange(selected.filter((x) => x !== id));
};
return (
<div data-testid="ss-mockups">
<p style={note}>
Attach a picture of the page you want (a sketch, a screenshot, a design export). Sitesmith builds toward it.
The image is kept privately for Sitesmith; it is never published or added to your assets.
</p>
{unavailableReason && <div style={warnBox} role="note" data-testid="ss-vision-note">{unavailableReason}</div>}
<div
data-testid="ss-dropzone"
onDragOver={(e) => { if (off) return; e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={(e) => { e.preventDefault(); setDrag(false); void addFiles(e.dataTransfer?.files ?? null); }}
style={{
border: `1px dashed ${drag ? '#a78bfa' : '#3f3f46'}`, borderRadius: 8, padding: 12, textAlign: 'center',
background: drag ? 'rgba(124,58,237,0.12)' : 'transparent', opacity: off ? 0.5 : 1,
}}
>
<button type="button" style={btn} disabled={off || busy} onClick={() => input.current?.click()}>
{busy ? 'Uploading…' : 'Choose an image'}
</button>
<span style={{ color: '#a1a1aa', fontSize: 12, marginLeft: 8 }}>or drop it here (JPEG, PNG, WebP or GIF, up to 8 MB)</span>
<input
ref={input} type="file" accept="image/jpeg,image/png,image/webp,image/gif" hidden multiple
data-testid="ss-mockup-input" disabled={off}
onChange={(e) => { void addFiles(e.target.files); e.target.value = ''; }}
/>
</div>
{err && <div role="alert" style={errBox}>{err}</div>}
{mockups.length > 0 && (
<ul style={{ listStyle: 'none', margin: '10px 0 0', padding: 0, display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{mockups.map((im) => {
const on = selected.includes(im.id);
return (
<li key={im.id} style={{ width: 96 }} data-testid="ss-mockup-item">
<label style={{ display: 'block', cursor: 'pointer' }}>
<img src={imageUrl(im.id)} alt={`Mockup ${im.name}`} style={{ ...thumb, width: 96, height: 72, outline: on ? '2px solid #a78bfa' : 'none' }} />
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#d4d4d8', marginTop: 3 }}>
<input type="checkbox" checked={on} disabled={off} onChange={() => toggle(im.id)} aria-label={`Use ${im.name} for the next request`} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{im.name}</span>
</span>
</label>
<div style={{ fontSize: 10, color: '#71717a' }}>{im.width && im.height ? `${im.width}×${im.height} · ` : ''}{formatBytes(im.size)}</div>
<button type="button" style={{ ...btnDanger, marginTop: 3, padding: '2px 6px', fontSize: 11 }} disabled={off} onClick={() => void remove(im.id)} aria-label={`Remove ${im.name}`}>Remove</button>
</li>
);
})}
</ul>
)}
</div>
);
};
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
const sectionStyle: React.CSSProperties = { border: '1px solid #27272a', borderRadius: 8, marginBottom: 8, background: '#13131c' };
const headStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, width: '100%', background: 'transparent', border: 'none', color: '#e4e4e7', padding: '8px 12px', cursor: 'pointer', fontSize: 13, fontWeight: 600, textAlign: 'left' };
const bodyStyle: React.CSSProperties = { padding: '4px 12px 12px', fontSize: 13, color: '#d4d4d8' };
/** Collapsible block used by the Sitesmith context area. */
export const Section: React.FC<{
id: string; title: string; icon?: string; badge?: string; open: boolean; onToggle: () => void; children: React.ReactNode;
}> = ({ id, title, icon, badge, open, onToggle, children }) => (
<div style={sectionStyle} data-testid={`ss-section-${id}`}>
<button type="button" style={headStyle} aria-expanded={open} aria-controls={`ss-sec-${id}`} onClick={onToggle}>
{icon && <i className={`fa ${icon}`} aria-hidden style={{ color: '#a78bfa', width: 16 }} />}
<span>{title}</span>
{badge && <span style={{ fontSize: 11, color: '#a1a1aa', fontWeight: 400 }}>{badge}</span>}
<span style={{ flex: 1 }} />
<i className={`fa fa-chevron-${open ? 'up' : 'down'}`} aria-hidden style={{ color: '#71717a', fontSize: 11 }} />
</button>
{open && <div id={`ss-sec-${id}`} style={bodyStyle}>{children}</div>}
</div>
);
export const note: React.CSSProperties = { fontSize: 12, color: '#a1a1aa', lineHeight: 1.5, margin: '4px 0 8px' };
export const warnBox: React.CSSProperties = { background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.4)', color: '#fde68a', padding: '6px 10px', borderRadius: 6, fontSize: 12, margin: '6px 0' };
export const errBox: React.CSSProperties = { background: '#3b1d1d', border: '1px solid #7f1d1d', color: '#fecaca', padding: '6px 10px', borderRadius: 6, fontSize: 12, margin: '6px 0' };
export const btn: React.CSSProperties = { background: 'transparent', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer' };
export const btnPrimary: React.CSSProperties = { background: '#7c3aed', color: '#fff', border: '1px solid #7c3aed', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' };
export const btnDanger: React.CSSProperties = { background: 'transparent', color: '#fca5a5', border: '1px solid #7f1d1d', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer' };
export const inputStyle: React.CSSProperties = { width: '100%', boxSizing: 'border-box', background: '#0f0f17', color: '#e5e7eb', border: '1px solid #3f3f46', borderRadius: 6, padding: '6px 8px', fontSize: 13 };
export const thumb: React.CSSProperties = { width: 64, height: 64, objectFit: 'cover', borderRadius: 6, border: '1px solid #3f3f46', background: '#0f0f17', display: 'block' };
@@ -0,0 +1,165 @@
import React, { useRef, useState } from 'react';
import type { SitesmithReference, SitesmithRefImage } from '../../hooks/useSitesmith';
import { btn, btnDanger, btnPrimary, errBox, inputStyle, note, thumb, warnBox } from './Section';
import type { Outcome } from './useSitesmithRefs';
interface Props {
references: SitesmithReference[];
images: SitesmithRefImage[];
selected: number[];
onSelectedChange: (ids: number[]) => void;
imageUrl: (id: string) => string;
loadError?: string | null;
addRef: (url: string, likes: string, shots: string[]) => Promise<Outcome>;
updateRef: (id: number, url: string, likes: string, shots: string[]) => Promise<Outcome>;
deleteRef: (id: number) => Promise<Outcome>;
uploadShot: (f: File) => Promise<Outcome & { image?: SitesmithRefImage }>;
removeImage: (id: string) => Promise<Outcome>;
maxRefs: number;
/** Image features unavailable (no vision): text-only entries still work. */
imagesUnavailableReason?: string | null;
disabled?: boolean;
}
const MAX_SHOTS = 3;
interface FormState { id: number | null; url: string; likes: string; shots: string[]; added: string[] }
const EMPTY: FormState = { id: null, url: '', likes: '', shots: [], added: [] };
/** "Sites I like": a per-site list of label + notes + optional screenshots. The address is a label and is never fetched. */
export const SitesILikePanel: React.FC<Props> = ({
references, images, selected, onSelectedChange, imageUrl, loadError, addRef, updateRef, deleteRef, uploadShot, removeImage,
maxRefs, imagesUnavailableReason, disabled,
}) => {
const [form, setForm] = useState<FormState | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const imgById = new Map(images.map((i) => [i.id, i]));
const startAdd = () => { setErr(null); setForm({ ...EMPTY }); };
const startEdit = (r: SitesmithReference) => { setErr(null); setForm({ id: r.id, url: r.url_label, likes: r.what_i_like, shots: [...r.screenshot_ids], added: [] }); };
const cancel = async () => {
// Screenshots uploaded for a form that was never saved would be orphans: drop them.
if (form) for (const id of form.added) await removeImage(id);
setForm(null); setErr(null);
};
const save = async () => {
if (!form) return;
setBusy(true); setErr(null);
try {
const r = form.id === null
? await addRef(form.url, form.likes, form.shots)
: await updateRef(form.id, form.url, form.likes, form.shots);
if (!r.ok) { setErr(r.message); return; }
setForm(null);
} finally { setBusy(false); }
};
const addShot = async (files: FileList | null) => {
if (!files || !form) return;
setErr(null); setBusy(true);
try {
for (const f of Array.from(files)) {
if (form.shots.length >= MAX_SHOTS) { setErr(`Up to ${MAX_SHOTS} screenshots per site.`); break; }
const r = await uploadShot(f);
if (!r.ok) { setErr(r.message); break; }
const id = r.image!.id;
setForm((s) => (s ? { ...s, shots: [...s.shots, id], added: [...s.added, id] } : s));
form.shots = [...form.shots, id];
}
} finally { setBusy(false); }
};
const dropShot = async (id: string) => {
if (!form) return;
// Removing from a saved entry detaches it; an image added in this form is deleted outright.
if (form.added.includes(id)) await removeImage(id);
setForm((s) => (s ? { ...s, shots: s.shots.filter((x) => x !== id), added: s.added.filter((x) => x !== id) } : s));
};
const del = async (id: number) => {
setErr(null);
const r = await deleteRef(id);
setConfirmDel(null);
if (!r.ok) { setErr(r.message); return; }
onSelectedChange(selected.filter((x) => x !== id));
};
const toggle = (id: number) => onSelectedChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
return (
<div data-testid="ss-sites-i-like">
<p style={note} data-testid="ss-sites-copy">
List websites whose look you admire and say what you like about each. The address is only a label for you:
<strong> Sitesmith does not open, visit or fetch it</strong>. It works from your description and any screenshots you attach yourself.
</p>
{loadError && <div style={errBox} role="alert">{loadError}</div>}
{err && !form && <div style={errBox} role="alert">{err}</div>}
{references.length === 0 && !form && <p style={note}>No sites yet.</p>}
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{references.map((r) => (
<li key={r.id} data-testid="ss-ref-item" style={{ border: '1px solid #27272a', borderRadius: 6, padding: 8 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<input type="checkbox" checked={selected.includes(r.id)} disabled={disabled} onChange={() => toggle(r.id)} aria-label={`Use ${r.url_label} for the next request`} style={{ marginTop: 3 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, color: '#e4e4e7', overflowWrap: 'anywhere' }}>{r.url_label}</div>
<div style={{ color: '#a1a1aa', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>{r.what_i_like}</div>
{r.screenshot_ids.length > 0 && (
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
{r.screenshot_ids.map((sid) => (
<img key={sid} src={imageUrl(sid)} alt={`Screenshot ${imgById.get(sid)?.name ?? ''}`} style={{ ...thumb, width: 48, height: 48 }} />
))}
</div>
)}
</div>
<button type="button" style={btn} disabled={disabled} onClick={() => startEdit(r)} aria-label={`Edit ${r.url_label}`}>Edit</button>
{confirmDel === r.id
? <button type="button" style={btnDanger} onClick={() => void del(r.id)} autoFocus>Confirm delete</button>
: <button type="button" style={btnDanger} disabled={disabled} onClick={() => setConfirmDel(r.id)} aria-label={`Delete ${r.url_label}`}>Delete</button>}
</div>
</li>
))}
</ul>
{form ? (
<div style={{ marginTop: 10, border: '1px solid #3f3f46', borderRadius: 6, padding: 10 }} data-testid="ss-ref-form">
<label style={{ display: 'block', fontSize: 12, color: '#a1a1aa', marginBottom: 2 }} htmlFor="ss-ref-url">Site address (label only, not opened)</label>
<input id="ss-ref-url" style={inputStyle} value={form.url} maxLength={300} placeholder="example.com" onChange={(e) => setForm({ ...form, url: e.target.value })} />
<label style={{ display: 'block', fontSize: 12, color: '#a1a1aa', margin: '8px 0 2px' }} htmlFor="ss-ref-likes">What I like about it</label>
<textarea id="ss-ref-likes" style={{ ...inputStyle, minHeight: 60, resize: 'vertical' }} value={form.likes} maxLength={1000} placeholder="Big photo header, lots of white space, simple menu" onChange={(e) => setForm({ ...form, likes: e.target.value })} />
<div style={{ fontSize: 12, color: '#a1a1aa', margin: '8px 0 4px' }}>Screenshots (optional, up to {MAX_SHOTS})</div>
{imagesUnavailableReason
? <div style={warnBox} role="note">{imagesUnavailableReason}</div>
: (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{form.shots.map((sid) => (
<span key={sid} style={{ position: 'relative' }}>
<img src={imageUrl(sid)} alt="Screenshot" style={{ ...thumb, width: 56, height: 56 }} />
<button type="button" onClick={() => void dropShot(sid)} aria-label="Remove screenshot" style={{ ...btnDanger, position: 'absolute', top: -6, right: -6, padding: '0 5px', fontSize: 11 }}>×</button>
</span>
))}
{form.shots.length < MAX_SHOTS && (
<button type="button" style={btn} disabled={busy} onClick={() => fileRef.current?.click()}>Add screenshot</button>
)}
<input ref={fileRef} type="file" hidden multiple accept="image/jpeg,image/png,image/webp,image/gif" data-testid="ss-shot-input" onChange={(e) => { void addShot(e.target.files); e.target.value = ''; }} />
</div>
)}
{err && <div style={errBox} role="alert">{err}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button type="button" style={btnPrimary} disabled={busy} onClick={() => void save()}>{form.id === null ? 'Add site' : 'Save changes'}</button>
<button type="button" style={btn} disabled={busy} onClick={() => void cancel()}>Cancel</button>
</div>
</div>
) : (
<button type="button" style={{ ...btn, marginTop: 10 }} disabled={disabled || references.length >= maxRefs} onClick={startAdd}>
Add a site I like{references.length >= maxRefs ? ` (limit ${maxRefs} reached)` : ''}
</button>
)}
</div>
);
};
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { SitesmithReference, SitesmithRefImage, SitesmithApiResult } from '../../hooks/useSitesmith';
import { validateAttachment, normalizeUrlLabel, DEFAULT_MAX_UPLOAD_BYTES } from '../../utils/sitesmith-attachments';
/** The slice of useSitesmith() this state needs (keeps it easy to fake in tests). */
export interface RefsApi {
listRefs: () => Promise<SitesmithApiResult<{ references: SitesmithReference[]; images: SitesmithRefImage[] }>>;
addReference: (r: { url_label: string; what_i_like: string; screenshot_ids?: string[] }) => Promise<SitesmithApiResult<{ reference: SitesmithReference }>>;
updateReference: (id: number, p: { url_label?: string; what_i_like?: string; screenshot_ids?: string[] }) => Promise<SitesmithApiResult<{ reference: SitesmithReference }>>;
deleteReference: (id: number) => Promise<SitesmithApiResult<object>>;
uploadRefImage: (f: File | Blob, kind: 'mockup' | 'screenshot') => Promise<SitesmithApiResult<{ image: SitesmithRefImage }>>;
deleteRefImage: (id: string) => Promise<SitesmithApiResult<object>>;
}
export type Outcome = { ok: true } | { ok: false; message: string };
/** Mockups, reference screenshots and "Sites I like" for one site. Never throws. */
export function useSitesmithRefs(api: RefsApi, enabled: boolean, maxUploadBytes = DEFAULT_MAX_UPLOAD_BYTES) {
const [references, setReferences] = useState<SitesmithReference[]>([]);
const [images, setImages] = useState<SitesmithRefImage[]>([]);
const [loaded, setLoaded] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const apiRef = useRef(api);
apiRef.current = api;
const alive = useRef(true);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const load = useCallback(async () => {
const r = await apiRef.current.listRefs();
if (!alive.current) return;
if (r.ok) { setReferences(r.references ?? []); setImages(r.images ?? []); setLoadError(null); }
else setLoadError(r.message);
setLoaded(true);
}, []);
useEffect(() => { if (enabled && !loaded) void load(); }, [enabled, loaded, load]);
const uploadImage = useCallback(async (file: File, kind: 'mockup' | 'screenshot'): Promise<Outcome & { image?: SitesmithRefImage }> => {
const bad = await validateAttachment(file, maxUploadBytes);
if (bad) return { ok: false, message: bad };
const r = await apiRef.current.uploadRefImage(file, kind);
if (!r.ok) return { ok: false, message: r.message };
if (alive.current) setImages((xs) => [...xs, r.image]);
return { ok: true, image: r.image };
}, [maxUploadBytes]);
const removeImage = useCallback(async (id: string): Promise<Outcome> => {
const r = await apiRef.current.deleteRefImage(id);
if (!r.ok) return { ok: false, message: r.message };
if (alive.current) {
setImages((xs) => xs.filter((i) => i.id !== id));
setReferences((rs) => rs.map((x) => ({ ...x, screenshot_ids: x.screenshot_ids.filter((s) => s !== id) })));
}
return { ok: true };
}, []);
const addRef = useCallback(async (urlLabel: string, whatILike: string, screenshotIds: string[] = []): Promise<Outcome & { reference?: SitesmithReference }> => {
const u = normalizeUrlLabel(urlLabel);
if (!u.ok) return u;
if (!whatILike.trim()) return { ok: false, message: 'Say what you like about this site so Sitesmith knows what to take from it.' };
const r = await apiRef.current.addReference({ url_label: u.value, what_i_like: whatILike.trim(), screenshot_ids: screenshotIds });
if (!r.ok) return { ok: false, message: r.message };
if (alive.current) setReferences((rs) => [...rs, r.reference]);
return { ok: true, reference: r.reference };
}, []);
const updateRef = useCallback(async (id: number, urlLabel: string, whatILike: string, screenshotIds?: string[]): Promise<Outcome> => {
const u = normalizeUrlLabel(urlLabel);
if (!u.ok) return u;
if (!whatILike.trim()) return { ok: false, message: 'Say what you like about this site so Sitesmith knows what to take from it.' };
const r = await apiRef.current.updateReference(id, { url_label: u.value, what_i_like: whatILike.trim(), ...(screenshotIds ? { screenshot_ids: screenshotIds } : {}) });
if (!r.ok) return { ok: false, message: r.message };
if (alive.current) setReferences((rs) => rs.map((x) => (x.id === id ? r.reference : x)));
return { ok: true };
}, []);
const deleteRef = useCallback(async (id: number): Promise<Outcome> => {
const r = await apiRef.current.deleteReference(id);
if (!r.ok) return { ok: false, message: r.message };
if (alive.current) setReferences((rs) => rs.filter((x) => x.id !== id));
return { ok: true };
}, []);
return { references, images, loaded, loadError, reload: load, uploadImage, removeImage, addRef, updateRef, deleteRef };
}
+3 -2
View File
@@ -4,7 +4,8 @@ export type SitesmithStatus =
| 'OK_BONUS' | 'OK_MONTHLY'
| 'DISABLED' | 'CAP_REACHED'
| 'USER_KILLSWITCH' | 'SERVER_KILLSWITCH'
| 'RATE_LIMITED' | 'BLOCKED' | 'AI_ERROR' | 'AI_INVALID';
| 'RATE_LIMITED' | 'BLOCKED' | 'AI_ERROR' | 'AI_INVALID'
| 'BAD_ATTACHMENT' | 'INTERRUPTED' | 'CANCELLED' | 'JOB_IN_PROGRESS';
export interface SitesmithSummary {
enabled: boolean;
@@ -44,6 +45,6 @@ export type SitesmithResponse =
| { type: 'patch'; ops: SitesmithPatchOp[]; message: string; }
| { type: 'ask'; question: string; options?: string[]; };
export interface SendResultOk { ok: true; response: SitesmithResponse; }
export interface SendResultOk { ok: true; response: SitesmithResponse; /** Non-blocking server messages (e.g. images skipped). */ notes?: string[]; }
export interface SendResultErr { ok: false; status: SitesmithStatus | 'BLOCKED'; message: string; }
export type SendResult = SendResultOk | SendResultErr;
@@ -0,0 +1,116 @@
import { describe, test, expect } from 'vitest';
import { sanitizeCaptureHtml, CAPTURE_CSP, BLOCKED_IMAGE } from './sanitize-capture-html';
const san = (h: string) => sanitizeCaptureHtml(h);
const dom = (h: string) => new DOMParser().parseFromString(h, 'text/html');
describe('sanitizeCaptureHtml', () => {
const evil = `<!doctype html><html><head>
<meta http-equiv="refresh" content="0;url=https://evil.example/"><base href="https://evil.example/">
<link rel="import" href="https://evil.example/x.html"><link rel="preload" href="https://evil.example/a" as="script">
<link rel="stylesheet" href="https://evil.example/x.css">
<style>@import url(https://evil.example/i.css); body{background:url(https://evil.example/t.png)} .ok{color:red}</style>
<script>window.__pwned=1</script></head><body onload="window.__pwned=1">
<img src=x onerror="window.__pwned=1"><img src="assets/a.png" onclick="x()">
<svg onload="window.__pwned=1"><script>1</script><foreignObject><div onclick="x()">f</div></foreignObject><a xlink:href="javascript:1"><rect/></a></svg>
<a href="javascript:window.__pwned=1">j</a><a href=" JaVa&#x09;script:1">j2</a><a href="data:text/html,<script>1</script>">d</a><a href="https://ok.example/">fine</a>
<iframe srcdoc="<script>1</script>"></iframe><iframe src="https://evil.example/"></iframe>
<object data="https://evil.example/o"></object><embed src="https://evil.example/e">
<form action="javascript:1"><button formaction="javascript:1">b</button></form>
<div style="background:url(javascript:1);width:1px">s</div><div style="background-image:url('https://evil.example/p.png')">s2</div><div style="width:10px;color:blue">keep</div>
<video src="https://evil.example/v.mp4"></video><noscript><img src=x onerror=1></noscript>
<img srcset="https://evil.example/a.png 1x, assets/b.png 2x" src="assets/b.png">
<p class="ok" data-animation="fade-in">Hello <b>world</b></p>
</body></html>`;
test('removes every script-capable or network-reaching construct', () => {
const out = san(evil);
expect(out.ok).toBe(true);
const d = dom((out as any).html);
for (const sel of ['script', 'iframe', 'object', 'embed', 'base', 'video', 'noscript', 'foreignObject', 'foreignobject', 'link[rel=import]', 'link[rel=preload]', 'link[href*="evil"]', 'meta[http-equiv=refresh]']) {
expect(d.querySelectorAll(sel).length, sel).toBe(0);
}
const all = Array.from(d.querySelectorAll('*'));
for (const el of all) for (const a of Array.from(el.attributes)) {
expect(/^on/i.test(a.name), `${el.tagName}.${a.name}`).toBe(false);
expect(/^\s*(javascript|vbscript|data:text)/i.test(a.value.replace(/[\s\u0000-\u001f]/g, '')), `${el.tagName}.${a.name}=${a.value}`).toBe(false);
expect(a.name === 'srcdoc' || a.name === 'formaction' || a.name === 'action').toBe(false);
}
expect((out as any).html).not.toMatch(/evil\.example/);
expect((out as any).html).not.toMatch(/@import/i);
});
test('keeps benign content, same-origin images, class/style/data-* and https links', () => {
const out = san(evil) as any;
const d = dom(out.html);
expect(d.querySelector('p.ok')?.getAttribute('data-animation')).toBe('fade-in');
expect(d.querySelector('p.ok b')?.textContent).toBe('world');
expect(d.querySelector('img[src="assets/a.png"]')).not.toBeNull();
expect(d.querySelector('a[href="https://ok.example/"]')).not.toBeNull();
expect(Array.from(d.querySelectorAll('div')).some((x) => x.getAttribute('style') === 'width:10px;color:blue')).toBe(true);
expect(d.querySelector('style')?.textContent).toContain('.ok{color:red}');
});
test('same-origin URLs other than assets/<file> and the allow-list are refused (no GET to arbitrary panel paths)', () => {
const h = '<html><body><img src="/api/site-builder.php?action=delete_asset&n=1"><img src="/api/site-builder.php?action=asset&f=a.png"><img src="x.png"><div style="background:url(/beacon?c)">a</div></body></html>';
const open = dom((san(h, ) as any).html);
expect(Array.from(open.querySelectorAll('img')).map((i) => i.getAttribute('src'))).toEqual([BLOCKED_IMAGE, BLOCKED_IMAGE, BLOCKED_IMAGE]);
expect((san(h) as any).html).not.toContain('/beacon');
const allowed = dom((sanitizeCaptureHtml(h, { allowUrls: ['/api/site-builder.php?action=asset&f=a.png'] }) as any).html);
expect(Array.from(allowed.querySelectorAll('img')).map((i) => i.getAttribute('src'))).toEqual([BLOCKED_IMAGE, '/api/site-builder.php?action=asset&f=a.png', BLOCKED_IMAGE]);
});
test('keeps font stylesheets from the two allowed hosts only', () => {
const h = '<html><head><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css"><link rel="stylesheet" href="https://evil.example/a.css"></head><body>x</body></html>';
const d = dom((san(h) as any).html);
expect(d.querySelectorAll('link[rel=stylesheet]').length).toBe(2);
});
test('inlines a restrictive CSP meta and exposes no other meta refresh', () => {
const d = dom((san('<html><head></head><body>x</body></html>') as any).html);
const csp = d.querySelector('meta[http-equiv="Content-Security-Policy"]')!;
expect(csp.getAttribute('content')).toBe(CAPTURE_CSP);
expect(CAPTURE_CSP).toContain("script-src 'none'");
expect(CAPTURE_CSP).toContain("default-src 'none'");
expect(CAPTURE_CSP).not.toMatch(/unsafe-eval|connect-src \*/);
});
test('un-hides animated elements (no script will reveal them)', () => {
const d = dom((san('<html><body><p data-animation="fade-in">a</p></body></html>') as any).html);
expect(d.querySelector('style[data-capture]')?.textContent).toContain('[data-animation]');
});
test('is a fixed point: a second pass changes nothing (mutation-XSS guard)', () => {
const a = san(evil) as any;
const b = san(a.html) as any;
expect(b.html).toBe(a.html);
});
test('mXSS-style payloads do not survive', () => {
for (const p of [
'<svg></p><style><a id="</style><img src=1 onerror=alert(1)>">',
'<math><mtext><table><mglyph><style><img src=x onerror=alert(1)>',
'<noscript><p title="</noscript><img src=x onerror=alert(1)>">',
'<form><math><mtext></form><form><mglyph><style></math><img src onerror=alert(1)>',
]) {
const r = san(`<html><body>${p}</body></html>`);
if (!r.ok) continue; // refusing is also safe
expect(r.html).not.toMatch(/onerror/i);
}
});
test('empty or non-string input is refused, not thrown', () => {
expect(san('')).toMatchObject({ ok: false });
expect(san(undefined as any)).toMatchObject({ ok: false });
});
});
describe('captureCsp', () => {
test('spells the origin out instead of self (Firefox does not match self in srcdoc frames)', async () => {
const { captureCsp } = await import('./sanitize-capture-html');
expect(captureCsp('https://panel.example')).toContain('img-src https://panel.example data:');
expect(captureCsp('https://panel.example')).not.toContain("'self'");
expect(captureCsp('javascript:alert(1)')).toContain("'self'");
expect(captureCsp()).toBe(CAPTURE_CSP);
});
});
+169
View File
@@ -0,0 +1,169 @@
/**
* Sanitiser for the self-review capture. The page HTML contains model output and user text, so it is
* treated as hostile: it is parsed with DOMParser (an inert document: nothing runs, nothing loads),
* stripped to a small allow-list, re-serialised, and only then handed to a script-less iframe whose
* own CSP (meta) forbids scripts and network access except fonts/stylesheets from two named hosts.
*
* Guarantees (each covered by sanitize-capture-html.test.ts and the real-browser proof
* scripts/e2e-capture-security.mjs): no script, frame, object, embed, base, refresh, import, event
* handler, javascript:/vbscript:/data:text URL, @import, or non-same-origin url() survives; output is
* a fixed point of the sanitiser (mutation-XSS guard).
*/
export const CAPTURE_CSP = [
"default-src 'none'",
"script-src 'none'",
"img-src 'self' data:",
"style-src 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com",
"font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com",
"base-uri 'none'",
"form-action 'none'",
"frame-src 'none'",
"object-src 'none'",
].join('; ');
/** Firefox does not match 'self' inside a srcdoc iframe, so callers pass the real origin to spell it out. */
export function captureCsp(origin?: string): string {
return origin && /^https?:\/\/[A-Za-z0-9.:\[\]-]+$/.test(origin) ? CAPTURE_CSP.replace(/'self'/g, origin) : CAPTURE_CSP;
}
const REMOVE_TAGS = new Set(['script',
'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'base', 'noscript', 'template', 'math',
'video', 'audio', 'source', 'track', 'portal', 'foreignobject', 'animate', 'set', 'animatetransform', 'animatemotion',
'meta', 'title-script',
]);
const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'xlink:href', 'srcset', 'poster', 'data', 'background', 'ping', 'cite', 'longdesc', 'manifest', 'codebase', 'usemap', 'lowsrc', 'dynsrc']);
const DROP_ATTRS = new Set(['srcdoc', 'http-equiv', 'is', 'formmethod', 'target', 'ping']);
const FONT_SHEET_HOSTS = /^https:\/\/(fonts\.googleapis\.com\/|cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome\/)/i;
const FONT_FILE_HOSTS = /^https:\/\/(fonts\.gstatic\.com\/|cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome\/)/i;
/** 1x1 grey PNG: stands in for an image the capture may not fetch (external host), keeping layout. */
export const BLOCKED_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO8d+/efwAIgQMeVxsvbAAAAABJRU5ErkJggg==';
const squash = (v: string) => v.replace(/[\s\u0000-\u001f\u007f-\u009f]/g, '').toLowerCase();
export interface SanitizeOptions {
/** Exact same-origin URLs (root-relative path + query) that may be fetched, e.g. the site's asset URLs. */
allowUrls?: string[];
/** Page origin, written into the frame CSP in place of 'self'. */
origin?: string;
}
interface Policy { allow: Set<string> }
/**
* A reference the capture frame may load: a data: image, a published-style `assets/<file>` path, or an
* exact URL the caller allow-listed. Every other relative or same-origin URL is refused: a GET to an
* arbitrary panel path (with the user's cookies) is not something page markup gets to trigger.
*/
function isLocalRef(raw: string, pol: Policy): boolean {
const t = raw.trim();
const v = squash(raw);
if (!v) return true;
if (v.startsWith('data:image/png') || v.startsWith('data:image/jpeg') || v.startsWith('data:image/gif') || v.startsWith('data:image/webp')) return true;
if (/^assets\/[A-Za-z0-9._%~+\/-]{1,200}$/.test(t) && !t.includes("..")) return true;
return pol.allow.has(t);
}
function safeLink(raw: string): boolean {
const v = squash(raw);
if (/^(javascript|vbscript|data|blob|file):/.test(v)) return false;
if (/^[a-z][a-z0-9+.-]*:/.test(v)) return /^(https?|mailto|tel):/.test(v);
return true;
}
/** Remove @import and any url() that is not a same-origin / data-image / allowed font reference. */
export function cleanCss(css: string, pol: Policy = { allow: new Set() }): string {
let out = css.replace(/@import\b[^;{}]*;?/gi, '');
out = out.replace(/expression\s*\(|-moz-binding|behavior\s*:/gi, 'x-removed(');
out = out.replace(/url\(\s*(["']?)([^)"']*)\1\s*\)/gi, (m, _q, u: string) => {
const t = u.trim();
if (isLocalRef(t, pol) || FONT_FILE_HOSTS.test(t)) return m;
return 'none';
});
return out;
}
export type SanitizeResult = { ok: true; html: string; csp?: string } | { ok: false; reason: string };
function walk(root: Element, doc: Document, pol: Policy): void {
const all = Array.from(root.querySelectorAll('*'));
for (const el of all) {
if (!el.isConnected && !root.contains(el)) continue;
const tag = el.localName.toLowerCase();
if (REMOVE_TAGS.has(tag)) { el.remove(); continue; }
if (tag === 'link') {
const rel = (el.getAttribute('rel') || '').toLowerCase();
const href = (el.getAttribute('href') || '').trim();
if (!(rel === 'stylesheet' && FONT_SHEET_HOSTS.test(href))) { el.remove(); continue; }
for (const a of Array.from(el.attributes)) if (!['rel', 'href', 'crossorigin', 'referrerpolicy'].includes(a.name)) el.removeAttribute(a.name);
continue;
}
if (tag === 'style') {
if (el.hasAttribute('data-capture')) { el.remove(); continue; } // ours is re-added last
const t = el.textContent || '';
if (t.includes('<')) { el.remove(); continue; } // markup-in-CSS is only ever an mXSS probe
el.textContent = cleanCss(t, pol);
}
if (tag === 'use') {
const h = el.getAttribute('href') || el.getAttribute('xlink:href') || '';
if (h && !h.trim().startsWith('#')) { el.remove(); continue; }
}
for (const a of Array.from(el.attributes)) {
const name = a.name.toLowerCase();
if (name.startsWith('on') || DROP_ATTRS.has(name)) { el.removeAttribute(a.name); continue; }
if (name === 'style') { el.setAttribute(a.name, cleanCss(a.value, pol)); continue; }
if (URL_ATTRS.has(name)) {
if (name === 'srcset') { el.removeAttribute(a.name); continue; } // one src is enough for a capture
if (name === 'href' && (tag === 'a' || tag === 'area')) { if (!safeLink(a.value)) el.removeAttribute(a.name); continue; }
if (name === 'href' && el.namespaceURI === 'http://www.w3.org/2000/svg') { if (!a.value.trim().startsWith('#')) el.removeAttribute(a.name); continue; }
if (!isLocalRef(a.value, pol)) {
if (name === 'src' && tag === 'img') { el.setAttribute('src', BLOCKED_IMAGE); el.removeAttribute('srcset'); } else el.removeAttribute(a.name);
}
}
}
}
void doc;
}
function core(html: string, pol: Policy): string | null {
let doc: Document;
try { doc = new DOMParser().parseFromString(html, 'text/html'); } catch { return null; }
const root = doc.documentElement;
if (!root) return null;
walk(root, doc, pol);
return root.outerHTML;
}
/** Our own head additions, applied last to already-clean markup (trusted constants). */
function addCaptureHead(clean: string, csp0: string): string | null {
let doc: Document;
try { doc = new DOMParser().parseFromString(clean, 'text/html'); } catch { return null; }
const root = doc.documentElement;
let head = doc.head;
if (!head) { head = doc.createElement('head'); root.insertBefore(head, root.firstChild); }
const csp = doc.createElement('meta');
csp.setAttribute('http-equiv', 'Content-Security-Policy');
csp.setAttribute('content', csp0);
head.insertBefore(csp, head.firstChild);
const un = doc.createElement('style');
un.setAttribute('data-capture', '');
// No script will ever add .animated, so reveal animated blocks and stop transitions.
un.textContent = '[data-animation]{opacity:1!important;animation:none!important}*{transition:none!important}';
head.appendChild(un);
return '<!DOCTYPE html>' + root.outerHTML;
}
/** Strip a model/user-derived page to something safe to render in the script-less capture frame. */
export function sanitizeCaptureHtml(html: string, opts: SanitizeOptions = {}): SanitizeResult {
const pol: Policy = { allow: new Set((opts.allowUrls ?? []).filter((u) => /^\/[^\s"'<>]*$/.test(u))) };
if (typeof html !== 'string' || !html.trim()) return { ok: false, reason: 'no page to capture' };
const first = core(html, pol);
if (first === null) return { ok: false, reason: 'page could not be parsed' };
// Mutation-XSS guard: what we output must re-parse to itself under the same sanitiser.
const second = core(first, pol);
if (second === null || second !== first) return { ok: false, reason: 'page markup was not stable under sanitising' };
const out = addCaptureHead(first, captureCsp(opts.origin));
return out === null ? { ok: false, reason: 'page could not be parsed' } : { ok: true, html: out, csp: captureCsp(opts.origin) };
}
+24 -8
View File
@@ -1,15 +1,19 @@
/**
* Client-side page capture for the Sitesmith self-review loop.
*
* Renders published-style HTML into a hidden same-origin iframe whose sandbox
* allows NO scripts (customer head code and model output can never run in the
* panel origin; `allow-same-origin` is only there so html-to-image can read the
* DOM) and rasterises it to a JPEG with html-to-image (MIT, zero dependencies).
* Renders published-style HTML into a hidden iframe and rasterises it to a JPEG with
* html-to-image (MIT, zero dependencies). The HTML is hostile input (model output, user
* text, customer head code): it is first run through sanitizeCaptureHtml (inert DOMParser
* walk: no script/frame/object/handlers/javascript: URLs/@import/foreign url(); fixed-point
* checked) and the frame itself has a sandbox WITHOUT allow-scripts plus a CSP that forbids
* scripts and all network access except fonts/stylesheets from two hosts. `allow-same-origin`
* is kept only so the parent can read the (already sanitised, script-less) frame document.
*
* Never throws: every failure is a `{ ok: false, reason }` so the caller can
* skip the review with a non-blocking note.
*/
import { toJpeg } from 'html-to-image';
import { sanitizeCaptureHtml } from './sanitize-capture-html';
export const FONT_AWESOME_CSS = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css';
@@ -26,6 +30,8 @@ export interface CaptureOptions {
maxBytes?: number;
/** Add the Font Awesome stylesheet when the HTML has none (Preview head may lack it). Default true. */
injectFontAwesome?: boolean;
/** Same-origin URLs (root-relative) the page may load, e.g. the site's asset proxy URLs. */
allowUrls?: string[];
/** Extra settle time after load for fonts/images (default 600 ms). */
settleMs?: number;
}
@@ -55,8 +61,15 @@ export function resolveAssetUrls(html: string, urls: Record<string, string>): st
let out = html;
for (const [rel, url] of Object.entries(urls)) {
if (!/^assets\/[A-Za-z0-9._%~+-]{1,200}$/.test(rel)) continue;
if (!/^(https?:\/\/|\/)[^\s"'<>]*$/.test(url)) continue;
out = out.split(rel).join(url);
// Same-origin only, written as a root-relative path (the sanitiser refuses absolute URLs).
let local = url;
try {
const u = new URL(url, typeof location !== 'undefined' ? location.href : 'http://localhost/');
if (typeof location !== 'undefined' && u.origin !== location.origin) continue;
local = u.pathname + u.search;
} catch { continue; }
if (!/^\/[^\s"'<>]*$/.test(local)) continue;
out = out.split(rel).join(local);
}
return out;
}
@@ -148,7 +161,10 @@ export async function captureHtml(html: string, opts: CaptureOptions = {}): Prom
await new Promise<void>((resolve, reject) => {
frame.addEventListener('load', () => resolve(), { once: true });
frame.addEventListener('error', () => reject(new Error('frame failed to load')), { once: true });
frame.srcdoc = prepareCaptureHtml(html, opts.injectFontAwesome !== false);
const clean = sanitizeCaptureHtml(prepareCaptureHtml(html, opts.injectFontAwesome !== false), { allowUrls: opts.allowUrls, origin: typeof location !== 'undefined' ? location.origin : undefined });
if (!clean.ok) { reject(new Error(`unsafe page not captured (${clean.reason})`)); return; }
frame.srcdoc = clean.html;
frame.setAttribute('csp', clean.csp ?? '');
document.body.appendChild(frame);
});
const doc = frame.contentDocument;
@@ -186,7 +202,7 @@ export async function captureHtml(html: string, opts: CaptureOptions = {}): Prom
try {
return await Promise.race([work, timeout]);
} catch (e) {
return { ok: false, reason: e instanceof Error ? e.message.slice(0, 120) : 'capture failed' };
return { ok: false, reason: e instanceof Error ? e.message.slice(0, 120) : (typeof Event !== 'undefined' && e instanceof Event ? `an image or resource failed to render (${e.type})` : 'capture failed') };
} finally {
if (timer) clearTimeout(timer);
frame.remove();
+45
View File
@@ -0,0 +1,45 @@
/** Client-side validation for mockup / reference-screenshot attachments (the server re-checks from the bytes). */
export const ATTACH_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const;
export const DEFAULT_MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
export type SniffedType = (typeof ATTACH_TYPES)[number] | null;
/** Decide the type from the file's first bytes, never from its name or declared MIME. */
export async function sniffImageType(file: Blob): Promise<SniffedType> {
let b: Uint8Array;
try { b = new Uint8Array(await file.slice(0, 12).arrayBuffer()); } catch { return null; }
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png';
if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return 'image/gif';
if (b.length >= 12 && String.fromCharCode(...b.slice(0, 4)) === 'RIFF' && String.fromCharCode(...b.slice(8, 12)) === 'WEBP') return 'image/webp';
return null;
}
export function formatBytes(n: number): string {
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
if (n >= 1024) return `${Math.round(n / 1024)} KB`;
return `${n} B`;
}
/** null when the file is acceptable; otherwise a plain-language reason. */
export async function validateAttachment(file: File, maxBytes = DEFAULT_MAX_UPLOAD_BYTES): Promise<string | null> {
const label = file.name ? `"${file.name.slice(0, 60)}"` : 'That file';
if (file.size === 0) return `${label} is empty.`;
if (file.size > maxBytes) return `${label} is ${formatBytes(file.size)}; the limit is ${formatBytes(maxBytes)}. Use a smaller image.`;
const t = await sniffImageType(file);
if (!t) return `${label} is not a JPEG, PNG, WebP or GIF image (SVG, PDF and other formats are not accepted).`;
return null;
}
/** Only http(s) URLs (or a bare domain) are accepted as a "site I like" label; it is never fetched. */
export function normalizeUrlLabel(raw: string): { ok: true; value: string } | { ok: false; message: string } {
const v = raw.trim();
if (!v) return { ok: false, message: 'Enter the site address, for example example.com.' };
if (v.length > 300) return { ok: false, message: 'That address is too long (300 characters at most).' };
if (/[\u0000-\u001f<>"]/.test(v)) return { ok: false, message: 'The address contains characters that are not allowed.' };
if (/^[a-z][a-z0-9+.-]*:/i.test(v) && !/^https?:\/\//i.test(v)) {
return { ok: false, message: 'Only http:// or https:// addresses are accepted.' };
}
return { ok: true, value: v };
}
+39
View File
@@ -0,0 +1,39 @@
/**
* The site design block sent with every Sitesmith request, in the shape the backend prompt builder
* (sitesmith_design_block in libs/sitesmith-prompts.php) reads: SiteDesign-named keys plus
* `brandColors: [{name?, hex}]` (up to 6, from the colour-system swatches flagged `brand`).
* The backend re-validates every value; this only keeps obviously unusable values out.
*/
import type { SiteDesign } from '../state/SiteDesignContext';
export interface DesignSwatch { label?: string; hex?: string; brand?: boolean }
const KEYS = [
'primaryColor', 'secondaryColor', 'accentColor', 'backgroundColor', 'textColor', 'mutedTextColor',
'borderColor', 'headingFont', 'bodyFont', 'borderRadius', 'buttonRadius',
] as const;
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
export function buildDesignPayload(design: SiteDesign & { swatches?: DesignSwatch[] }): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const k of KEYS) {
const v = (design as unknown as Record<string, unknown>)[k];
if (typeof v !== 'string' || !v.trim()) continue;
// Font stacks: the backend accepts letters, digits, space, comma, dash and underscore only.
out[k] = k.endsWith('Font') ? v.replace(/["']/g, '').trim() : v.trim();
}
const sw = (design as { swatches?: unknown }).swatches;
if (Array.isArray(sw)) {
const brand = sw
.filter((s): s is DesignSwatch => !!s && typeof s === 'object' && (s as DesignSwatch).brand === true
&& typeof (s as DesignSwatch).hex === 'string' && HEX.test((s as DesignSwatch).hex!.trim()))
.slice(0, 6)
.map((s) => {
const name = typeof s.label === 'string' ? s.label.replace(/[\u0000-\u001f<>]/g, '').trim().slice(0, 40) : '';
return name ? { name, hex: s.hex!.trim().toLowerCase() } : { hex: s.hex!.trim().toLowerCase() };
});
if (brand.length) out.brandColors = brand;
}
return out;
}