Files
safetynet/apps/browser/password-fill.cjs
T

60 lines
6.0 KiB
JavaScript

'use strict';
// Runs only in the isolated main-frame world. No arbitrary selectors or scripts from callers.
function fillDocument(expectedUrl,origin,username,password,expiresAt,approvedForms){
if(Date.now()>=expiresAt||window.top!==window||!window.isSecureContext||location.href!==expectedUrl||location.origin!==origin)throw Error('Login destination changed');
const visible=element=>{
if(element.disabled||element.readOnly||element.closest('[hidden],[inert],[aria-hidden="true"],[aria-disabled="true"],fieldset[disabled]')||element.getClientRects().length===0)return false;
for(let node=element;node;node=node.parentElement){const style=getComputedStyle(node);if(style.display==='none'||style.visibility==='hidden'||Number(style.opacity)===0)return false;}return true;
};
const passwords=[...document.querySelectorAll('input[type="password"]')].filter(element=>visible(element)&&!String(element.autocomplete||'').toLowerCase().split(/\s+/).includes('new-password'));
if(passwords.length!==1)throw Error('A single visible login password field is required');
const target=passwords[0],form=target.form;
if(!form||form.method.toLowerCase()!=='post'||!['','_self'].includes(form.target.toLowerCase()))throw Error('A same-window POST login form is required');
const action=new URL(form.action||location.href,location.href);
if(action.protocol!=='https:'||action.origin!==origin||action.username||action.password)throw Error('Login form sends to a different destination');
for(const control of [...(form.elements||form.querySelectorAll('button,input'))]){
if(control.disabled||typeof control.getAttribute!=='function')continue;
const actionOverride=control.getAttribute('formaction'),methodOverride=control.getAttribute('formmethod'),targetOverride=control.getAttribute('formtarget');
if(actionOverride!==null){const destination=new URL(actionOverride||location.href,location.href);if(destination.protocol!=='https:'||destination.origin!==origin||destination.username||destination.password)throw Error('Login submit destination differs');}
if(methodOverride!==null&&methodOverride.toLowerCase()!=='post')throw Error('Login submit method differs');
if(targetOverride!==null&&!['','_self'].includes(targetOverride.toLowerCase()))throw Error('Login submit window differs');
}
const inputs=[...form.querySelectorAll('input')].filter(element=>visible(element)&&['text','email'].includes(element.type));
const named=inputs.filter(element=>element.autocomplete==='username'||/^(?:username|user|email|login)$/i.test(element.name||element.id));
const candidates=named.length?named:inputs;
if(username&&candidates.length!==1)throw Error('A single login username field is required');
const user=username?candidates[0]:null;
if(user&&user.form!==form)throw Error('Login fields belong to different forms');
let approved;
if(approvedForms!==undefined){
if(!Array.isArray(approvedForms)||approvedForms.length>32||!user||form.enctype.toLowerCase()!=='application/x-www-form-urlencoded')throw Error('Controlled login form is unsupported');
const matches=approvedForms.filter(rule=>rule.actionUrl===action.href&&rule.usernameField===user.name&&rule.passwordField===target.name);
if(matches.length!==1||[...(form.elements||[])].filter(element=>element.name===user.name).length!==1||[...(form.elements||[])].filter(element=>element.name===target.name).length!==1)throw Error('Login form is not explicitly approved');
approved=matches[0];
for(const control of [...(form.elements||[])]){if(control.disabled||typeof control.getAttribute!=='function')continue;const destination=control.getAttribute('formaction'),encoding=control.getAttribute('formenctype');if(destination!==null&&new URL(destination||location.href,location.href).href!==action.href||encoding!==null&&encoding.toLowerCase()!=='application/x-www-form-urlencoded')throw Error('Controlled login submit differs');}
}
const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;
// Recheck inside the target renderer, after queued native dispatch. A hidden
// or blurred document must not receive credentials after a tab switch.
if(document.visibilityState!=='visible'||!document.hasFocus())throw Error('Login tab is no longer foreground');
// Set both values before dispatching events: page handlers cannot move focus between writes.
if(user)setter.call(user,username);setter.call(target,password);
if(user)user.dispatchEvent(new Event('input',{bubbles:true}));
target.dispatchEvent(new Event('input',{bubbles:true}));
return {completed:true,...(approved?{formId:approved.id}:{})};
}
function fillScript(expectedUrl,credential,expiresAt=Date.now()+10000){
const url=new URL(expectedUrl);
if(url.protocol!=='https:'||url.username||url.password||url.origin!==credential.origin)throw Error('Saved login requires its exact HTTPS origin');
if(!Number.isFinite(expiresAt)||expiresAt<=Date.now())throw Error('Password policy expired');
return `(${fillDocument.toString()})(${JSON.stringify(url.href)},${JSON.stringify(credential.origin)},${JSON.stringify(credential.username)},${JSON.stringify(credential.password)},${JSON.stringify(expiresAt)})`;
}
module.exports={fillDocument,fillScript};
function prepareFillScript(expectedUrl,credential,forms,expiresAt=Date.now()+10000){
const url=new URL(expectedUrl);if(url.protocol!=='https:'||url.username||url.password||url.origin!==credential.origin||!Array.isArray(forms)||!Number.isFinite(expiresAt)||expiresAt<=Date.now())throw Error('Approved controlled login required');
const rules=forms.filter(row=>new URL(row.actionUrl).origin===credential.origin).map(({id,actionUrl,usernameField,passwordField})=>({id,actionUrl,usernameField,passwordField}));
if(!rules.length)throw Error('No approved login endpoint');
return `(${fillDocument.toString()})(${JSON.stringify(url.href)},${JSON.stringify(credential.origin)},${JSON.stringify(credential.username)},${JSON.stringify(credential.password)},${JSON.stringify(expiresAt)},${JSON.stringify(rules)})`;
}
module.exports.prepareFillScript=prepareFillScript;