81 lines
7.3 KiB
JavaScript
81 lines
7.3 KiB
JavaScript
'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};
|