From 41d072102812ae42eb8888797d6a086afbddb877 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 17:24:06 -0800 Subject: [PATCH] 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 (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 --- craft/scripts/e2e-capture-fidelity.mjs | 32 +++++- craft/scripts/e2e-capture-security.mjs | 6 ++ craft/src/utils/capture-placeholder.test.ts | 107 ++++++++++++++++++++ craft/src/utils/capture-placeholder.ts | 101 ++++++++++++++++++ craft/src/utils/sanitize-capture-html.ts | 14 ++- craft/src/utils/self-review-capture.ts | 7 +- 6 files changed, 261 insertions(+), 6 deletions(-) create mode 100644 craft/src/utils/capture-placeholder.test.ts create mode 100644 craft/src/utils/capture-placeholder.ts diff --git a/craft/scripts/e2e-capture-fidelity.mjs b/craft/scripts/e2e-capture-fidelity.mjs index 9261ebc..911b983 100644 --- a/craft/scripts/e2e-capture-fidelity.mjs +++ b/craft/scripts/e2e-capture-fidelity.mjs @@ -40,6 +40,9 @@ body{margin:0;background:#ffffff}
+Team working +
+Nope `; // [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 (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 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('hosthost'); }).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), '
x
'); 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); diff --git a/craft/scripts/e2e-capture-security.mjs b/craft/scripts/e2e-capture-security.mjs index 4f81052..bc7cbff 100644 --- a/craft/scripts/e2e-capture-security.mjs +++ b/craft/scripts/e2e-capture-security.mjs @@ -48,6 +48,8 @@ const EVIL = `
css bypass probes
+ +
ph

Visible paragraph so the capture has real content ${'lorem ipsum '.repeat(40)}

`; const BENIGN = ` @@ -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)); diff --git a/craft/src/utils/capture-placeholder.test.ts b/craft/src/utils/capture-placeholder.test.ts new file mode 100644 index 0000000..dbd90a9 --- /dev/null +++ b/craft/src/utils/capture-placeholder.test.ts @@ -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é & ', + }); + }); + 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('<script>alert(1)</script> & "q"'); + expect(svg.replace('xmlns="http://www.w3.org/2000/svg"', '')).not.toMatch(/ 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(' { + expect(isPlaceholderDataUrl('data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=')).toBe(false); + const forged = btoa(''); + expect(isPlaceholderDataUrl(`data:image/svg+xml;base64,${forged}`)).toBe(false); + const d = placeholderDataUrl('https://placehold.co/80x60?text=ok')!; + const tampered = svgOf(d).replace('', ''); + 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('' + + '
' + + '') 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'); + 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); + }); +}); diff --git a/craft/src/utils/capture-placeholder.ts b/craft/src/utils/capture-placeholder.ts new file mode 100644 index 0000000..659d5d1 --- /dev/null +++ b/craft/src/utils/capture-placeholder.ts @@ -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/x//?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, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +const unesc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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 + ? `${esc(p.text)}` + : ''; + return `${label}`; +} + +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 = /^(?:([^<>"']{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; +} diff --git a/craft/src/utils/sanitize-capture-html.ts b/craft/src/utils/sanitize-capture-html.ts index 47e01a5..6456c99 100644 --- a/craft/src/utils/sanitize-capture-html.ts +++ b/craft/src/utils/sanitize-capture-html.ts @@ -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; diff --git a/craft/src/utils/self-review-capture.ts b/craft/src/utils/self-review-capture.ts index 6398d33..52b5555 100644 --- a/craft/src/utils/self-review-capture.ts +++ b/craft/src/utils/self-review-capture.ts @@ -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((resolve, reject) => { */ export async function fetchBounded(url: string, pol: Policy, maxBytes = MAX_RESOURCE_BYTES): Promise { 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 {