Files
safetynet/apps/browser/human-interaction.cjs
T

92 lines
8.0 KiB
JavaScript

'use strict';
// Runs only in the privileged isolated world; no preload IPC or page-world bridge.
function humanInteractionDOM(mode,url,token){
const key='__safetynetHumanInteraction';
const previous=globalThis[key];
if(mode==='dispose'){if(previous?.token===token){previous.dispose();delete globalThis[key];}return null;}
if(window.top!==window||location.href!==url)return null;
if(mode==='drain'){
if(!previous||previous.token!==token)return null;
previous.until=Date.now()+1500;const events=previous.queue.splice(0,16);return {events,dropped:previous.dropped};
}
if(mode!=='install')return null;
if(previous){previous.dispose();delete globalThis[key];}
const state={token,queue:[],dropped:0,until:Date.now()+1500,tokens:20,last:Date.now(),gesture:null};
const sensitive=e=>{
let steps=0;
for(let p=e;p;p=p.parentElement){
if(++steps>64)return true;
if(p.hasAttribute?.('data-sensitive')||/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|master|credit.?card|cc-number|cc-csc/i.test(['name','id','autocomplete','aria-label'].map(a=>p.getAttribute?.(a)||'').join(' '))||/^(password|file|hidden)$/i.test(p.type||''))return true;
}
return false;
};
const formSensitive=e=>{
const form=e?.tagName==='FORM'?e:e?.form||e?.closest?.('form');
if(!form)return false;
const controls=form.elements;if(!controls||controls.length>128)return true;
for(let i=0;i<controls.length;i++)if(sensitive(controls[i]))return true;
return false;
};
const record=event=>{
const now=Date.now();if(event.isTrusted!==true||now>state.until||location.href!==url||event.type==='submit'&&(navigator.userActivation?.isActive!==true||state.gesture===null||now-state.gesture>1000))return;
const target=event.target?.nodeType===1?event.target:event.target?.parentElement;
if(!target?.isConnected||sensitive(target)||formSensitive(target))return;
if(event.type==='click')state.gesture=now;
state.tokens=Math.min(20,state.tokens+Math.max(0,now-state.last)*0.01);state.last=now;
if(state.tokens<1||state.queue.length>=16){state.dropped=Math.min(1000000,state.dropped+1);return;}
state.tokens--;
const tag=['A','BUTTON','INPUT','TEXTAREA','SELECT','DIV','SPAN','P','BODY'].includes(target.tagName)?target.tagName:'OTHER';
const facts={tag};const role=target.getAttribute?.('role');
if(['link','button','checkbox','radio','textbox','combobox','text','other','menuitem','option'].includes(role))facts.role=role;
if(event.type==='click'&&Number.isFinite(event.clientX)&&Number.isFinite(event.clientY)&&event.clientX>=0&&event.clientY>=0&&event.clientX<=innerWidth&&event.clientY<=innerHeight){facts.x=Math.round(event.clientX);facts.y=Math.round(event.clientY);}
state.queue.push({action:event.type,outcome:'observed',sensitiveField:false,target:facts});
};
const enter=event=>{if(event.isTrusted===true&&event.key==='Enter'&&Date.now()<=state.until&&location.href===url&&!sensitive(event.target)&&!formSensitive(event.target))state.gesture=Date.now();};
window.addEventListener('click',record,true);window.addEventListener('submit',record,true);window.addEventListener('keydown',enter,true);
state.dispose=()=>{window.removeEventListener('click',record,true);window.removeEventListener('submit',record,true);window.removeEventListener('keydown',enter,true);state.queue.length=0;};
globalThis[key]=state;return {installed:true};
}
function humanInteractionScript(mode,{url,token}){
if(!['install','drain','dispose'].includes(mode)||typeof url!=='string'||url.length>4096||typeof token!=='string'||!/^[a-f0-9]{32,64}$/.test(token))throw TypeError('Invalid interaction observer context');
return `(${humanInteractionDOM.toString()})(${JSON.stringify(mode)},${JSON.stringify(url)},${JSON.stringify(token)})`;
}
module.exports={humanInteractionScript};
function createHumanInteractionObserver({getContext,isCurrent,executeScript,onObserved,onDropped=()=>{},interval=250,deadline=1000}){
if([getContext,isCurrent,executeScript,onObserved,onDropped].some(fn=>typeof fn!=='function'))throw TypeError('Invalid interaction observer callbacks');
if(!Number.isSafeInteger(interval)||interval<100||interval>500||!Number.isSafeInteger(deadline)||deadline<100||deadline>1000)throw TypeError('Invalid interaction observer timing');
const {randomBytes}=require('node:crypto');let current=null,retired=null,busy=false,closed=false,epoch=0;
const eligible=c=>!!c&&c.ready===true&&c.enabled===true&&c.foreground===true&&typeof c.sessionId==='string'&&typeof c.url==='string'&&typeof c.token==='string'&&Number.isSafeInteger(c.policyVersion)&&c.webContents&&!c.webContents.isDestroyed?.()&&/^https?:\/\//.test(c.url)&&isCurrent(c);
const same=(a,b)=>a&&b&&a.sessionId===b.sessionId&&a.url===b.url&&a.token===b.token&&a.policyVersion===b.policyVersion&&a.webContents===b.webContents;
const validBatch=result=>result&&typeof result==='object'&&!Array.isArray(result)&&Object.keys(result).every(k=>['events','dropped'].includes(k))&&Array.isArray(result.events)&&result.events.length<=16&&Number.isSafeInteger(result.dropped)&&result.dropped>=0&&result.dropped<=1000000;
async function tick(){
if(closed||busy)return;busy=true;const generation=epoch;let timedOut=false;const timer=setTimeout(()=>{timedOut=true;epoch++;current=null;},deadline);timer.unref?.();
try{
if(retired){const previous=retired;retired=null;await executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose');if(closed||timedOut||generation!==epoch)return;}
const context=getContext();
if(!eligible(context)){const previous=current;current=null;if(previous)await executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose');return;}
if(!same(current?.context,context)){
const previous=current;current=null;if(previous)await executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose');
if(closed||timedOut||generation!==epoch||!eligible(context))return;
const secret=randomBytes(24).toString('hex');const result=await executeScript(context.webContents,humanInteractionScript('install',{url:context.url,token:secret}),'install');
if(!closed&&!timedOut&&generation===epoch&&eligible(context)&&result?.installed===true)current={context,secret,dropped:0};
else if(result?.installed===true)await executeScript(context.webContents,humanInteractionScript('dispose',{url:context.url,token:secret}),'dispose');return;
}
const selected=current;const result=await executeScript(context.webContents,humanInteractionScript('drain',{url:context.url,token:selected.secret}),'drain');
if(closed||timedOut||generation!==epoch||current!==selected||!eligible(context)||!validBatch(result))return;
if(result.dropped>selected.dropped){await onDropped(result.dropped-selected.dropped,context);selected.dropped=result.dropped;}
for(const event of result.events){
if(closed||timedOut||generation!==epoch||current!==selected||!eligible(context))return;
// Never trust arbitrary renderer keys; the root validates the shared evidence schema too.
if(!event||typeof event!=='object'||Array.isArray(event)||Object.keys(event).some(k=>!['action','outcome','sensitiveField','target'].includes(k))||!['click','submit'].includes(event.action)||event.outcome!=='observed'||event.sensitiveField!==false)continue;
await onObserved(event,context);
}
}catch{current=null;}finally{clearTimeout(timer);busy=false;}
}
const timer=setInterval(()=>{void tick();},interval);timer.unref?.();
function refresh(){epoch++;if(current)retired=current;current=null;void tick();}
function dispose(){if(closed)return;closed=true;epoch++;clearInterval(timer);const previous=current||retired;current=null;retired=null;if(previous)Promise.resolve().then(()=>executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose')).catch(()=>{});}
return {refresh,dispose,poll:tick};
}
module.exports.createHumanInteractionObserver=createHumanInteractionObserver;