62 lines
9.1 KiB
JavaScript
62 lines
9.1 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);}
|
|
});
|
|
|
|
test('clipboard completion metadata follows actual commit and native write, never exposes content',async()=>{
|
|
const frame={},wc={mainFrame:frame,focusedFrame:frame},steps=[],events=[];
|
|
let c={ready:true,tabId:'71911cdc-a390-4e73-a604-d98d05fd52a5',activation:1,policy:defaultPolicy(),url:'https://company.example/',navigationId:1,webContents:wc};
|
|
const options={getContext:()=>c,stage:async()=>({text:'safe text',target:{tag:'input',x:10,y:20,text:'SECRET',selector:'#secret'}}),validate:async()=>({completed:true}),commit:async()=>{steps.push('commit');return{completed:true};},readClipboard:async()=> 'safe paste',writeClipboard:async()=>steps.push('write'),inspect:async()=>({decision:'allow'}),audit:async(kind,rule,facts)=>{steps.push('audit');assert.equal(JSON.stringify(facts).includes('safe text'),false);},onCompleted:async metadata=>{steps.push('completed');events.push(metadata);}};
|
|
const guard=createClipboardGuard(options);assert.equal((await guard.perform('copy',wc)).completed,true);
|
|
assert.deepEqual(steps,['audit','commit','write','completed']);assert.deepEqual(events,[{action:'copy',sessionId:c.tabId,pageUrl:c.url,outcome:'completed',sensitiveField:false,target:{tag:'input',x:10,y:20},bytes:9}]);
|
|
events.length=0;options.inspect=async()=>({decision:'block',ruleId:'blocked'});assert.equal((await createClipboardGuard(options).perform('paste',wc)).completed,false);assert.equal(events.length,0);
|
|
options.inspect=async()=>({decision:'allow'});options.writeClipboard=async()=>{c={...c,navigationId:c.navigationId+1};};assert.equal((await createClipboardGuard(options).perform('copy',wc)).completed,false);assert.equal(events.length,0);
|
|
});
|