2026-11-01 10:31:29 -08:00
#!/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>
2026-11-01 10:37:56 -08:00
<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">
2026-11-01 10:31:29 -08:00
<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 } )` );
2026-11-01 10:37:56 -08:00
check ( name , 'hostile page: ZERO requests to the panel origin /beacon (every CSS/URL/encoding bypass class)' , beacons . length === 0 , JSON . stringify ( beacons ));
2026-11-01 10:31:29 -08:00
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.
2026-11-01 10:37:56 -08:00
check ( name , 'unsanitised probe: CSP alone (img-src data:) lets no request reach the panel origin' , beacons . length === 0 , JSON . stringify ( beacons ));
2026-11-01 10:31:29 -08:00
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 );