Files
2026-10-13 10:34:48 +02:00

81 lines
7.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// `unknown` is never an approval: callers must hold credential actions until a fresh verdict.
function describeDocument(expectedURL){
if(location.href!==expectedURL)throw Error('Document changed');
const visible=e=>{if(!e||e.closest('[hidden],[aria-hidden="true"],script,style,template,noscript'))return false;let p=e,count=0;while(p&&count++<32){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0')return false;p=p.parentElement;}return e.getClientRects().length>0;};
const candidates=[];
for(const e of document.querySelectorAll('input')){
if(candidates.length>=32)break;
if(!visible(e)||e.disabled||e.type==='hidden')continue;
if(/^(password|email)$/i.test(e.type)||/username|email|login|current-password|new-password/i.test([e.name,e.id,e.getAttribute('autocomplete')].join(' ')))candidates.push(e);
}
let formRect;
if(candidates.length){const container=candidates[0].closest('form')||candidates[0].closest('main,[role="main"]')||candidates[0];const r=container.getBoundingClientRect();const x=Math.max(0,r.x),y=Math.max(0,r.y),width=Math.min(innerWidth,r.right)-x,height=Math.min(innerHeight,r.bottom)-y;if(width>=16&&height>=16)formRect={x,y,width,height};}
const walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);let node,text='',visited=0;
while((node=walker.nextNode())&&visited++<20000&&text.length<8000){const e=node.parentElement;if(!e||e.closest('input,textarea,select,[contenteditable],script,style,template,noscript')||!visible(e))continue;const piece=(node.textContent||'').replace(/\s+/g,' ').trim();if(piece)text+=(text?'\n':'')+piece.slice(0,8000-text.length);}
return {hasCredentialInputs:candidates.length>0,visibleText:text.slice(0,8000),...(formRect?{formRect}:{})};
}
const describeScript=expectedURL=>`(${describeDocument.toString()})(${JSON.stringify(expectedURL)})`;
const normalize=text=>String(text).normalize('NFKC').toLowerCase().replace(/ß/g,'ss').replace(/ς/g,'σ');
function createVisualPhishing({getContext,capture,describe,fingerprint,compare,audit,block}={}){
if([getContext,capture,describe,fingerprint,compare,audit,block].some(fn=>typeof fn!=='function'))throw TypeError('Trusted visual phishing callbacks required');
let pending=null,generation=0;
const snapshot=()=>{const c=getContext();return {...c,url:c?.url||c?.webContents?.getURL(),generation};};
const current=initial=>{const c=snapshot();return c.ready&&Number.isFinite(Date.parse(c.policy?.expiresAt))&&Date.parse(c.policy.expiresAt)>Date.now()&&c.policy===initial.policy&&c.webContents===initial.webContents&&c.url===initial.url&&c.navigationId===initial.navigationId&&c.generation===initial.generation;};
const result=(initial,decision,reason,ruleId)=>({decision,reason,...(ruleId?{ruleId}:{}),checkedURL:initial.url,policyVersion:initial.policy?.version});
async function run(){
const initial=snapshot(),settings=initial.policy?.security?.visualPhishing;
if(!initial.ready)return result(initial,'unknown','browser-not-ready');
if(!Number.isFinite(Date.parse(initial.policy?.expiresAt))||Date.parse(initial.policy.expiresAt)<=Date.now())return result(initial,'unknown','policy-expired');
if(!settings?.enabled)return result(initial,'allow','visual-policy-disabled');
let pageURL;try{pageURL=new URL(initial.url);if(!['https:','http:'].includes(pageURL.protocol))throw Error();}catch{return result(initial,'unknown','unsupported-url');}
const blocked=async(reason,ruleId)=>{
if(!current(initial))return result(initial,'unknown','stale-context');
await audit({kind:'phishing.block',ruleId:ruleId||'visual-inspection-failed',host:pageURL.hostname});
if(!current(initial))return result(initial,'unknown','stale-context');
const decision=result(initial,'block',reason,ruleId||'visual-inspection-failed');await block(decision,initial);
return decision;
};
let description;
try{description=await describe(initial.url,initial);if(!current(initial))return result(initial,'unknown','stale-context');if(typeof description?.hasCredentialInputs!=='boolean'||typeof description.visibleText!=='string'||description.visibleText.length>8000)throw Error();}
catch{if(!current(initial))return result(initial,'unknown','stale-context');return settings.failClosed?blocked('visual-description-unavailable'):result(initial,'unknown','description-unavailable');}
if(!description.hasCredentialInputs)return result(initial,'allow','no-credential-inputs');
const text=normalize(description.visibleText),captures=new Map(),features=new Map();let comparable=0,unknown=false;
try{
for(const brand of settings.brands){
// Exemption applies only to this brand; other protected brands remain checked.
if(pageURL.protocol==='https:'&&brand.allowedOrigins.includes(pageURL.origin))continue;
if(!brand.requiredText.every(phrase=>text.includes(normalize(phrase))))continue;
for(const reference of brand.references){
if(reference.scope==='login-form'&&!description.formRect){unknown=true;continue;}
const key=reference.scope;let image;
if(captures.has(key))image=captures.get(key);else{image=await capture(reference.scope,description,reference);if(!current(initial))return result(initial,'unknown','stale-context');captures.set(key,image);}
if(reference.viewport){
if(!Number.isFinite(image.width)||!Number.isFinite(image.height)||image.width<=0||image.height<=0)throw Error('Capture dimensions unavailable');
const expected=reference.viewport;
if(reference.scope==='viewport'?(image.width!==expected.width||image.height!==expected.height):Math.abs((image.width/image.height)/(expected.width/expected.height)-1)>.1){unknown=true;continue;}
}
const featureKey=`${reference.scope}:${reference.algorithm}`;let feature;
if(features.has(featureKey))feature=features.get(featureKey);
else{feature=await fingerprint(image,reference.algorithm);if(!current(initial))return result(initial,'unknown','stale-context');features.set(featureKey,feature);}
const hash=typeof feature==='string'?feature:feature?.hash;
if(feature?.usable===false||!/^[a-f0-9]{16}$/.test(hash||'')){unknown=true;continue;}
const distance=await compare(hash,reference.hash);if(!current(initial))return result(initial,'unknown','stale-context');
if(!Number.isInteger(distance)||distance<0||distance>64)throw Error('Comparison unavailable');
comparable++;
if(distance<=brand.maxDistance)return blocked('credential-brand-impersonation',brand.id);
}
}
}catch{if(!current(initial))return result(initial,'unknown','stale-context');return settings.failClosed?blocked('credential-visual-inspection-failed'):result(initial,'unknown','visual-inspection-failed');}
if(unknown)return result(initial,'unknown','reference-not-comparable');
return result(initial,'allow',comparable?'no-protected-brand-match':'no-applicable-brand-reference');
}
function check({force=false}={}){
if(pending&&!force)return pending;
const prior=pending;const operation=(prior?prior.catch(()=>{}):Promise.resolve()).then(run);
pending=operation;operation.finally(()=>{if(pending===operation)pending=null;}).catch(()=>{});return operation;
}
return {check,reset:()=>{generation++;}};
}
module.exports={createVisualPhishing,describeScript};