sitesmith capture: draw placehold.co images locally as labelled boxes

The capture never fetches external images, so placehold.co placeholders came out as a blank grey box
(Chromium) or alt text (Firefox) and the reviewer reported a false "screenshot mismatch". checkUrl now
maps a strictly parsed placehold.co URL (https, host exactly placehold.co, /WxH with 1-4 digits, up to
two 3/6-digit hex colours, optional image extension) to a locally generated SVG data URL of the same
intrinsic size: URL bg colour (default #cccccc), ?text= label (default "W x H") in the fg colour
(default #969696), XML-escaped. Off-spec placehold.co URLs become a plain #cccccc box (no label), or
the old 1x1 grey image when the size is unknown. Works for <img> (own CSS: width, aspect-ratio,
object-fit) and CSS url() backgrounds. Only an SVG byte-identical to our generator passes the
second sanitiser pass. makePolicy/allow-list, sandbox and CSP unchanged; fetchBounded refuses
placehold.co outright. e2e: fidelity samples the boxes/labels, security asserts zero placehold.co
requests (incl. hostile ?text=) in Chromium and Firefox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 17:24:06 -08:00
1 parent 9781e02043
commit 41d0721028
6 files changed
+261 -6

No files matched your search

+30 -2
View File
@@ -40,6 +40,9 @@ body{margin:0;background:#ffffff}
<div style="padding:0;background-color:#ffffff;border:6px solid var(--wsb-border);border-top:14px solid var(--wsb-accent);border-radius:0;height:60px"></div>
<div style="background-color:#ffffff;border:6px solid var(--wsb-border);border-top:14px solid #00aa00;height:60px"></div>
<div class="sheet-card"></div>
<img alt="Team working" src="https://placehold.co/800x1000/1f3a5f/fbfaf7?text=Team+working" style="display:block;width:400px;aspect-ratio:4/3;object-fit:cover">
<div style="background:url(https://placehold.co/1200x400/222222/eeeeee?text=Hero) center/cover #ff0000;height:100px"></div>
<img alt="Nope" src="https://placehold.co/200x100/orange/white?text=Nope" style="display:block">
</body></html>`;
// [label, x, y, expected rgb]
const POINTS = [
@@ -63,6 +66,18 @@ const POINTS = [
[`${label}: bottom border`, 640, y0 + 77, [0xd4, 0x37, 0x2c]],
[`${label}: interior stays white`, 640, y0 + 45, [0xff, 0xff, 0xff]],
]),
// placehold.co images are drawn LOCALLY (no request) in the element's own box (eval 2026-11-01: blank / alt text).
['placehold.co <img> (width 400, aspect-ratio 4/3, object-fit cover): bg colour', 10, 950, [0x1f, 0x3a, 0x5f]],
['... box ends at 400px wide (white beyond)', 405, 950, [0xff, 0xff, 0xff]],
['... and at 300px tall (bottom-right corner still bg)', 395, 1236, [0x1f, 0x3a, 0x5f]],
['placehold.co CSS background (center/cover, red fallback colour): bg colour, not the fallback', 5, 1245, [0x22, 0x22, 0x22]],
['off-spec placehold.co (named colours): plain neutral #cccccc box of the URL size', 100, 1390, [0xcc, 0xcc, 0xcc]],
['... that is 200px wide (white beyond)', 205, 1390, [0xff, 0xff, 0xff]],
];
// Rows scanned for the label text colour (any pixel close to it).
const SCANS = [
['placehold.co <img> shows its ?text= label in the fg colour', 1090, 20, 380, [0xfb, 0xfa, 0xf7]],
['placehold.co CSS background shows its label in the fg colour', 1290, 300, 980, [0xee, 0xee, 0xee]],
];
const srv = http.createServer((q, r) => { r.setHeader('content-type', 'text/html'); r.end('<!doctype html><title>host</title><body>host</body>'); }).listen(0);
@@ -89,9 +104,22 @@ async function run(name, launcher, exe) {
// Recovered-from-source shorthands pass the same URL gate.
const hostile2 = await page.evaluate((h) => window.sanitizeCaptureHtml(h), '<div style="background: var(--m, url(/beacon2?x)) center; background-color: red; border: 1px solid var(--b); border-top: 2px solid red">x</div><style>.b{background:var(--q, url(https://evil.example/b.png)) no-repeat;background-position:0 0}</style>');
check(name, 'overridden var() shorthand with a disallowed url() fallback is still dropped (inline + sheet)', hostile2.ok && !/beacon2|evil\.example/.test(hostile2.html) && /border:\s*1px solid var\(--b\)/.test(hostile2.html), hostile2.ok ? '' : hostile2.reason);
const res = await page.evaluate(([h, pts]) => window.captureAndSample(h, pts), [PAGE, POINTS.map((p) => [p[1], p[2]])]);
const scanPts = SCANS.flatMap(([, y, x0, x1]) => Array.from({ length: Math.floor((x1 - x0) / 2) }, (_, k) => [x0 + 2 * k, y]));
const placeholdReq = [];
page.on('request', (q) => { if (/placehold\.co/.test(q.url())) placeholdReq.push(q.url()); });
const res = await page.evaluate(([h, pts]) => window.captureAndSample(h, pts), [PAGE, [...POINTS.map((p) => [p[1], p[2]]), ...scanPts]]);
check(name, 'capture succeeded', res.ok === true, res.ok ? '' : res.reason);
if (res.ok) POINTS.forEach(([label, , , want], i) => check(name, label, near(res.samples[i], want), `got rgb(${res.samples[i].join(',')}) want rgb(${want.join(',')})`));
check(name, 'no request to placehold.co', placeholdReq.length === 0, JSON.stringify(placeholdReq));
if (res.ok) {
POINTS.forEach(([label, , , want], i) => check(name, label, near(res.samples[i], want), `got rgb(${res.samples[i].join(',')}) want rgb(${want.join(',')})`));
let at = POINTS.length;
for (const [label, , x0, x1, want] of SCANS) {
const n = Math.floor((x1 - x0) / 2);
const row = res.samples.slice(at, at + n); at += n;
const hits = row.filter((px) => near(px, want, 12)).length;
check(name, label, hits >= 3, `(${hits} pixels near rgb(${want.join(',')}))`);
}
}
await browser.close();
}
await run('chromium', chromium, process.env.CHROMIUM_PATH);
+6
View File
@@ -48,6 +48,8 @@ const EVIL = `<!doctype html><html><head>
</style>
<div class="b1 b2 b3 b4 b5 b6 b7 b8 b9 b10 b11 b12 b13 b14 b15 b16 b17" 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">
<img src="https://placehold.co/400x300/123456/abcdef?text=%3Cscript%3E${encodeURIComponent(PWN)}%3C/script%3E%22%3E%3Cimg+src=/beacon?ph-text%3E"><img src="https://placehold.co/400x300/123/fff/x.png?text=y"><img src="http://placehold.co/400x300?text=z"><img src="https://placehold.co/400x300/url(/beacon?ph-css)/fff">
<div style="background:url(https://placehold.co/1200x400/222/eee?text=Hero) center/cover;height:20px"></div><style>.ph{background-image:url("https://placehold.co/300x300?text=%22%29%3B%7D*%7Bbackground:url(/beacon?ph-break)")}</style><div class="ph">ph</div>
<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>
@@ -71,6 +73,9 @@ async function run(name, launcher, exe) {
const page = await ctx.newPage();
const evilReq = [];
await ctx.route(/evil\.example/, (route) => { evilReq.push(route.request().url()); route.abort(); });
// placehold.co images are drawn locally: not one request may be made for them.
const placeholdReq = [];
await ctx.route(/placehold\.co/, (route) => { placeholdReq.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++; });
@@ -87,6 +92,7 @@ async function run(name, launcher, exe) {
check(name, 'hostile page: no postMessage to the editor', messages === 0, `(${messages})`);
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: no request to placehold.co (placeholders are drawn locally, incl. hostile ?text=)', placeholdReq.length === 0, JSON.stringify(placeholdReq));
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));
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, test } from 'vitest';
import { parsePlaceholdUrl, placeholderDataUrl, isPlaceholderDataUrl } from './capture-placeholder';
import { sanitizeCaptureHtml, checkUrl, makePolicy, BLOCKED_IMAGE } from './sanitize-capture-html';
import { fetchBounded } from './self-review-capture';
const svgOf = (d: string) => new TextDecoder().decode(Uint8Array.from(atob(d.split(',')[1]), (c) => c.charCodeAt(0)));
const dom = (h: string) => new DOMParser().parseFromString(h, 'text/html');
describe('parsePlaceholdUrl (strict)', () => {
test('size, colours and text', () => {
expect(parsePlaceholdUrl('https://placehold.co/800x1000/1f3a5f/fbfaf7?text=Team+working+together')).toEqual({
kind: 'label', w: 800, h: 1000, bg: '#1f3a5f', fg: '#fbfaf7', text: 'Team working together',
});
});
test('defaults: #cccccc on #969696, label = W x H', () => {
expect(parsePlaceholdUrl('https://placehold.co/600x400')).toEqual({ kind: 'label', w: 600, h: 400, bg: '#cccccc', fg: '#969696', text: '600 × 400' });
expect(parsePlaceholdUrl('https://placehold.co/600x400/000')).toMatchObject({ bg: '#000000', fg: '#969696' });
});
test('percent-decoding, format suffix, 3-digit hex', () => {
expect(parsePlaceholdUrl('https://placehold.co/300x200/abc/FFF.png?text=Caf%C3%A9%20%26%20%3Cb%3E')).toMatchObject({
kind: 'label', bg: '#aabbcc', fg: '#ffffff', text: 'Café & <b>',
});
});
test('not placehold.co -> null (today\'s behaviour)', () => {
for (const u of ['https://example.com/800x600', 'https://placehold.co.evil.example/800x600', 'https://evil.example/placehold.co/800x600',
'https://via.placeholder.com/800x600', '//placehold.co/800x600', 'placehold.co/800x600', 'assets/x.png', '/beacon']) {
expect(parsePlaceholdUrl(u)).toBeNull();
}
});
test('placehold.co but anything off-spec -> plain neutral box, no label', () => {
expect(parsePlaceholdUrl('http://placehold.co/800x600/000/fff?text=hi')).toEqual({ kind: 'neutral', w: 800, h: 600 });
expect(parsePlaceholdUrl('https://placehold.co/800x600/orange/white?text=hi')).toEqual({ kind: 'neutral', w: 800, h: 600 });
expect(parsePlaceholdUrl('https://placehold.co/800x600/0000/fff')).toEqual({ kind: 'neutral', w: 800, h: 600 });
expect(parsePlaceholdUrl('https://user@placehold.co/800x600')).toEqual({ kind: 'neutral', w: 800, h: 600 });
expect(parsePlaceholdUrl('https://placehold.co:8443/800x600')).toEqual({ kind: 'neutral', w: 800, h: 600 });
expect(parsePlaceholdUrl('https://placehold.co/800x600/000/fff/extra')).toEqual({ kind: 'neutral', w: 800, h: 600 });
expect(parsePlaceholdUrl('https://placehold.co/12345x600')).toEqual({ kind: 'neutral', w: 0, h: 0 });
expect(parsePlaceholdUrl('https://placehold.co/0x600')).toEqual({ kind: 'neutral', w: 0, h: 0 });
expect(parsePlaceholdUrl('https://placehold.co/600')).toEqual({ kind: 'neutral', w: 0, h: 0 });
expect(parsePlaceholdUrl('https://PLACEHOLD.CO/800x600')).toMatchObject({ kind: 'label' }); // hosts are case-insensitive
});
});
describe('placeholderDataUrl', () => {
test('a local SVG of the image size, bg fill and escaped label', () => {
const d = placeholderDataUrl('https://placehold.co/800x1000/1f3a5f/fbfaf7?text=%3Cscript%3Ealert(1)%3C/script%3E+%26+"q"')!;
expect(d.startsWith('data:image/svg+xml;base64,')).toBe(true);
const svg = svgOf(d);
expect(svg).toMatch(/width="800" height="1000"/);
expect(svg).toContain('fill="#1f3a5f"');
expect(svg).toContain('fill="#fbfaf7"');
expect(svg).toContain('&lt;script&gt;alert(1)&lt;/script&gt; &amp; &quot;q&quot;');
expect(svg.replace('xmlns="http://www.w3.org/2000/svg"', '')).not.toMatch(/<script|https?:|href/);
expect(isPlaceholderDataUrl(d)).toBe(true);
});
test('neutral box: size kept, #cccccc, no text; unknown size -> the 1x1 blocked image', () => {
const svg = svgOf(placeholderDataUrl('https://placehold.co/800x600/orange/white?text=hi')!);
expect(svg).toMatch(/width="800" height="600"/);
expect(svg).toContain('fill="#cccccc"');
expect(svg).not.toContain('<text');
expect(placeholderDataUrl('https://placehold.co/600?text=x')).toBe(BLOCKED_IMAGE);
expect(placeholderDataUrl('https://example.com/a.png')).toBeNull();
});
test('only our own generated SVG passes isPlaceholderDataUrl', () => {
expect(isPlaceholderDataUrl('data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=')).toBe(false);
const forged = btoa('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><image href="https://evil.example/x"/></svg>');
expect(isPlaceholderDataUrl(`data:image/svg+xml;base64,${forged}`)).toBe(false);
const d = placeholderDataUrl('https://placehold.co/80x60?text=ok')!;
const tampered = svgOf(d).replace('</svg>', '<image href="https://evil.example/x"/></svg>');
expect(isPlaceholderDataUrl(`data:image/svg+xml;base64,${btoa(tampered)}`)).toBe(false);
});
});
describe('sanitiser + capture use the local placeholder, never the network', () => {
test('img src and CSS background url() become the local SVG', () => {
const r = sanitizeCaptureHtml('<html><body><img id="i" src="https://placehold.co/800x600/1f3a5f/fbfaf7?text=Team" srcset="https://placehold.co/1600x1200 2x" style="width:100%;aspect-ratio:4/3;object-fit:cover">'
+ '<div id="b" style="background-image:url(https://placehold.co/1200x400/222/eee?text=Hero);background-color:#333;height:40px"></div>'
+ '<img id="x" src="https://example.com/a.png"></body></html>') as any;
expect(r.ok).toBe(true);
const d = dom(r.html);
const src = d.getElementById('i')!.getAttribute('src')!;
expect(isPlaceholderDataUrl(src)).toBe(true);
expect(svgOf(src)).toContain('>Team</text>');
expect(d.getElementById('i')!.getAttribute('style')).toMatch(/object-fit:\s*cover/);
const bg = /url\("([^"]+)"\)/.exec(d.getElementById('b')!.getAttribute('style')!)![1]; // jsdom: longhands only (its CSSOM re-serialises a position shorthand unstably)
expect(isPlaceholderDataUrl(bg)).toBe(true);
expect(svgOf(bg)).toContain('fill="#222222"');
expect(d.getElementById('x')!.getAttribute('src')).toBe(BLOCKED_IMAGE); // any other external image: unchanged behaviour
expect(r.html).not.toContain('placehold.co');
});
test('checkUrl returns the data URL (not the URL), and the policy is not widened', () => {
const pol = makePolicy();
expect(pol.allow.size).toBe(0);
expect(checkUrl('https://placehold.co/80x60', pol)!.startsWith('data:image/svg+xml;base64,')).toBe(true);
expect(checkUrl('https://placehold.co/80x60', pol, true)).toBeNull(); // never a font
});
test('fetchBounded never fetches a placehold.co URL', async () => {
let calls = 0;
const orig = globalThis.fetch;
globalThis.fetch = (async () => { calls++; return new Response('x'); }) as typeof fetch;
try {
expect(await fetchBounded('https://placehold.co/80x60', makePolicy())).toBeNull();
expect(await fetchBounded('http://placehold.co/80x60', makePolicy())).toBeNull();
} finally { globalThis.fetch = orig; }
expect(calls).toBe(0);
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* Local stand-ins for placehold.co images in the self-review capture.
*
* The capture never fetches external images (network allow-list), so `https://placehold.co/<W>x<H>/<bg>/<fg>?text=...`
* used to come out as a blank grey box (Chromium) or its alt text (Firefox), and the reviewing model reported a
* false "screenshot mismatch". Instead the sanitiser swaps such a URL for a locally generated SVG data URL of the
* same intrinsic size, filled with the URL's background colour and showing its text: no request is ever made, and
* the element's own CSS (width, aspect-ratio, object-fit, background-size...) lays it out exactly like the real image.
*
* The URL is parsed strictly: https, host exactly placehold.co, path `/<1-4 digits>x<1-4 digits>` plus at most two
* 3/6-digit hex colours and an optional image extension. A placehold.co URL that is anything else becomes a plain
* neutral box (no label); every other URL is left to the sanitiser's normal gate.
*/
/** 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==';
export type Placehold =
| { kind: 'label'; w: number; h: number; bg: string; fg: string; text: string }
| { kind: 'neutral'; w: number; h: number };
const DEFAULT_BG = '#cccccc';
const DEFAULT_FG = '#969696';
const MAX_TEXT = 200;
const HEX = '([0-9a-f]{3}|[0-9a-f]{6})';
const STRICT_PATH = new RegExp(`^/(\\d{1,4})x(\\d{1,4})(?:/${HEX})?(?:/${HEX})?(?:\\.(?:png|jpe?g|gif|webp|avif|svg))?$`, 'i');
const hex = (h: string | undefined, dflt: string) => {
if (!h) return dflt;
const l = h.toLowerCase();
return '#' + (l.length === 3 ? l.split('').map((c) => c + c).join('') : l);
};
/** null = not a placehold.co URL (the caller's normal rules apply). */
export function parsePlaceholdUrl(raw: string): Placehold | null {
if (typeof raw !== 'string') return null;
const t = raw.trim();
if (!/^https?:\/\//i.test(t) || t.length > 2000) return null;
let u: URL;
try { u = new URL(t); } catch { return null; }
if (u.hostname !== 'placehold.co') return null;
const size = /^\/(\d{1,4})x(\d{1,4})(?=[/.]|$)/.exec(u.pathname);
const w = size ? Number(size[1]) : 0;
const h = size ? Number(size[2]) : 0;
if (!w || !h) return { kind: 'neutral', w: 0, h: 0 };
const m = STRICT_PATH.exec(u.pathname);
if (u.protocol !== 'https:' || u.username || u.password || u.port || !m) return { kind: 'neutral', w, h };
const text = (u.searchParams.get('text') ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT);
return { kind: 'label', w, h, bg: hex(m[3], DEFAULT_BG), fg: hex(m[4], DEFAULT_FG), text: text || `${w} × ${h}` };
}
const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
const unesc = (s: string) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&');
/** One line, centred, shrunk to fit (as placehold.co does). */
const fontSize = (w: number, h: number, text: string) =>
Math.round(Math.max(6, Math.min(h * 0.18, (w * 0.86) / (0.56 * Math.max(1, text.length)))) * 10) / 10;
function svgFor(p: { w: number; h: number; bg: string; fg?: string; text?: string }): string {
const { w, h } = p;
const label = p.text && p.fg
? `<text x="${w / 2}" y="${h / 2}" fill="${p.fg}" font-family="sans-serif" font-size="${fontSize(w, h, p.text)}" text-anchor="middle" dominant-baseline="central">${esc(p.text)}</text>`
: '';
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" fill="${p.bg}"/>${label}</svg>`;
}
const PREFIX = 'data:image/svg+xml;base64,';
function b64(s: string): string {
let bin = '';
for (const b of new TextEncoder().encode(s)) bin += String.fromCharCode(b);
return btoa(bin);
}
function unb64(s: string): string | null {
try { return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(atob(s), (c) => c.charCodeAt(0))); } catch { return null; }
}
/**
* The local image for a placehold.co URL: a labelled SVG, a neutral SVG box when the URL is off-spec, the 1x1
* blocked image when not even the size is known; null when the URL is not a placehold.co URL at all.
*/
export function placeholderDataUrl(raw: string): string | null {
const p = parsePlaceholdUrl(raw);
if (!p) return null;
if (p.kind === 'neutral') return p.w && p.h ? PREFIX + b64(svgFor({ w: p.w, h: p.h, bg: DEFAULT_BG })) : BLOCKED_IMAGE;
return PREFIX + b64(svgFor(p));
}
const OURS = /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" width="(\d{1,4})" height="(\d{1,4})" viewBox="0 0 \d{1,4} \d{1,4}"><rect width="\d{1,4}" height="\d{1,4}" fill="(#[0-9a-f]{6})"\/>(?:<text x="[\d.]+" y="[\d.]+" fill="(#[0-9a-f]{6})" font-family="sans-serif" font-size="[\d.]+" text-anchor="middle" dominant-baseline="central">([^<>"']{1,1400})<\/text>)?<\/svg>$/;
/** True only for an SVG data URL byte-identical to one placeholderDataUrl generates (the sanitiser's second pass). */
export function isPlaceholderDataUrl(raw: string): boolean {
if (typeof raw !== 'string' || !raw.startsWith(PREFIX) || raw.length > 8000) return false;
const svg = unb64(raw.slice(PREFIX.length));
const m = svg ? OURS.exec(svg) : null;
if (!m) return false;
const w = Number(m[1]); const h = Number(m[2]);
if (!w || !h) return false;
const text = m[5] === undefined ? undefined : unesc(m[5]);
if (text !== undefined && text.length > MAX_TEXT) return false;
return svgFor({ w, h, bg: m[3], fg: m[4], text }) === svg;
}
+11 -3
View File
@@ -18,6 +18,7 @@
* 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).
*/
import { BLOCKED_IMAGE, placeholderDataUrl, isPlaceholderDataUrl } from './capture-placeholder';
export const CAPTURE_CSP = [
"default-src 'none'",
@@ -42,8 +43,7 @@ const DROP_ATTRS = new Set(['srcdoc', 'http-equiv', 'is', 'formmethod', 'target'
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, keeping layout. */
export const BLOCKED_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO8d+/efwAIgQMeVxsvbAAAAABJRU5ErkJggg==';
export { BLOCKED_IMAGE };
export interface SanitizeOptions {
/** Exact URLs (root-relative path + query, as the editor knows them) that may be fetched, e.g. the site's asset URLs. */
@@ -68,7 +68,15 @@ const ASSET_PATH = /^assets\/[A-Za-z0-9._~+-]+(?:\/[A-Za-z0-9._~+-]+)*$/;
export function checkUrl(raw: string, pol: Policy, font = false): string | null {
if (typeof raw !== 'string') return null;
const t = raw.trim();
if (!t || t.length > 4000) return null;
if (!t) return null;
if (!font) {
// placehold.co -> a locally generated SVG of the same size/colours/label (never fetched); and that SVG
// itself on the sanitiser's second pass. Not a network allow-list entry: the result is a data: URL.
if (isPlaceholderDataUrl(t)) return t;
const ph = placeholderDataUrl(t);
if (ph) return ph;
}
if (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;
+6 -1
View File
@@ -14,6 +14,7 @@
*/
import { toJpeg } from 'html-to-image';
import { sanitizeCaptureHtml, checkUrl, makePolicy, BLOCKED_IMAGE, FONT_FILE_HOSTS, type Policy } from './sanitize-capture-html';
import { parsePlaceholdUrl } from './capture-placeholder';
export const FONT_AWESOME_CSS = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css';
@@ -93,7 +94,11 @@ const blobToDataUrl = (b: Blob) => new Promise<string>((resolve, reject) => {
*/
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;
// placehold.co is drawn locally (capture-placeholder.ts) and must never be requested; checkUrl maps it to a
// data: URL, which is never fetched here either.
if (parsePlaceholdUrl(url)) return null;
const ok = isFont ? url : checkUrl(url, pol);
if (!ok || ok.startsWith('data:')) return null;
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
try {