Files
safetynet/tests/clipboard-review.test.mjs
T
2026-10-13 10:34:48 +02:00

52 lines
7.5 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import vm from 'node:vm';
import { defaultPolicy } from '../packages/core/policy.mjs';
const require=createRequire(import.meta.url),{createClipboardGuard}=require('../apps/browser/clipboard-guard.cjs'),{clipboardScript}=require('../apps/browser/clipboard-dom.cjs');
const deferred=()=>{let resolve;const promise=new Promise(r=>resolve=r);return{promise,resolve};};
function guardFixture(inspect){
const frame={},webContents={mainFrame:frame,focusedFrame:frame};let context={ready:true,policy:defaultPolicy(),url:'https://company.example/login',navigationId:1,webContents};const writes=[],commits=[],discards=[],audit=[];
const guard=createClipboardGuard({getContext:()=>context,stage:async()=>({text:'public selected text'}),validate:async()=>({completed:true}),commit:async args=>{commits.push(args);return{completed:true};},discard:async args=>discards.push(args),readClipboard:()=> 'public pasted text',writeClipboard:text=>writes.push(text),inspect,audit:async(...args)=>audit.push(args),timeoutMs:25});
return{guard,webContents,writes,commits,discards,audit,context:()=>context,setContext:c=>{context=c;}};
}
test('independent clipboard review: changed policy, navigation or readiness prevents release after scanning',async()=>{
for(const changed of ['policy','url','navigationId','ready']){const pending=deferred(),started=deferred(),f=guardFixture(async()=>{started.resolve();return pending.promise;});const operation=f.guard.perform('copy',f.webContents);await started.promise;const context={...f.context()};context[changed]=changed==='policy'?{...context.policy,version:2}:changed==='url'?'https://other.example/':changed==='navigationId'?2:false;f.setContext(context);pending.resolve({decision:'allow'});assert.equal((await operation).completed,false,changed);assert.equal(f.writes.length,0);assert.equal(f.commits.length,0);assert.equal(f.discards.length,1);assert.equal(JSON.stringify(f.audit).includes('public selected text'),false);}
});
test('independent clipboard review: timeout does not authorize delayed inspection completion',async()=>{
const pending=deferred(),started=deferred(),f=guardFixture(async()=>{started.resolve();return pending.promise;});const operation=f.guard.perform('paste',f.webContents);await started.promise;const result=await operation;assert.equal(result.ruleId,'clipboard-timeout');pending.resolve({decision:'allow'});await new Promise(r=>setImmediate(r));assert.equal(f.writes.length,0);assert.equal(f.commits.length,0);assert.ok(f.discards.length>=1);
});
test('switching away and back during scan invalidates clipboard authorization',async()=>{
const pending=deferred(),started=deferred(),f=guardFixture(async()=>{started.resolve();return pending.promise;});
f.setContext({...f.context(),tabId:'tab-a',activation:1});
const operation=f.guard.perform('copy',f.webContents);await started.promise;
f.setContext({...f.context(),tabId:'tab-a',activation:3});
pending.resolve({decision:'allow'});assert.equal((await operation).completed,false);
assert.equal(f.writes.length,0);assert.equal(f.commits.length,0);
});
test('independent clipboard review: focused nested frame cannot request a main-frame copy',async()=>{
const f=guardFixture(async()=>({decision:'allow'}));f.webContents.focusedFrame={};assert.equal((await f.guard.perform('copy',f.webContents)).completed,false);assert.equal(f.writes.length,0);assert.equal(f.commits.length,0);
});
test('independent clipboard review: failed native clipboard write cannot delete a cut selection',async()=>{
const frame={},webContents={mainFrame:frame,focusedFrame:frame},context={ready:true,policy:defaultPolicy(),url:'https://company.example/',navigationId:1,webContents};let mutations=0,discarded=0;
const guard=createClipboardGuard({getContext:()=>context,stage:async()=>({text:'selected public text'}),validate:async()=>({completed:true}),commit:async()=>{mutations++;return{completed:true};},discard:async()=>{discarded++;},readClipboard:()=>'',writeClipboard:()=>{throw Error('Native write failed');},inspect:async()=>({decision:'allow'}),audit:async()=>{}});
assert.equal((await guard.perform('cut',webContents)).completed,false);assert.equal(mutations,0);assert.equal(discarded,1);
});
function domFixture(){
const matches=(node,selector)=>{selector=selector.trim();if(selector.startsWith('[')){const match=/^\[([^=\]]+)(?:="([^"]+)")?\]$/.exec(selector);return !!match&&Object.hasOwn(node.attributes,match[1])&&(match[2]===undefined||node.attributes[match[1]]===match[2]);}return node.tagName.toLowerCase()===selector;};
const element=(tag,parent=null,attributes={})=>{const e={nodeType:1,tagName:tag.toUpperCase(),parentElement:parent,attributes,children:[],isConnected:true,type:attributes.type||'',style:{display:'block',visibility:'visible',opacity:'1'},getAttribute:name=>e.attributes[name]??null,getClientRects:()=>[{width:100,height:16}],closest:selector=>{for(let p=e;p;p=p.parentElement)if(selector.split(',').some(s=>matches(p,s)))return p;return null;},querySelector:selector=>descendants(e).find(n=>selector.split(',').some(s=>matches(n,s)))||null};parent?.children.push(e);return e;};
const descendants=e=>e.children.flatMap(c=>[c,...descendants(c)]),body=element('body'),paragraph=element('p',body),child=element('span',paragraph),range={commonAncestorContainer:paragraph,startContainer:paragraph,endContainer:paragraph,startOffset:0,endOffset:1,toString:()=> 'public text with selected hidden content',cloneRange(){return{...this};},intersectsNode:()=>true},selection={rangeCount:1,isCollapsed:false,getRangeAt:()=>range};
const document={body,activeElement:body,createTreeWalker:root=>{const nodes=descendants(root);let index=0;return{nextNode:()=>nodes[index++]||null};}},window={};window.top=window;
const sandbox={window,document,location:{href:'https://company.example/'},NodeFilter:{SHOW_ELEMENT:1},getSelection:()=>selection,getComputedStyle:e=>e.style,Event:class{}};vm.createContext(sandbox);
const execute=(mode='stage',operation='copy')=>vm.runInContext(clipboardScript(mode,{url:sandbox.location.href,token:'trusted-token',operation,text:'public replacement',limit:1024}),sandbox);
return{sandbox,document,paragraph,child,element,range,execute};
}
test('independent clipboard review: selected hidden, inert, script and ancestor-transparent content is refused',()=>{
for(const type of ['hidden','inert','aria-hidden','script','transparent']){const f=domFixture();if(type==='script')f.child.tagName='SCRIPT';else if(type==='transparent')f.paragraph.style.opacity='0';else f.child.attributes[type]=type==='aria-hidden'?'true':'';assert.throws(()=>f.execute(),undefined,type);assert.equal(f.sandbox.__safetynetClipboardSnapshot,undefined);}
const publicRange=domFixture();assert.equal(publicRange.execute().text,'public text with selected hidden content');publicRange.child.attributes.hidden='';assert.throws(()=>publicRange.execute('commit'));assert.equal(publicRange.sandbox.__safetynetClipboardSnapshot,undefined);
});
test('independent clipboard review: password fields and parent-hidden text fields never reach the scanner',()=>{
for(const type of ['password','secret-name','transparent']){const f=domFixture(),input=f.element('input',f.paragraph,{type:type==='password'?'password':'text',...(type==='secret-name'?{name:'api_token'}:{})});Object.assign(input,{value:'private field secret',selectionStart:0,selectionEnd:20,disabled:false,readOnly:false});f.document.activeElement=input;if(type==='transparent')f.paragraph.style.opacity='0';assert.throws(()=>f.execute());assert.equal(f.sandbox.__safetynetClipboardSnapshot,undefined);}
});