Files
safetynet/apps/browser/agent-visual-probe.cjs
T

62 lines
3.7 KiB
JavaScript
Raw Normal View History

'use strict';
// Runs only in a private isolated world. No bridge, page callback or caller code.
function visualDocumentProbe(expectedURL) {
if (location.href !== expectedURL || document.readyState !== 'complete') throw Error('Visual probe refused');
const key='__safetynetVisualProbeV1';
let state=globalThis[key];
if (!state) {
state={id:crypto.randomUUID(),revision:0,roots:new WeakSet(),watches:[]};
const changed=()=>{state.revision++;};
state.watch=root=>{
if(state.roots.has(root))return;
if(state.watches.length>=129)throw Error('Visual probe refused');
const observer=new MutationObserver(changed);
observer.observe(root,{subtree:true,childList:true,characterData:true,attributes:true});
state.roots.add(root);state.watches.push({root,observer});
if(root!==document)changed();
};
state.watch(document);
for (const name of ['input','change','scroll','resize']) addEventListener(name,changed,true);
Object.defineProperty(globalThis,key,{value:state,writable:false,configurable:false});
}
for(const watch of [...state.watches]){
if(watch.root!==document&&watch.root.host?.isConnected===false){watch.observer.disconnect();state.roots.delete(watch.root);state.watches.splice(state.watches.indexOf(watch),1);state.revision++;}
else if(watch.observer.takeRecords().length)state.revision++;
}
const forbidden=/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|credit.?card|cc-number|cc-csc/i;
let safe=true,visited=0;
const visible=e=>{
for(let n=e;n;n=n.parentElement){const s=getComputedStyle(n);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0'||n.hidden)return false;}
const r=e.getBoundingClientRect();return r.width>0&&r.height>0&&r.bottom>0&&r.right>0&&r.left<innerWidth&&r.top<innerHeight;
};
function walk(root) {
state.watch(root);
for(const e of root.querySelectorAll('*')) {
if(++visited>20000){safe=false;return;}
if(['IFRAME','FRAME','OBJECT','EMBED'].includes(e.tagName)){safe=false;return;}
// Credential-bearing controls are refused even offscreen: a transient CSS
// reveal must not evade the checks surrounding native surface capture.
if(/^(INPUT|TEXTAREA|SELECT)$/.test(e.tagName)&&
(e.type==='password'||['name','id','autocomplete','aria-label'].some(a=>forbidden.test(e.getAttribute(a)||'')))){safe=false;return;}
if(e.hasAttribute('data-sensitive')&&visible(e)){safe=false;return;}
if(e.shadowRoot){walk(e.shadowRoot);if(!safe)return;}
}
}
walk(document);
const width=Math.round(innerWidth),height=Math.round(innerHeight);
// Active media/animations are not represented by the DOM revision counter.
if(state.watches.some(w=>(w.root.getAnimations?.()||[]).some(a=>a.playState==='running'))||
[...document.querySelectorAll('video')].some(e=>visible(e)&&!e.paused))safe=false;
return {safe,revision:[state.id,state.revision,width,height,scrollX,scrollY,devicePixelRatio,visualViewport?.scale||1].join(':'),width,height};
}
async function probeVisualContext(context) {
const contents=context.webContents,frame=contents.mainFrame;
if(!frame||frame.isDestroyed()||frame.framesInSubtree.length!==1||contents.isLoading())throw Error('Managed visual export refused');
await require('./page-clipboard-guard.cjs').guardedVisualStructure(contents);
const result=await contents.executeJavaScriptInIsolatedWorld(1004,[{code:`(${visualDocumentProbe.toString()})(${JSON.stringify(context.url)})`}],false);
if(contents.isDestroyed()||contents.mainFrame!==frame||frame.isDestroyed()||frame.framesInSubtree.length!==1)throw Error('Managed visual export refused');
return result;
}
module.exports={probeVisualContext,visualDocumentProbe};