45 lines
3.9 KiB
JavaScript
45 lines
3.9 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){
|
|
if(Date.now()>=expiresAt||window.top!==window||!window.isSecureContext||location.href!==expectedUrl||location.origin!==origin)throw Error('Login destination changed');
|
|
const visible=element=>{
|
|
const style=getComputedStyle(element);
|
|
return !element.disabled&&!element.readOnly&&!element.closest('[hidden],[inert],[aria-hidden="true"]')&&style.display!=='none'&&style.visibility!=='hidden'&&Number(style.opacity)!==0&&element.getClientRects().length>0;
|
|
};
|
|
const passwords=[...document.querySelectorAll('input[type="password"]')].filter(element=>visible(element)&&element.autocomplete!=='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');
|
|
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};
|
|
}
|
|
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};
|