Measured in Chromium and Firefox: the CSSOM exposes `background: var(--x)` and `border: 1px solid var(--x)` only as pending-substitution longhands whose values read back as "", so the capture sanitiser (which re-serialises the parsed declarations) dropped them. The self-review screenshot then showed dark heroes, image placeholders and card borders as white, and the model "fixed" things that were fine on the real page. The sanitiser now emits the declared shorthand once, at its first longhand, through the same value/URL gate as every other declaration. Sandbox, CSP and the single URL allow-list are unchanged. - scripts/e2e-capture-fidelity.mjs: real-browser test through the whole capture path (sanitise -> inline -> sandboxed frame -> html-to-image), decodes the JPEG and samples pixels. Failed 9/12 checks per browser before, passes after. - e2e-capture-security: hostile var() shorthand / var() fallback url() probes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
8.4 KiB
JavaScript
114 lines
8.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Real-browser proof (Firefox + Chromium) that the self-review capture cannot run or leak hostile page
|
|
* markup. A malicious fixture goes through the WHOLE capture path; sentinels: window.__pwned in the
|
|
* parent, any request to /beacon or evil.example, parent cookie/localStorage reachability, frame
|
|
* navigation. A second probe feeds the SAME hostile page to the frame WITHOUT the sanitiser to show the
|
|
* sandbox + CSP alone also hold. Benign pages must still capture.
|
|
* Run: node scripts/e2e-capture-security.mjs (CHROMIUM_PATH / FIREFOX_PATH override the binaries)
|
|
*/
|
|
import { build } from 'esbuild';
|
|
import { chromium, firefox } from '@playwright/test';
|
|
import http from 'node:http';
|
|
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const out = await build({
|
|
entryPoints: [path.join(root, 'scripts/e2e-capture-security-entry.ts')],
|
|
bundle: true, write: false, format: 'iife', platform: 'browser', logLevel: 'error',
|
|
define: { 'process.env.NODE_ENV': '"production"' },
|
|
});
|
|
const js = out.outputFiles[0].text;
|
|
|
|
const PWN = "(function(){try{parent.__pwned=1;top.__pwned=1}catch(e){}try{fetch('/beacon?js')}catch(e){}try{top.postMessage('pwned','*')}catch(e){}try{new Image().src='/beacon?img-js'}catch(e){}})()";
|
|
const EVIL = `<!doctype html><html><head>
|
|
<meta http-equiv="refresh" content="0;url=/beacon?refresh"><base href="https://evil.example/">
|
|
<link rel="import" href="/beacon?import"><link rel="stylesheet" href="/beacon?css"><link rel="preload" href="/beacon?preload" as="script">
|
|
<style>@import url(/beacon?atimport); body{background:url(https://evil.example/bg.png);} .pad{padding:8px}</style>
|
|
<script>${PWN}</script></head>
|
|
<body onload="${PWN}" style="font:16px sans-serif">
|
|
<h1 class="pad" style="color:#123">Hostile but readable</h1>
|
|
<img src="x" onerror="${PWN}"><img src="/beacon?img">
|
|
<svg width="10" height="10" onload="${PWN}"><script>${PWN}</script><a xlink:href="javascript:${PWN}"><rect width="5" height="5"/></a></svg>
|
|
<a href="javascript:${PWN}">js link</a><form action="javascript:${PWN}"><button formaction="javascript:${PWN}">b</button></form>
|
|
<iframe srcdoc="<script>${PWN}</script>"></iframe><iframe src="/beacon?iframe"></iframe>
|
|
<object data="/beacon?object"></object><embed src="/beacon?embed">
|
|
<div style="background:url(/beacon?cssurl)">x</div><video src="/beacon?video"></video>
|
|
<input autofocus onfocus="${PWN}"><details open ontoggle="${PWN}"><summary>s</summary></details>
|
|
<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)}}
|
|
:root{--vu:url(/beacon?b-var-sh);--vs:url(/beacon?b-var-src)} .b16{background:var(--vu) no-repeat;border:1px solid var(--vu);border-image:var(--vu) 30;list-style:var(--vu) inside} .b17{background:var(--missing, url(/beacon?b-var-fallback)) center}
|
|
</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">
|
|
<p style="color:#444">Visible paragraph so the capture has real content ${'lorem ipsum '.repeat(40)}</p>
|
|
</body></html>`;
|
|
const BENIGN = `<!doctype html><html><head><style>body{margin:0;font:20px sans-serif}.h{background:linear-gradient(135deg,#0f172a,#3b82f6);color:#fff;padding:40px}</style></head>
|
|
<body><div class="h"><h1>Benign page</h1><p>${'Hello world. '.repeat(30)}</p></div></body></html>`;
|
|
|
|
const beacons = [];
|
|
const srv = http.createServer((q, r) => {
|
|
const p = q.url.split('?')[0];
|
|
if (p === '/') { r.setHeader('content-type', 'text/html'); return r.end('<!doctype html><title>host</title><body>host</body>'); }
|
|
if (p === '/beacon') { beacons.push(q.url); r.statusCode = 204; return r.end(); }
|
|
r.statusCode = 404; r.end('nf');
|
|
}).listen(0);
|
|
const port = srv.address().port;
|
|
|
|
let failed = 0;
|
|
const check = (name, label, ok, extra = '') => { if (!ok) failed++; console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' ' + extra : ''}`); };
|
|
|
|
async function run(name, launcher, exe) {
|
|
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
const evilReq = [];
|
|
await ctx.route(/evil\.example/, (route) => { evilReq.push(route.request().url()); route.abort(); });
|
|
// Only requests that actually reach the network count (Playwright also reports CSP-blocked ones as 'request').
|
|
let messages = 0;
|
|
await page.exposeFunction('__onMsg', () => { messages++; });
|
|
await page.goto(`http://localhost:${port}/`);
|
|
await page.evaluate(() => { document.cookie = 'sess=SECRET; path=/'; localStorage.setItem('csrf', 'SECRET'); window.addEventListener('message', (e) => { if (e.data === 'pwned') window.__onMsg(); }); });
|
|
await page.addScriptTag({ content: js });
|
|
|
|
// 1) hostile page through the whole capture path
|
|
beacons.length = 0;
|
|
const res = await page.evaluate(async (h) => { const r = await window.captureHtml(h, { settleMs: 300 }); return r.ok ? { ok: true, size: r.blob.size } : r; }, EVIL);
|
|
await page.waitForTimeout(500);
|
|
const pwned = await page.evaluate(() => window.__pwned);
|
|
check(name, 'hostile page: parent sentinel window.__pwned untouched', pwned === undefined, `(${pwned})`);
|
|
check(name, 'hostile page: no postMessage to the editor', messages === 0, `(${messages})`);
|
|
check(name, 'hostile page: 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));
|
|
|
|
// 2) defence in depth: same hostile page, NO sanitiser, only sandbox + CSP
|
|
beacons.length = 0; evilReq.length = 0; messages = 0;
|
|
await page.evaluate((h) => window.rawFrame(h), EVIL);
|
|
await page.waitForTimeout(500);
|
|
const pwned2 = await page.evaluate(() => window.__pwned);
|
|
check(name, 'unsanitised probe: sandbox+CSP alone stop script execution (__pwned)', pwned2 === undefined, `(${pwned2})`);
|
|
check(name, 'unsanitised probe: no postMessage', messages === 0);
|
|
// Same-origin passive GETs (img/css url) are allowed by CSP 'self'; that is why the sanitiser also restricts them. Script-initiated ones must never occur.
|
|
check(name, 'unsanitised probe: 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()));
|
|
|
|
// 3) benign page still captures
|
|
const ok = await page.evaluate(async (h) => { const r = await window.captureHtml(h, { settleMs: 300 }); return r.ok ? { ok: true, size: r.blob.size, w: r.width, h: r.height } : r; }, BENIGN);
|
|
check(name, 'benign page captures', ok.ok === true && ok.size > 3000, JSON.stringify(ok));
|
|
await browser.close();
|
|
}
|
|
await run('chromium', chromium, process.env.CHROMIUM_PATH);
|
|
await run('firefox', firefox, process.env.FIREFOX_PATH);
|
|
srv.close();
|
|
process.exit(failed ? 1 : 0);
|