Sitesmith capture: CSS judged via CSSOM (no regex), single URL gate (no % / dot segments / backslash), all resources inlined through a bounded no-credentials fetch, CSP img-src data: only; bypass-class proof tests (unit + Firefox/Chromium)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 10:37:56 -08:00
1 parent c4f45541a3
commit f0b3dd1dba
7 files changed
+523 -93

No files matched your search

+2 -2
View File
@@ -1,13 +1,13 @@
// 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';
import { CAPTURE_CSP } 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);
const CSP = CAPTURE_CSP;
f.setAttribute('csp', CSP);
f.style.cssText = 'position:fixed;left:-9999px;width:800px;height:600px';
f.addEventListener('load', () => setTimeout(resolve, 800), { once: true });
+13 -2
View File
@@ -36,6 +36,17 @@ const EVIL = `<!doctype html><html><head>
<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>
<style>
.b1{background:u\\72l(/beacon?b-escape)} .b2{background-image:image-set("/beacon?b-imageset" 1x)} .b3{background:url("x)/beacon?b-paren")}
.b4{background:ur/**/l(/beacon?b-comment)} :root{--u:url(/beacon?b-var)} .b5{background:var(--u)}
@font-face{font-family:bx;src:url(/beacon?b-fontface)} .b6{font-family:bx}
@font-face{font-family:by;src:url(/api/site-builder.php?action=delete_asset)} .b7{font-family:by}
.b8{background:url(assets/%2e%2e/beacon?b-pct)} .b9{background:url(assets/%252e%252e/beacon?b-pct2)} .b10{background:url(assets/..%2fbeacon?b-pct3)}
.b11{background:url(javascript:1)} .b12{background:url(blob:http://x/y)} .b13{background:url("data:text/html,<b>1</b>")} .b14{-moz-binding:url(/beacon?b-moz)}
@keyframes bk{from{background:url(/beacon?b-kf)}to{color:red}} @media (min-width:1px){.b15{background:url(/beacon?b-media)}}
</style>
<div class="b1 b2 b3 b4 b5 b6 b7 b8 b9 b10 b11 b12 b13 b14 b15" style="background:u\\72l(/beacon?i-escape);background-image:image-set('/beacon?i-set' 1x)">css bypass probes</div>
<img src="assets/%2e%2e/beacon?p1"><img src="assets/%252e%252e/beacon?p2"><img src="assets/..%2fbeacon?p3"><img src="assets/../beacon?p4"><img src="//localhost/beacon?p5"><img src="data:text/html,x"><img src="blob:http://x/y"><img src="assets\\..\\beacon?p6">
<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>
@@ -73,7 +84,7 @@ async function run(name, launcher, exe) {
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: ZERO requests to the panel origin /beacon (every CSS/URL/encoding bypass class)', 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));
@@ -86,7 +97,7 @@ async function run(name, launcher, exe) {
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: CSP alone (img-src data:) lets no request reach the panel origin', beacons.length === 0, 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()));
+61
View File
@@ -0,0 +1,61 @@
import type { SendResult } from '../../types/sitesmith';
import type { CaptureResult } from '../../utils/self-review-capture';
/** Instruction for the one follow-up job. The render rides in the mockup slot, so say plainly what it is. */
export const REVIEW_MESSAGE =
'The attached image is a screenshot of the page as it is built right now. It is NOT a mockup to copy. ' +
'Review it like a careful designer: look for text that is hard to read, cramped or uneven spacing, misaligned or ' +
'broken layout, images that are cropped badly, missing icons, and sections that look generic. ' +
'Fix only real problems with a small patch; if the page already looks right, answer with a short "ask"-free patch with no ops and say it looks good. ' +
'Do not redesign the page and do not change the wording.';
export interface SelfReviewDeps {
/** Published-style HTML of the current page (untrusted; the capture sanitises it). */
composeHtml: () => string;
/** `assets/<file>` -> same-origin URL, for images in the capture. */
assetUrls: Record<string, string>;
allowUrls: string[];
capture: (html: string, o: { allowUrls: string[]; timeoutMs: number }) => Promise<CaptureResult>;
upload: (file: File) => Promise<{ ok: true; id: string } | { ok: false; message: string }>;
/** Start the follow-up job and wait for it. */
review: (message: string, imageId: string) => Promise<SendResult>;
/** Apply the review result (contrast repair included). */
applyResult: (res: SendResult) => Promise<{ ok: boolean; message?: string; summary?: string }>;
removeImage: (id: string) => Promise<unknown>;
resolveAssetUrls: (html: string, urls: Record<string, string>) => string;
}
export type SelfReviewOutcome =
| { status: 'applied'; note: string }
| { status: 'looks-good'; note: string }
| { status: 'skipped'; note: string };
const skip = (why: string): SelfReviewOutcome => ({ status: 'skipped', note: `Self-review skipped: ${why}. Your page was built as normal.` });
/** One review round, never more. Never throws; any failure is a non-blocking note. */
export async function runSelfReview(d: SelfReviewDeps): Promise<SelfReviewOutcome> {
let imageId: string | null = null;
try {
let html: string;
try { html = d.resolveAssetUrls(d.composeHtml(), d.assetUrls); } catch { return skip('the page could not be rendered'); }
const cap = await d.capture(html, { allowUrls: d.allowUrls, timeoutMs: 20000 });
if (!cap.ok) return skip(cap.reason);
const file = new File([cap.blob], 'self-review-render.jpg', { type: 'image/jpeg' });
const up = await d.upload(file);
if (!up.ok) return skip(up.message || 'the screenshot could not be uploaded');
imageId = up.id;
const res = await d.review(REVIEW_MESSAGE, imageId);
if (!res.ok) return skip(res.message || 'the review request failed');
if (res.response.type === 'ask') return { status: 'looks-good', note: 'Self-review: Sitesmith had no changes to make.' };
if (res.response.type === 'patch' && res.response.ops.length === 0) return { status: 'looks-good', note: 'Self-review: Sitesmith looked at the finished page and found nothing to fix.' };
const applied = await d.applyResult(res);
if (!applied.ok) return skip(applied.message || 'the fixes could not be applied');
const extra = applied.summary ? ` ${applied.summary}.` : '';
return { status: 'applied', note: `Self-review: Sitesmith looked at the finished page and made fixes.${extra} Use Undo to go back.` };
} catch (e) {
return skip(e instanceof Error && e.message ? e.message.slice(0, 100) : 'something went wrong');
} finally {
// The render is private scratch, not a keepsake: it would otherwise eat the per-site image quota.
if (imageId) { try { await d.removeImage(imageId); } catch { /* best effort */ } }
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { SiteDesign } from '../state/SiteDesignContext';
import { exportBodyHtml, exportComposedPreviewHtml } from './html-export';
export interface ComposeInput {
/** `query.serialize()` of the page on the canvas. */
serialized: string;
headerState: string | null;
footerState: string | null;
title: string;
design: SiteDesign;
}
/**
* Published-style document for the page (header + body + footer, token CSS, fonts), built exactly like
* the editor's Preview but WITHOUT the site's custom head code (customer scripts must never reach the
* capture). The result is still untrusted and goes through sanitizeCaptureHtml before it is rendered.
*/
export function composeReviewHtml(i: ComposeInput): string {
const header = i.headerState ? exportBodyHtml(i.headerState, 'header').html : '';
const body = exportBodyHtml(i.serialized).html;
const footer = i.footerState ? exportBodyHtml(i.footerState, 'footer').html : '';
return exportComposedPreviewHtml(i.serialized, header + body + footer, {
title: i.title || 'Page',
includeFonts: true,
design: i.design,
favicon: '',
});
}
+90 -7
View File
@@ -105,12 +105,95 @@ describe('sanitizeCaptureHtml', () => {
});
});
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);
describe('CSS is judged from the parsed model, not by regex (bypass classes)', () => {
const css = (c: string, allow: string[] = []) => {
const r = sanitizeCaptureHtml(`<html><head><style>${c}</style></head><body><p>x</p></body></html>`, { allowUrls: allow }) as any;
expect(r.ok).toBe(true);
return dom(r.html).querySelector('style:not([data-capture])')?.textContent ?? '';
};
const inline = (v: string) => {
const r = sanitizeCaptureHtml(`<html><body><div id="d" style="${v.replace(/"/g, '&quot;')}">x</div></body></html>`) as any;
return dom(r.html).getElementById('d')?.getAttribute('style') ?? null;
};
const noFetch = (out: string) => {
expect(out).not.toMatch(/beacon|evil|\/api\//);
expect(out).not.toMatch(/image-set|@import|expression|-moz-binding|behavior/i);
};
test.each([
['escape-encoded url(', 'p{background:u\\72l(/beacon?e1)}'],
['escape inside the target', 'p{background:url("\\2f beacon?e2")}'],
['image-set with string', 'p{background-image:image-set("/beacon?e3" 1x)}'],
['-webkit-image-set', 'p{background-image:-webkit-image-set(url(/beacon?e4) 1x)}'],
['image() / cross-fade()', 'p{background-image:cross-fade(url(/beacon?e5),url(/beacon?e5b),50%)}'],
['quoted ) in the url', 'p{background:url("x)/beacon?e6")}'],
['comment splitting the token', 'p{background:ur/**/l(/beacon?e7)}'],
['comment inside url()', 'p{background:url(/*x*/ /beacon?e8)}'],
['custom-property indirection', ':root{--u:url(/beacon?e9)} p{background:var(--u)}'],
['@import', '@import url(/beacon?e10); p{color:red}'],
['escaped @import', '@im\\70ort url(/beacon?e11); p{color:red}'],
['@namespace', '@namespace url(/beacon?e12); p{color:red}'],
['expression()', 'p{width:expression(alert(1))}'],
['-moz-binding / behavior', 'p{-moz-binding:url(/beacon?e13);behavior:url(/beacon?e14)}'],
['@font-face src to a same-origin API endpoint', '@font-face{font-family:x;src:url(/api/site-builder.php?action=delete_asset)} p{font-family:x}'],
['@font-face src, other host', '@font-face{font-family:x;src:url(https://evil.example/f.woff2)}'],
['percent-encoded traversal', 'p{background:url(assets/%2e%2e/api/x)}'],
['double-encoded traversal', 'p{background:url(assets/%252e%252e/api/x)}'],
['mixed-case encoded', 'p{background:url(assets/%2E%2E%2Fapi/x)}'],
['dot segments', 'p{background:url(assets/../api/x)}'],
['protocol-relative', 'p{background:url(//evil.example/x)}'],
['javascript: url', 'p{background:url(javascript:alert(1))}'],
['blob: / data:text', 'p{background:url(blob:http://x/y)} q{background:url("data:text/html,<script>1</script>")}'],
['keyframes with url', '@keyframes k{from{background:url(/beacon?e15)}to{color:red}}'],
['@media wrapping', '@media (min-width:1px){p{background:url(/beacon?e16)}}'],
])('%s is dropped', (_n, c) => {
noFetch(css(c));
});
test('benign CSS survives, re-serialised from the parsed model', () => {
const out = css('@media (max-width:600px){.a{color:red}} .b{background:linear-gradient(135deg,#0f172a,#334155);padding:8px} @keyframes k{from{opacity:0}to{opacity:1}} .c{background:url(assets/p.png)}');
expect(out).toContain('@media');
expect(out).toContain('linear-gradient');
expect(out).toContain('@keyframes k');
expect(out).toMatch(/url\(["']?assets\/p\.png["']?\)/);
});
test('exact allow-listed URLs pass in CSS and attributes; near misses do not', () => {
const a = '/api/site-builder.php?action=asset&f=a.png';
expect(css(`p{background:url(${a})}`, [a])).toContain(a);
expect(css(`p{background:url(${a}&x=1)}`, [a])).not.toContain('action=asset');
});
test('inline style: injection and fetchers are dropped, plain declarations kept', () => {
expect(inline('color:red;padding:4px')).toContain('color:red');
expect(inline('background:url(/beacon?i1)') ?? '').not.toMatch(/beacon|image-set|url\(/i);
expect(inline('color:red}b{background:url(/beacon?i2)') ?? '').not.toMatch(/beacon/);
expect(inline('background:image-set("/beacon?i3" 1x)') ?? '').not.toMatch(/beacon|image-set|url\(/i);
expect(inline('background:u\\72l(/beacon?i4)') ?? '').not.toMatch(/beacon|image-set|url\(/i);
expect(inline('background-image:url(assets/p.png);color:red')).toContain('color:red');
});
test.each([
['assets/%2e%2e/api/x'], ['assets/%2E%2E/x'], ['assets/%252e%252e/x'], ['assets/..%2fx'], ['assets/a%2Fb'],
['assets\\..\\x'], ['assets/../x'], ['assets/./x'], ['//evil.example/x'], ['/beacon?x'], ['x.png'], ['blob:http://x/y'],
['javascript:alert(1)'], ['data:text/html;base64,PHNjcmlwdD4='], ['data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='], ['assets/a\u0000b'],
])('img src %j is replaced by the placeholder', (src) => {
const r = sanitizeCaptureHtml(`<html><body><img id="i" src="${src.replace(/"/g, '&quot;')}"></body></html>`) as any;
expect(dom(r.html).getElementById('i')!.getAttribute('src')).toBe(BLOCKED_IMAGE);
});
test('plain assets/ images are kept', () => {
const r = sanitizeCaptureHtml('<html><body><img id="i" src="assets/photo-1.webp"><img id="j" src="assets/sub/dir/p.png"></body></html>') as any;
expect(dom(r.html).getElementById('i')!.getAttribute('src')).toBe('assets/photo-1.webp');
expect(dom(r.html).getElementById('j')!.getAttribute('src')).toBe('assets/sub/dir/p.png');
});
test('SVG presentation attributes may only reference in-document ids', () => {
const r = sanitizeCaptureHtml('<html><body><svg><rect id="a" fill="url(#g)" filter="url(/beacon?f)" mask="url(https://evil.example/m)"/></svg></body></html>') as any;
const rect = dom(r.html).getElementById('a')!;
expect(rect.getAttribute('fill')).toBe('url(#g)');
expect(rect.hasAttribute('filter')).toBe(false);
expect(rect.hasAttribute('mask')).toBe(false);
});
});
+232 -62
View File
@@ -2,101 +2,254 @@
* 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.
* own CSP (meta) forbids scripts and every network load except fonts/stylesheets from 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).
* CSS is NEVER filtered with regular expressions. Every stylesheet and style attribute is parsed by the
* browser's own CSSOM (inert), each rule and declaration is judged from the PARSED, normalised value
* (so escapes, comments, quoting and image-set() tricks are already resolved), and the output is
* re-serialised from that model. Anything not understood is dropped, not repaired.
*
* URLs (attributes and CSS alike) go through one strict gate, `checkUrl`: data: images, `assets/<file>`
* (no percent signs, no dot segments), the caller's exact allow-list, and font files from named hosts.
* Nothing else is ever fetched by the editor on the page's behalf.
*
* Guarantees (sanitize-capture-html.test.ts + 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, escape-encoded or image-set() url, or non-allow-listed url() survives; the 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:",
'img-src 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",
'font-src 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',
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',
'meta',
]);
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;
export const FONT_SHEET_HOSTS = /^https:\/\/(fonts\.googleapis\.com\/|cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome\/)/i;
export 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. */
/** 1x1 grey PNG: stands in for an image the capture may not fetch, 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. */
/** Exact URLs (root-relative path + query, as the editor knows them) 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> }
export interface Policy { allow: Set<string> }
export function makePolicy(allowUrls: string[] = []): Policy {
return { allow: new Set(allowUrls.filter((u) => typeof u === 'string' && /^\/[^\s"'<>\\]*$/.test(u) && !u.startsWith('//'))) };
}
const CTRL = /[\u0000-\u001f\u007f-\u009f\\]/;
const DATA_IMAGE = /^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+$/;
const DATA_FONT = /^data:(font\/(woff2?|ttf|otf)|application\/(font-woff2?|x-font-(woff|ttf|otf)|vnd\.ms-fontobject|octet-stream));base64,[A-Za-z0-9+/=]+$/;
const ASSET_PATH = /^assets\/[A-Za-z0-9._~+-]+(?:\/[A-Za-z0-9._~+-]+)*$/;
/**
* 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.
* The single URL gate. Returns the canonical form to emit, or null when the URL may not be used.
* `font` widens it to font data URLs and the named font hosts.
*/
function isLocalRef(raw: string, pol: Policy): boolean {
export function checkUrl(raw: string, pol: Policy, font = false): string | null {
if (typeof raw !== 'string') return null;
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);
if (!t || t.length > 4000) return null;
if (pol.allow.has(t)) return t; // exact, caller-supplied (server-provided asset URLs)
if (CTRL.test(t) || t.startsWith('//') || t.includes('%')) return null;
if (DATA_IMAGE.test(t)) return t;
if (font && DATA_FONT.test(t)) return t;
if (ASSET_PATH.test(t) && !t.split('/').some((s) => /^\.+$/.test(s))) return t;
if (font) {
let u: URL;
try { u = new URL(t); } catch { return null; }
if (u.protocol === 'https:' && !u.username && !u.password && FONT_FILE_HOSTS.test(u.href) && !u.href.includes('%')) return u.href;
}
return null;
}
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;
// ---------------------------------------------------------------- CSS (CSSOM, never regex)
const RULE = { STYLE: 1, MEDIA: 4, FONT_FACE: 5, KEYFRAMES: 7, KEYFRAME: 8, SUPPORTS: 12 } as const;
const BAD_PROP = /^(behavior|-moz-binding|-ms-behavior)$/i;
// Functions that can fetch: anything but plain url() is dropped outright.
const FETCH_FN = /(?:^|[^a-z0-9_-])(image-set|-webkit-image-set|src|cross-fade|-webkit-cross-fade|element|-moz-element|paint|image)\s*\(/i;
const FETCH_ANY = /(?:^|[^a-z0-9_-])(url|image-set|-webkit-image-set|src|cross-fade|-webkit-cross-fade|element|-moz-element|paint|image)\s*\(/i;
const SAFE_KEY = /^[A-Za-z0-9%., -]+$/;
const SAFE_IDENT = /^[A-Za-z0-9_-]+$/;
function baseDir(): string {
try { return new URL('./', typeof document !== 'undefined' ? document.baseURI : 'http://localhost/').href; } catch { return ''; }
}
/** 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';
});
/** Browsers serialise url() targets resolved against the document: map back to what was written. */
function unresolve(u: string): string[] {
const out = [u];
const dir = baseDir();
if (dir && u.startsWith(dir)) out.push(u.slice(dir.length));
try {
const abs = new URL(u);
if (typeof document !== 'undefined' && abs.origin === new URL(document.baseURI).origin) out.push(abs.pathname + abs.search);
} catch { /* relative: already in `out` */ }
return out;
}
export type SanitizeResult = { ok: true; html: string; csp?: string } | { ok: false; reason: string };
/** Judge one parsed declaration value: every fetching function must be plain url() with an allowed target. */
function valueOk(value: string, pol: Policy, font: boolean): boolean {
if (/[\\]/.test(value)) return false;
if (/expression\s*\(/i.test(value) || /javascript:/i.test(value)) return false;
if (!FETCH_ANY.test(value)) return true;
if (FETCH_FN.test(value)) return false;
let rest = value;
const re = /url\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^"'()\s\\]+))\s*\)/gi;
let m: RegExpExecArray | null;
let n = 0;
while ((m = re.exec(value))) {
n++;
const target = m[1] ?? m[2] ?? m[3] ?? '';
if (!unresolve(target).some((c) => checkUrl(c, pol, font))) return false;
}
rest = value.replace(re, '');
if (/url\s*\(/i.test(rest)) return false; // an url( we could not parse as a plain quoted/bare token
return n > 0;
}
function walk(root: Element, doc: Document, pol: Policy): void {
/** Canonical form of every url() in an accepted value, so the emitted CSS carries the checked targets only. */
function canonicalize(value: string, pol: Policy, font: boolean): string {
return value.replace(/url\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^"'()\s\\]+))\s*\)/gi, (_m, a, b, c) => {
const target = a ?? b ?? c ?? '';
for (const cand of unresolve(target)) {
const ok = checkUrl(cand, pol, font);
if (ok) return `url("${ok.replace(/"/g, '')}")`;
}
return 'none';
});
}
function declarations(style: CSSStyleDeclaration, pol: Policy, font = false): string {
const out: string[] = [];
for (let i = 0; i < style.length; i++) {
const prop = style[i];
if (!prop || /[^A-Za-z0-9_-]/.test(prop) || BAD_PROP.test(prop)) continue;
const value = style.getPropertyValue(prop);
if (!value || !valueOk(value, pol, font || prop === 'src')) continue;
const pri = style.getPropertyPriority(prop) ? ' !important' : '';
out.push(`${prop}:${canonicalize(value, pol, font || prop === 'src')}${pri}`);
}
return out.join(';');
}
function parseSheet(css: string): CSSRuleList | null {
try {
if (typeof CSSStyleSheet !== 'undefined') {
const s = new CSSStyleSheet();
s.replaceSync(css);
return s.cssRules;
}
} catch { /* fall through to the inert document */ }
try {
const d = document.implementation.createHTMLDocument('');
const st = d.createElement('style');
st.textContent = css;
d.head.appendChild(st);
return st.sheet ? st.sheet.cssRules : null;
} catch { return null; }
}
function rulesToCss(rules: CSSRuleList, pol: Policy): string {
const out: string[] = [];
for (const r of Array.from(rules)) {
switch (r.type) {
case RULE.STYLE: {
const sr = r as CSSStyleRule;
if (/\\/.test(sr.selectorText) || /[<>]/.test(sr.selectorText.replace(/[>+~]/g, ''))) break;
const d = declarations(sr.style, pol);
if (d) out.push(`${sr.selectorText}{${d}}`);
break;
}
case RULE.FONT_FACE: {
const d = declarations((r as CSSFontFaceRule).style, pol, true);
if (/(^|;)src:/.test(d)) out.push(`@font-face{${d}}`);
break;
}
case RULE.MEDIA: {
const mr = r as CSSMediaRule;
const cond = mr.media.mediaText;
if (/[\\{}<]/.test(cond)) break;
const inner = rulesToCss(mr.cssRules, pol);
if (inner) out.push(`@media ${cond}{${inner}}`);
break;
}
case RULE.SUPPORTS: {
const sr = r as CSSRule & { conditionText?: string; cssRules: CSSRuleList };
const cond = sr.conditionText ?? '';
if (!cond || /[\\{}<]/.test(cond)) break;
const inner = rulesToCss(sr.cssRules, pol);
if (inner) out.push(`@supports ${cond}{${inner}}`);
break;
}
case RULE.KEYFRAMES: {
const kr = r as CSSKeyframesRule;
if (!SAFE_IDENT.test(kr.name)) break;
const frames: string[] = [];
for (const f of Array.from(kr.cssRules) as CSSKeyframeRule[]) {
if (f.type !== RULE.KEYFRAME || !SAFE_KEY.test(f.keyText)) continue;
const d = declarations(f.style, pol);
if (d) frames.push(`${f.keyText}{${d}}`);
}
if (frames.length) out.push(`@keyframes ${kr.name}{${frames.join('')}}`);
break;
}
default:
// @import, @namespace, @page, @property, @layer, @container, unknown at-rules: dropped.
break;
}
}
return out.join('\n');
}
/** Sanitise a stylesheet's text through the CSSOM. Empty string when nothing safe is left. */
export function cleanCss(css: string, pol: Policy = { allow: new Set() }): string {
const rules = parseSheet(css);
return rules ? rulesToCss(rules, pol) : '';
}
/** Sanitise a style="" attribute value; '' when it is not a plain declaration list. */
export function cleanInlineStyle(value: string, pol: Policy = { allow: new Set() }): string {
const rules = parseSheet(`a{${value}}`);
if (!rules || rules.length !== 1 || rules[0].type !== RULE.STYLE) return '';
const sr = rules[0] as CSSStyleRule;
if (sr.selectorText.trim() !== 'a') return '';
return declarations(sr.style, pol);
}
// ---------------------------------------------------------------- HTML
export type SanitizeResult = { ok: true; html: string; csp: string } | { ok: false; reason: string };
function walk(root: Element, 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; }
if (!(rel === 'stylesheet' && FONT_SHEET_HOSTS.test(href) && !CTRL.test(href) && !href.includes('%2'))) { el.remove(); continue; }
for (const a of Array.from(el.attributes)) if (!['rel', 'href', 'crossorigin', 'referrerpolicy'].includes(a.name)) el.removeAttribute(a.name);
continue;
}
@@ -113,18 +266,35 @@ function walk(root: Element, doc: Document, pol: Policy): void {
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 (name === 'style') {
const s = cleanInlineStyle(a.value, pol);
if (s) el.setAttribute(a.name, s); else el.removeAttribute(a.name);
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);
}
const ok = checkUrl(a.value, pol);
if (ok) { if (ok !== a.value) el.setAttribute(a.name, ok); }
else if (name === 'src' && tag === 'img') { el.setAttribute('src', BLOCKED_IMAGE); el.removeAttribute('srcset'); }
else el.removeAttribute(a.name);
continue;
}
// Presentation attributes (fill, filter, mask...) may carry url(): only in-document #refs.
if (/url\s*\(/i.test(a.value) || /[\\]/.test(a.value) && /url/i.test(a.value)) {
const only = Array.from(a.value.matchAll(/url\(\s*["']?([^"')]*)["']?\s*\)/gi)).every((m) => m[1].trim().startsWith('#'));
if (!only || /\\/.test(a.value)) el.removeAttribute(a.name);
}
}
}
void doc;
}
function safeLink(raw: string): boolean {
const v = raw.replace(/[\s\u0000-\u001f\u007f-\u009f]/g, '').toLowerCase();
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;
}
function core(html: string, pol: Policy): string | null {
@@ -132,12 +302,12 @@ function core(html: string, pol: Policy): string | null {
try { doc = new DOMParser().parseFromString(html, 'text/html'); } catch { return null; }
const root = doc.documentElement;
if (!root) return null;
walk(root, doc, pol);
walk(root, pol);
return root.outerHTML;
}
/** Our own head additions, applied last to already-clean markup (trusted constants). */
function addCaptureHead(clean: string, csp0: string): string | null {
function addCaptureHead(clean: string): string | null {
let doc: Document;
try { doc = new DOMParser().parseFromString(clean, 'text/html'); } catch { return null; }
const root = doc.documentElement;
@@ -145,7 +315,7 @@ function addCaptureHead(clean: string, csp0: string): string | null {
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);
csp.setAttribute('content', CAPTURE_CSP);
head.insertBefore(csp, head.firstChild);
const un = doc.createElement('style');
un.setAttribute('data-capture', '');
@@ -157,13 +327,13 @@ function addCaptureHead(clean: string, csp0: string): string | null {
/** 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))) };
const pol = makePolicy(opts.allowUrls);
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) };
const out = addCaptureHead(first);
return out === null ? { ok: false, reason: 'page could not be parsed' } : { ok: true, html: out, csp: CAPTURE_CSP };
}
+97 -20
View File
@@ -13,7 +13,7 @@
* skip the review with a non-blocking note.
*/
import { toJpeg } from 'html-to-image';
import { sanitizeCaptureHtml } from './sanitize-capture-html';
import { sanitizeCaptureHtml, checkUrl, makePolicy, BLOCKED_IMAGE, FONT_FILE_HOSTS, type Policy } from './sanitize-capture-html';
export const FONT_AWESOME_CSS = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css';
@@ -76,6 +76,8 @@ export function resolveAssetUrls(html: string, urls: Record<string, string>): st
const MAX_FONT_FACES = 24;
const MAX_FONT_BYTES = 2.5 * 1024 * 1024;
const MAX_RESOURCE_BYTES = 4 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 8000;
const blobToDataUrl = (b: Blob) => new Promise<string>((resolve, reject) => {
const r = new FileReader();
@@ -84,6 +86,29 @@ const blobToDataUrl = (b: Blob) => new Promise<string>((resolve, reject) => {
r.readAsDataURL(b);
});
/**
* The ONLY network path the capture uses on a page's behalf. A URL must be one the sanitiser would have
* allowed (exact allow-list entry, assets/ path, or a font file on a named host); no credentials unless it
* is an exact allow-listed (server-provided, same-origin) URL; no cache; no redirects; size and time caps.
*/
export async function fetchBounded(url: string, pol: Policy, maxBytes = MAX_RESOURCE_BYTES): Promise<Blob | null> {
const isFont = FONT_FILE_HOSTS.test(url);
if (!isFont && !checkUrl(url, pol)) return null;
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
credentials: !isFont && pol.allow.has(url) ? 'same-origin' : 'omit',
cache: 'no-store', redirect: 'error', signal: ctl.signal,
});
if (!res.ok) return null;
const len = Number(res.headers.get('content-length') || 0);
if (len > maxBytes) return null;
const blob = await res.blob();
return blob.size > maxBytes ? null : blob;
} catch { return null; } finally { clearTimeout(timer); }
}
/** True when a @font-face unicode-range could cover basic Latin (or has no range at all). */
function coversLatin(range: string): boolean {
if (!range.trim()) return true;
@@ -97,12 +122,12 @@ function coversLatin(range: string): boolean {
}
/**
* Our own web-font embedding. html-to-image's built-in version silently produced
* fallback fonts for Google Fonts, Font Awesome and same-origin @font-face in the
* measurement (see the capture notes), so we inline every readable @font-face as a
* data URL ourselves and hand the result in as `fontEmbedCSS`.
* Our own web-font embedding (html-to-image's built-in version silently produced fallback fonts for
* Google Fonts and Font Awesome in the measurement). Reads the frame's stylesheets (only sanitiser-allowed
* <link>s and our already-sanitised <style>s exist there), and inlines each @font-face file as a data URL
* through fetchBounded, which refuses any URL that is not a named font host or an allow-listed asset.
*/
export async function buildFontEmbedCss(doc: Document): Promise<{ css: string; faces: number; failed: number }> {
export async function buildFontEmbedCss(doc: Document, pol: Policy = makePolicy()): Promise<{ css: string; faces: number; failed: number }> {
const jobs: Promise<string | null>[] = [];
let failed = 0;
let total = 0;
@@ -110,7 +135,7 @@ export async function buildFontEmbedCss(doc: Document): Promise<{ css: string; f
let rules: CSSRuleList;
try { rules = sheet.cssRules; } catch { failed++; continue; }
for (const rule of Array.from(rules)) {
if (!(rule instanceof (doc.defaultView as unknown as typeof globalThis).CSSFontFaceRule)) continue;
if (rule.type !== 5) continue; // CSSFontFaceRule
const ff = rule as CSSFontFaceRule;
if (!coversLatin(ff.style.getPropertyValue('unicode-range'))) continue;
if (jobs.length >= MAX_FONT_FACES) break;
@@ -120,17 +145,17 @@ export async function buildFontEmbedCss(doc: Document): Promise<{ css: string; f
const family = ff.style.getPropertyValue('font-family');
const weight = ff.style.getPropertyValue('font-weight') || 'normal';
const style = ff.style.getPropertyValue('font-style') || 'normal';
if (/[;{}\\<]/.test(family + weight + style)) continue;
jobs.push((async () => {
for (const u of urls) {
if (u.startsWith('data:')) return `@font-face{font-family:${family};font-weight:${weight};font-style:${style};src:url(${u})}`;
try {
const res = await fetch(new URL(u, base).href);
if (!res.ok) continue;
const blob = await res.blob();
total += blob.size;
if (total > MAX_FONT_BYTES) return null;
return `@font-face{font-family:${family};font-weight:${weight};font-style:${style};src:url(${await blobToDataUrl(blob)})}`;
} catch { /* try the next format */ }
let abs: string;
try { abs = new URL(u, base).href; } catch { continue; }
const blob = await fetchBounded(abs, pol, MAX_FONT_BYTES);
if (!blob) continue;
total += blob.size;
if (total > MAX_FONT_BYTES) return null;
try { return `@font-face{font-family:${family};font-weight:${weight};font-style:${style};src:url(${await blobToDataUrl(blob)})}`; } catch { /* next */ }
}
failed++;
return null;
@@ -141,6 +166,54 @@ export async function buildFontEmbedCss(doc: Document): Promise<{ css: string; f
return { css: parts.join('\n'), faces: parts.length, failed };
}
const IMAGE_TYPE = /^image\/(png|jpeg|gif|webp)$/;
const FONT_TYPE = /^(font\/|application\/(font|x-font|octet-stream))/;
/**
* Replace every remaining (sanitiser-approved) URL in the clean page with a data: URL, fetched once each
* through fetchBounded, so the frame itself performs no image/font loads (its CSP allows data: only) and
* html-to-image has nothing left to fetch. A URL that cannot be fetched, or whose bytes are not an image
* (img src, CSS images) or a font (@font-face) becomes the grey placeholder / `none`.
*/
export async function inlineResources(cleanHtml: string, pol: Policy): Promise<string> {
const doc = new DOMParser().parseFromString(cleanHtml, 'text/html');
const blobs = new Map<string, Promise<Blob | null>>();
const fetchOnce = (u: string) => {
if (!blobs.has(u)) blobs.set(u, fetchBounded(u, pol));
return blobs.get(u)!;
};
const toData = async (u: string, font: boolean): Promise<string | null> => {
const blob = await fetchOnce(u);
if (!blob) return null;
const type = blob.type.toLowerCase().split(';')[0];
if (!(font ? FONT_TYPE.test(type) : IMAGE_TYPE.test(type))) return null;
try { return await blobToDataUrl(blob); } catch { return null; }
};
const rewriteCss = async (css: string, font: boolean): Promise<string> => {
const urls = Array.from(new Set(Array.from(css.matchAll(/url\("([^"]*)"\)/g)).map((m) => m[1]).filter((u) => !u.startsWith('data:'))));
let out = css;
for (const u of urls) {
const d = await toData(u, font);
out = out.split(`url("${u}")`).join(d ? `url("${d}")` : 'none');
}
return out;
};
for (const img of Array.from(doc.querySelectorAll('img[src]'))) {
const u = img.getAttribute('src') || '';
if (u.startsWith('data:')) continue;
img.setAttribute('src', (await toData(u, false)) ?? BLOCKED_IMAGE);
}
for (const st of Array.from(doc.querySelectorAll('style:not([data-capture])'))) {
// @font-face blocks hold fonts; everything else holds images.
const parts = (st.textContent || '').split(/(@font-face\{[^}]*\})/);
const out: string[] = [];
for (let i = 0; i < parts.length; i++) out.push(await rewriteCss(parts[i], i % 2 === 1));
st.textContent = out.join('');
}
for (const el of Array.from(doc.querySelectorAll('[style]'))) el.setAttribute('style', await rewriteCss(el.getAttribute('style') || '', false));
return '<!DOCTYPE html>' + doc.documentElement.outerHTML;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export async function captureHtml(html: string, opts: CaptureOptions = {}): Promise<CaptureResult> {
@@ -157,15 +230,18 @@ export async function captureHtml(html: string, opts: CaptureOptions = {}): Prom
frame.style.cssText = `position:fixed;left:-99999px;top:0;width:${width}px;height:900px;border:0;visibility:hidden;pointer-events:none`;
let timer: ReturnType<typeof setTimeout> | undefined;
const pol = makePolicy(opts.allowUrls);
const work = (async (): Promise<CaptureResult> => {
await new Promise<void>((resolve, reject) => {
frame.addEventListener('load', () => resolve(), { once: true });
frame.addEventListener('error', () => reject(new Error('frame failed to load')), { once: true });
const clean = sanitizeCaptureHtml(prepareCaptureHtml(html, opts.injectFontAwesome !== false), { allowUrls: opts.allowUrls, origin: typeof location !== 'undefined' ? location.origin : undefined });
const clean = sanitizeCaptureHtml(prepareCaptureHtml(html, opts.injectFontAwesome !== false), { allowUrls: opts.allowUrls });
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);
inlineResources(clean.html, pol).then((inlined) => {
frame.srcdoc = inlined;
frame.setAttribute('csp', clean.csp);
document.body.appendChild(frame);
}, reject);
});
const doc = frame.contentDocument;
if (!doc || !doc.body) return { ok: false, reason: 'page not readable' };
@@ -181,12 +257,13 @@ export async function captureHtml(html: string, opts: CaptureOptions = {}): Prom
const height = Math.min(full, maxHeight);
frame.style.height = `${height}px`;
await sleep(50);
const fonts = await buildFontEmbedCss(doc);
const fonts = await buildFontEmbedCss(doc, pol);
const dataUrl = await toJpeg(doc.body, {
quality: opts.quality ?? 0.8,
width, height, canvasWidth: width, canvasHeight: height, pixelRatio: 1,
backgroundColor: '#ffffff',
cacheBust: false,
fetchRequestInit: { credentials: 'omit', cache: 'no-store', redirect: 'error' },
fontEmbedCSS: fonts.css,
style: { margin: '0' },
});