2026-10-13 10:33:31 +02:00
|
|
|
'use strict';
|
|
|
|
|
const {randomUUID}=require('node:crypto');
|
2026-10-13 18:04:02 +02:00
|
|
|
function createClipboardGuard({getContext,stage,validate,commit,discard,readClipboard,writeClipboard,inspect,audit,onCompleted,timeoutMs=10000}){
|
|
|
|
|
const targetFacts=value=>{if(!value||!['a','button','input','textarea','select','div','span','p','body','unknown'].includes(value.tag))return undefined;const target={tag:value.tag};if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(value.role))target.role=value.role;if(['x','y'].every(k=>Number.isSafeInteger(value[k])&&value[k]>=0&&value[k]<=100000)) {target.x=value.x;target.y=value.y;}return target;};
|
2026-10-13 10:33:31 +02:00
|
|
|
let pending=null;
|
|
|
|
|
const current=c=>{const n=getContext(c.webContents);return n.ready&&n.tabId===c.tabId&&n.activation===c.activation&&n.policy===c.policy&&n.url===c.url&&n.navigationId===c.navigationId&&n.webContents===c.webContents&&n.webContents?.focusedFrame===n.webContents?.mainFrame&&Date.parse(n.policy?.expiresAt)>Date.now();};
|
|
|
|
|
async function run(operation,webContents){
|
|
|
|
|
const c=getContext(webContents),token=randomUUID();let text='',timer,expired=false;
|
2026-10-13 18:04:02 +02:00
|
|
|
const denied=async ruleId=>{await audit('clipboard.block',ruleId,{action:operation,sessionId:c.tabId,outcome:'denied'});return{completed:false,ruleId};};
|
2026-10-13 10:33:31 +02:00
|
|
|
const work=async()=>{try{
|
|
|
|
|
if(!['copy','cut','paste'].includes(operation)||!current(c))return denied('clipboard-context-denied');
|
|
|
|
|
const limit=Math.min(c.policy.security?.clipboard?.maxTextBytes||0,c.policy.dlp.maxBytes);
|
|
|
|
|
if(!limit)return denied('clipboard-policy-unavailable');
|
|
|
|
|
const snapshot=await stage({operation,token,url:c.url,limit,webContents:c.webContents});
|
|
|
|
|
if(!current(c)||expired)return denied('clipboard-context-changed');
|
|
|
|
|
if(operation==='paste')text=await readClipboard(limit);else text=snapshot.text;
|
|
|
|
|
if(!current(c)||expired)return denied('clipboard-context-changed');
|
|
|
|
|
if(typeof text!=='string'||Buffer.byteLength(text)>limit||text.includes('\0'))return denied('clipboard-size-limit');
|
|
|
|
|
const verdict=await inspect(Buffer.from(text),c.policy,operation);
|
|
|
|
|
if(!current(c)||expired)return denied('clipboard-context-changed');
|
|
|
|
|
if(verdict?.decision!=='allow')return denied(verdict?.ruleId||'clipboard-inspection-failed');
|
|
|
|
|
// Persist authorization before side effects; never audit raw clipboard text.
|
2026-10-13 18:04:02 +02:00
|
|
|
await audit(`clipboard.${operation}`,undefined,{action:operation,sessionId:c.tabId,outcome:'authorized'});
|
2026-10-13 10:33:31 +02:00
|
|
|
if(!current(c)||expired)return denied('clipboard-context-changed');
|
|
|
|
|
const args={operation,token,url:c.url,text,limit,webContents:c.webContents};
|
|
|
|
|
if(operation==='cut'){
|
|
|
|
|
const validated=await validate(args);
|
|
|
|
|
if(validated?.completed!==true||!current(c)||expired)return denied('clipboard-target-changed');
|
|
|
|
|
await writeClipboard(text);
|
|
|
|
|
if(!current(c)||expired)return denied('clipboard-context-changed');
|
|
|
|
|
}
|
|
|
|
|
const result=await commit(args);
|
|
|
|
|
if(result?.completed!==true||!current(c)||expired)return denied('clipboard-target-changed');
|
|
|
|
|
if(operation==='copy'){await writeClipboard(text);if(!current(c)||expired)return denied('clipboard-context-changed');}
|
2026-10-13 18:04:02 +02:00
|
|
|
if(onCompleted && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(c.tabId||'')) {
|
|
|
|
|
await onCompleted({action:operation,sessionId:c.tabId,pageUrl:c.url,outcome:'completed',sensitiveField:false,...(targetFacts(snapshot.target)?{target:targetFacts(snapshot.target)}:{}),bytes:Buffer.byteLength(text)});
|
|
|
|
|
if(!current(c)||expired)return denied('clipboard-context-changed');
|
|
|
|
|
}
|
2026-10-13 10:33:31 +02:00
|
|
|
return {completed:true};
|
|
|
|
|
}finally{try{await discard?.({token,webContents:c.webContents});}catch{}}};
|
|
|
|
|
try{return await Promise.race([work(),new Promise(resolve=>{timer=setTimeout(()=>{expired=true;Promise.resolve(discard?.({token,webContents:c.webContents})).catch(()=>{});denied('clipboard-timeout').then(resolve,()=>resolve({completed:false,ruleId:'clipboard-timeout'}));},timeoutMs);})]);}
|
|
|
|
|
catch{return denied('clipboard-inspection-failed');}
|
|
|
|
|
finally{clearTimeout(timer);text='';}
|
|
|
|
|
}
|
|
|
|
|
function perform(operation,webContents){if(pending)return Promise.resolve({completed:false,ruleId:'clipboard-busy'});const task=run(operation,webContents);pending=task;task.finally(()=>{if(pending===task)pending=null;}).catch(()=>{});return task;}
|
|
|
|
|
return {perform,isPending:()=>!!pending};
|
|
|
|
|
}
|
|
|
|
|
module.exports={createClipboardGuard};
|