91 lines
8.0 KiB
JavaScript
91 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;
|