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:
1 parent
9781e02043
commit
41d0721028
6 files changed
+261
-6
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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('<script>alert(1)</script> & "q"');
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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, '&').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
|
||||
? `<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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user