Files
safetynet/tests/visual-preload.test.mjs
2026-10-13 10:34:48 +02:00

113 lines
10 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source=fs.readFileSync(new URL('../apps/browser/site-preload.cjs',import.meta.url),'utf8');
function harness(enabled,options={}){
const listeners=new Map(),ipcListeners=new Map(),timers=[],requests=[],sent=[],observers=[];
const window={stop:()=>{},addEventListener:(name,handler,capture)=>{assert.equal(capture===true||capture===undefined,true);listeners.set(name,[...(listeners.get(name)||[]),handler]);}};
const ipcRenderer={on:(channel,callback)=>ipcListeners.set(channel,callback),sendSync:channel=>{assert.equal(channel,'managed:visual-policy');return {enabled};},send:(channel,payload)=>sent.push({channel,payload}),invoke:(channel,payload)=>new Promise(resolve=>requests.push({channel,payload,resolve}))};
const location={href:options.url||'https://example.test/login'};
class MutationObserver{constructor(callback){this.callback=callback;observers.push(this);}observe(_element,settings){this.settings=settings;}}
class Document{}Document.prototype.execCommand=()=>true;
const context={require:name=>{assert.equal(name,'electron');return {ipcRenderer,contextBridge:{executeInMainWorld:({func})=>func()}};},Document,DOMException,process:{sandboxed:true,contextIsolated:true,isMainFrame:true},window,location,document:{body:{innerText:'Public page content'},documentElement:{}},crypto:options.crypto||{randomUUID:()=> 'opaque-document-fixture-token'},MutationObserver,setTimeout:(callback,delay)=>{timers.push({callback,delay});return timers.length;}};
vm.runInNewContext(source,context,{filename:'site-preload.cjs'});
function dispatch(name,credential=true,inputType){const result={prevented:false,stopped:false};const target={closest:()=>credential?target:null};for(const listener of listeners.get(name)||[]){listener({target,inputType,preventDefault:()=>result.prevented=true,stopImmediatePropagation:()=>result.stopped=true});if(result.stopped)break;}return result;}
function domReady(){for(const listener of listeners.get('DOMContentLoaded')||[])listener();}
function mutate(attribute){assert.ok(observers.length);for(const observer of observers){if(!attribute||observer.settings.attributes&&(!observer.settings.attributeFilter||observer.settings.attributeFilter.includes(attribute)))observer.callback([]);}}
function configure(nextEnabled,token){assert.ok(ipcListeners.has('managed:visual-config'));ipcListeners.get('managed:visual-config')({}, {enabled:nextEnabled,token});}
function timer(delay){const index=timers.findIndex(t=>t.delay===delay);if(index<0)return false;const item=timers.splice(index,1)[0];item.callback();return true;}
return {context,location,requests,sent,timers,dispatch,domReady,mutate,timer,configure};
}
const settle=()=>new Promise(resolve=>setImmediate(resolve));
test('disabled visual policy preserves input and submits without requesting visual approval',()=>{
const f=harness(false);f.domReady();
for(const event of ['beforeinput','submit','pointerdown'])assert.deepEqual(f.dispatch(event),{prevented:false,stopped:false});
assert.equal(f.timer(150),false);assert.equal(f.requests.length,0);
assert.equal(f.timer(350),true);assert.equal(f.sent[0].channel,'managed:page-content');
});
test('enabled credential input, paste and submit are held until current document approval',async()=>{
const f=harness(true);f.domReady();
for(const event of ['beforeinput','paste','submit'])assert.deepEqual(f.dispatch(event),{prevented:true,stopped:true});
assert.equal(f.timer(150),true);assert.equal(f.requests.length,1);
const request=f.requests[0];assert.equal(request.channel,'managed:visual-check');assert.equal(request.payload.url,f.location.href);assert.ok(request.payload.revision>0);
request.resolve({decision:'allow'});await settle();
for(const event of ['beforeinput','submit'])assert.deepEqual(f.dispatch(event),{prevented:false,stopped:false});
});
test('DOM mutation invalidates approval synchronously before a new inspection completes',async()=>{
const f=harness(true);f.domReady();f.timer(150);f.requests[0].resolve({decision:'allow'});await settle();
assert.equal(f.dispatch('beforeinput').prevented,false);
f.mutate();
assert.equal(f.dispatch('beforeinput').prevented,true,'A changed credential form must not receive input during inspection');
assert.equal(f.dispatch('submit').prevented,true);
});
test('stale successful inspection cannot release a form changed while inspection was pending',async()=>{
const f=harness(true);f.domReady();f.timer(150);const first=f.requests[0];
f.mutate();f.timer(150);const second=f.requests[1];assert.ok(second.payload.revision>first.payload.revision);
first.resolve({decision:'allow'});await settle();
assert.equal(f.dispatch('beforeinput').prevented,true,'An allow result for the previous form revision must be discarded');
// The held paste itself invalidates the outstanding second revision as another attempt.
second.resolve({decision:'allow'});await settle();assert.equal(f.dispatch('submit').prevented,true);
f.timer(150);const latest=f.requests.at(-1);latest.resolve({decision:'allow'});await settle();
assert.equal(f.dispatch('beforeinput').prevented,false);
});
test('changed document URL discards an outstanding allow result and exposes no page bridge',async()=>{
const f=harness(true);f.domReady();f.timer(150);f.location.href='https://replacement.test/login';f.requests[0].resolve({decision:'allow'});await settle();
assert.equal(f.dispatch('beforeinput').prevented,true);
assert.equal(f.context.window.safetynet,undefined);assert.equal(f.context.window.ipcRenderer,undefined);
assert.ok(f.requests.every(request=>Object.keys(request.payload).sort().join(',')==='revision,token,url'));
});
test('live policy enable holds input immediately and acknowledges before asynchronous inspection',async()=>{
const f=harness(false);f.domReady();assert.equal(f.dispatch('beforeinput').prevented,false);
f.configure(true,'configuration-1');assert.equal(f.dispatch('beforeinput').prevented,true);
assert.ok(f.sent.some(item=>item.channel==='managed:visual-configured'&&item.payload==='configuration-1'));
f.timer(150);f.requests.at(-1).resolve({decision:'allow'});await settle();assert.equal(f.dispatch('beforeinput').prevented,false);
f.configure(false,'configuration-2');assert.equal(f.dispatch('beforeinput').prevented,false);assert.equal(f.dispatch('submit').prevented,false);
assert.ok(f.sent.some(item=>item.channel==='managed:visual-configured'&&item.payload==='configuration-2'));
});
test('live disable and re-enable invalidates outstanding old approval',async()=>{
const f=harness(true);f.domReady();f.timer(150);const stale=f.requests[0];
f.configure(false,'off');assert.equal(f.dispatch('beforeinput').prevented,false);
f.configure(true,'on');stale.resolve({decision:'allow'});await settle();assert.equal(f.dispatch('beforeinput').prevented,true);
});
test('credential marker attributes invalidate approval even without visual text changes',async()=>{
for(const attribute of ['name','id','autocomplete','aria-hidden','disabled']){
const f=harness(true);f.domReady();f.timer(150);f.requests[0].resolve({decision:'allow'});await settle();assert.equal(f.dispatch('beforeinput').prevented,false);
f.mutate(attribute);assert.equal(f.dispatch('beforeinput').prevented,true,`${attribute} changed a credential form without changing its visible text`);
}
});
test('legacy clipboard primitive remains frozen and unsupported page transfer events are always denied',()=>{
const f=harness(false);const descriptor=Object.getOwnPropertyDescriptor(f.context.Document.prototype,'execCommand');
assert.equal(descriptor.configurable,false);assert.equal(descriptor.writable,false);
for(const operation of ['copy','cut','paste','PasteAndMatchStyle','PasteGlobalSelection'])assert.equal(descriptor.value(operation),false);
assert.equal(descriptor.value('bold'),true);
for(const event of ['copy','cut','paste','dragstart','dragover','drop'])assert.equal(f.dispatch(event).prevented,true);
for(const type of ['insertFromPaste','insertFromDrop','deleteByCut'])assert.equal(f.dispatch('beforeinput',true,type).prevented,true);
let coerced=false;assert.equal(descriptor.value({toString:()=>{coerced=true;return 'copy';}}),false);assert.equal(coerced,false);
for(const operation of ['open','write','writeln']){
const replacement=Object.getOwnPropertyDescriptor(f.context.Document.prototype,operation);assert.equal(replacement.configurable,false);assert.equal(replacement.writable,false);
assert.throws(()=>replacement.value(),error=>error.name==='SecurityError');
}
});
test('document listener reset and page tampering with Reflect/RegExp cannot restore legacy clipboard commands',()=>{
const f=harness(false);
// Document.open clears listeners without creating a new JS realm; retain only the frozen primitive.
const result=vm.runInNewContext(`(()=>{window.addEventListener=()=>{};Reflect.apply=()=>true;RegExp.prototype.test=()=>false;try{Document.prototype.execCommand=()=>true;}catch{};return {copy:Document.prototype.execCommand('copy'),paste:Document.prototype.execCommand('paste'),bold:Document.prototype.execCommand('bold'),locked:Object.getOwnPropertyDescriptor(Document.prototype,'execCommand').configurable===false};})()`,f.context);
assert.equal(result.copy,false);assert.equal(result.paste,false);assert.equal(result.bold,true);assert.equal(result.locked,true);
});
test('HTTP insecure context without randomUUID still monitors content and uses random document token',()=>{
let randomCalls=0;
const f=harness(true,{url:'http://proxy-fixture.test/',crypto:{getRandomValues:bytes=>{randomCalls++;for(let i=0;i<bytes.length;i++)bytes[i]=i+1;return bytes;}}});
f.domReady();assert.equal(randomCalls,1);assert.equal(f.dispatch('beforeinput').prevented,true);
assert.equal(f.timer(350),true);assert.deepEqual(JSON.parse(JSON.stringify(f.sent.find(item=>item.channel==='managed:page-content').payload)),{text:'Public page content'});
f.timer(150);assert.match(f.requests[0].payload.token,/^[a-f0-9]{32}$/);assert.equal(f.requests[0].payload.url,'http://proxy-fixture.test/');
f.context.document.body.innerText='Changed public page';f.mutate('name');f.timer(350);
assert.equal(f.sent.filter(item=>item.channel==='managed:page-content').at(-1).payload.text,'Changed public page');
});