Add policy-bound credential submission and native DLP audit proofs
This commit is contained in:
1 parent
b991ee9da6
commit
c8b6ff9cdd
45 files changed
+777
-98
No files matched your search
@@ -153,7 +153,7 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his
|
||||
await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.url,outcome:'completed',sensitiveField:false,...(target?{target}:{})});check(initial,args,signal);}
|
||||
return {completed:result?.completed===true};
|
||||
};
|
||||
const appActions=['vault:status','vault:list','vault:fill','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
|
||||
const appActions=['vault:prepare-submit','vault:status','vault:list','vault:fill','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
|
||||
// Native history owns target resolution and pre-release destination checks. A
|
||||
// refreshed token permits intentional navigation but never a different tab.
|
||||
const historyAdapters=Object.fromEntries(['back','forward','reload'].map(action=>[action,async(args={}, {signal}={})=>{
|
||||
|
||||
@@ -3,7 +3,7 @@ const http = require('node:http');
|
||||
const crypto = require('node:crypto');
|
||||
const channel=require('../../packages/core/policy-channel.cjs');
|
||||
|
||||
const APP_ACTIONS=Object.freeze(['sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']);
|
||||
const APP_ACTIONS=Object.freeze(['vault:prepare-submit','sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']);
|
||||
const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate', read: 'browser:read', inspect:'browser:read', scroll:'browser:interact', click: 'browser:interact', type: 'browser:interact', select:'browser:interact', check:'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) });
|
||||
const fail = (status, message) => Object.assign(new Error(message), { status });
|
||||
const bounded = (value, max, name) => {
|
||||
@@ -16,7 +16,7 @@ function validate(body) {
|
||||
if (!Object.hasOwn(SCOPES, action)) throw fail(400, 'Unsupported action');
|
||||
const args = body.args ?? {};
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) throw fail(400, 'Invalid arguments');
|
||||
const keys = { 'sessions:save':['name','sessionIds','id','expectedRevision'],'sessions:list':[],'sessions:restore':['id','planId'],'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action];
|
||||
const keys = { 'sessions:save':['name','sessionIds','id','expectedRevision'],'sessions:list':[],'sessions:restore':['id','planId'],'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'vault:prepare-submit':['sessionId','credentialRef'],'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action];
|
||||
if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument');
|
||||
const clean = {};
|
||||
if(action==='sessions:save'){
|
||||
@@ -31,8 +31,8 @@ function validate(body) {
|
||||
}
|
||||
if(action.startsWith('sessions:'))for(const key of ['id','expectedRevision','planId'])if(clean[key]!==undefined&&!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean[key]))throw fail(400,'Invalid session handle');
|
||||
if(action.startsWith('vault:')){
|
||||
if(args.sessionId!==undefined||action==='vault:fill')clean.sessionId=bounded(args.sessionId,128,'sessionId');
|
||||
if(action==='vault:fill'){clean.credentialRef=bounded(args.credentialRef,36,'credentialRef');if(!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean.credentialRef))throw fail(400,'Invalid credential reference');}
|
||||
if(args.sessionId!==undefined||['vault:fill','vault:prepare-submit'].includes(action))clean.sessionId=bounded(args.sessionId,128,'sessionId');
|
||||
if(['vault:fill','vault:prepare-submit'].includes(action)){clean.credentialRef=bounded(args.credentialRef,36,'credentialRef');if(!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean.credentialRef))throw fail(400,'Invalid credential reference');}
|
||||
}
|
||||
if(action.startsWith('history:')){
|
||||
clean.sessionId=bounded(args.sessionId,128,'sessionId');
|
||||
@@ -89,6 +89,7 @@ function project(action, result) {
|
||||
if(!Array.isArray(result)||result.length>32)throw fail(502,'Invalid vault response');
|
||||
return result.map(row=>{let origin;try{origin=new URL(row.origin);}catch{throw fail(502,'Invalid vault response');}if(typeof row.credentialRef!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(row.credentialRef)||typeof row.origin!=='string'||row.origin.length>2048||origin.protocol!=='https:'||origin.username||origin.password||origin.origin!==row.origin)throw fail(502,'Invalid vault response');return {credentialRef:row.credentialRef,origin:row.origin};});
|
||||
}
|
||||
if(action==='vault:prepare-submit')return {completed:result?.completed===true,prepared:result?.prepared===true};
|
||||
if(action==='vault:fill')return {completed:result?.completed===true};
|
||||
if(action==='history:list'){
|
||||
const uuid=value=>{if(typeof value!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value))throw fail(502,'Invalid history response');return value;};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
const {randomBytes,createHmac,createHash,timingSafeEqual}=require('node:crypto');
|
||||
const fail=()=>{throw Error('Controlled credential submission refused');};
|
||||
const field=/^[\x21-\x7e]{1,128}$/;
|
||||
const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
|
||||
function createCredentialSubmissionBroker({isCurrent,traceOperation=(_operation,fn)=>fn(),now=Date.now,setIntervalImpl=setInterval,clearIntervalImpl=clearInterval}={}){
|
||||
if(typeof isCurrent!=='function'||typeof now!=='function')throw TypeError('Credential authority callback required');
|
||||
const stage=(operation,fn)=>{let called=false,value,error;const once=()=>{if(called){if(error)throw error;return value;}called=true;try{value=fn();return value;}catch(e){error=e;throw e;}};try{traceOperation(operation,once);}catch{}if(!called)return once();if(error)throw error;return value;};
|
||||
let closed=false;const issued=new Map(),pending=new Map(),redirected=new Map(),blocked=new Map();
|
||||
const wipe=lease=>{lease.key.fill(0);lease.username.digest.fill(0);lease.password.digest.fill(0);};
|
||||
const expire=()=>{for(const [id,expiry] of blocked)if(expiry<=now())blocked.delete(id);for(const [id,l]of redirected)if(l.expires<=now()){redirected.delete(id);blocked.set(id,now()+60000);wipe(l);}for(const [id,l] of issued)if(l.expires<=now()){issued.delete(id);wipe(l);}for(const [id,l]of pending)if(l.expires<=now()){pending.delete(id);blocked.set(id,now()+60000);wipe(l);}};
|
||||
const expiryTimer=setIntervalImpl(expire,1000);expiryTimer?.unref?.();
|
||||
const digest=(key,value)=>createHmac('sha256',key).update(value,'utf8').digest();
|
||||
const match=(l,part,value)=>{const candidate=digest(l.key,value);try{return timingSafeEqual(candidate,l[part].digest);}finally{candidate.fill(0);}};
|
||||
const authority=(l,context,phase,details)=>stage('credential.authority',()=>{if(l.expires<=now()||!context.policy?.passwordManager?.controlledSubmission?.enabled||l.vault.status().locked||context.policy!==l.context.policy||context.webContents!==l.context.webContents||context.mainFrame!==l.context.mainFrame||JSON.stringify(context.actor??null)!==l.actor||context.vault!==undefined&&context.vault!==l.vault||l.vault.accessGeneration()!==l.vaultGeneration||isCurrent(l.context,{phase},details)!==true)fail();});
|
||||
const containsCredential=(l,text)=>stage('credential.match',()=>{if(typeof text!=='string'||text.length>131072)fail();let decoded=text;for(let round=0;round<3;round++){for(const part of ['username','password']){const length=l[part].length;if(length)for(let i=0;i+length<=decoded.length;i++)if(match(l,part,decoded.slice(i,i+length)))return true;}try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}return false;});
|
||||
// Native headers can be reordered and unrelated fields interleaved. This
|
||||
// bounded check covers two whole header-value fragments in either order.
|
||||
const pairCredential=(l,values)=>stage('credential.match',()=>{if(!Array.isArray(values)||values.length>128)fail();let chars=0;const variants=[];for(let index=0;index<values.length;index++){let value=values[index];if(typeof value!=='string')fail();const seen=new Set();for(let round=0;round<3;round++){if(seen.has(value))break;seen.add(value);chars+=value.length;if(chars>131072)fail();variants.push({index,value});try{const next=decodeURIComponent(value);if(next===value)break;value=next;}catch{break;}}}let work=0;for(const a of variants)for(const b of variants){if(a.index===b.index)continue;if(++work>65536)fail();const length=a.value.length+b.value.length;for(const part of ['username','password'])if(length===l[part].length&&match(l,part,a.value+b.value))return true;}return false;});
|
||||
const native=(l,details,context)=>stage('credential.native',()=>{if(!Number.isSafeInteger(details.id)||details.id<0||details.method!=='POST'||details.resourceType!=='mainFrame'||details.url!==l.form.actionUrl||details.webContentsId!==context.webContents.id||!details.frame||details.frame!==context.mainFrame||details.initiatorOrigin!==new URL(l.context.sourceURL).origin)fail();});
|
||||
function issue({context,formId,username,password,vault,vaultGeneration}){if(closed)fail();expire();if(blocked.size>=1024)fail();if(!context?.policy?.passwordManager?.controlledSubmission?.enabled||!context.token||!context.webContents||!context.mainFrame||context.webContents.mainFrame!==context.mainFrame||!vault||vault.status().locked||vault.accessGeneration()!==vaultGeneration||isCurrent(context,{phase:'issued'})!==true)fail();const forms=context.policy.passwordManager.controlledSubmission.forms;if(!Array.isArray(forms)||forms.length>32)fail();const form=forms.find(f=>f.id===formId);if(!form||!field.test(form.usernameField)||!field.test(form.passwordField)||form.usernameField===form.passwordField)fail();let action,source;try{action=new URL(form.actionUrl);source=new URL(context.sourceURL);}catch{fail();}if(action.protocol!=='https:'||source.protocol!=='https:'||action.origin!==source.origin||action.username||action.password||action.search||action.hash||action.href!==form.actionUrl)fail();if(typeof username!=='string'||typeof password!=='string'||!username||!password||username.length>512||password.length>4096||/[\u0000\uD800-\uDFFF]/u.test(username+password))fail();if(issued.size+pending.size+redirected.size>=32&&!issued.has(context.webContents.id))fail();const old=issued.get(context.webContents.id);if(old)wipe(old);const key=randomBytes(32),lease={context,actor:JSON.stringify(context.actor??null),form:{...form},vault,vaultGeneration,key,username:{digest:digest(key,username),length:username.length},password:{digest:digest(key,password),length:password.length},expires:now()+15000};issued.set(context.webContents.id,lease);return {prepared:true,expiresAt:lease.expires};}
|
||||
function claim(details,bytes,context){expire();if(redirected.has(details.id)){validateRedirect(details,context);return null;}if(blocked.has(details.id)||pending.has(details.id))fail();const l=issued.get(details.webContentsId);if(!l)return null;const candidate=details.method==='POST'&&details.resourceType==='mainFrame'&&details.url===l.form.actionUrl;let observed=false;try{observed=containsCredential(l,details.url);if(!candidate&&bytes?.length){if(!Buffer.isBuffer(bytes)||bytes.length>65536)fail();observed=observed||containsCredential(l,new TextDecoder('utf-8',{fatal:true}).decode(bytes));}}catch{observed=true;}if(!candidate&&!observed)return null;issued.delete(details.webContentsId);blocked.set(details.id,now()+60000);try{authority(l,context,'request',details);native(l,details,context);if(!Buffer.isBuffer(bytes)||bytes.length<1||bytes.length>65536||!Array.isArray(details.uploadData)||details.uploadData.some(p=>!p.bytes||p.file||p.blobUUID)||hash(Buffer.concat(details.uploadData.map(p=>Buffer.from(p.bytes))))!==hash(bytes))fail();const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes);if(/[\u0000-\u001f\u007f]/.test(text)||text.split('&').length>128)fail();const entries=stage('credential.parse',()=>text.split('&').map(raw=>{const index=raw.indexOf('=');if(index<1||/%(?![0-9a-f]{2})/i.test(raw))fail();let name,value;try{name=decodeURIComponent(raw.slice(0,index).replace(/\+/g,' '));value=decodeURIComponent(raw.slice(index+1).replace(/\+/g,' '));}catch{fail();}if(!field.test(name)||/[\u0000-\u001f\u007f]/.test(value))fail();return {name,value,raw};}));if(new Set(entries.map(e=>e.name)).size!==entries.length)fail();const u=entries.find(e=>e.name===l.form.usernameField),p=entries.find(e=>e.name===l.form.passwordField);if(!u||!p||!stage('credential.match',()=>match(l,'username',u.value)&&match(l,'password',p.value)))fail();const others=entries.filter(e=>e!==u&&e!==p);if(others.some(e=>containsCredential(l,e.name+'\n'+e.value))||containsCredential(l,others.map(e=>e.value).join(''))||containsCredential(l,others.map(e=>e.name).join('')))fail();const residual=stage('credential.residual',()=>Buffer.from([others.map(e=>e.raw).join('&'),others.map(e=>e.name+'\n'+e.value).join('\n'),others.map(e=>e.value).join(''),entries.map(e=>e.name).join('\n')].join('\n')));l.bodyHash=hash(bytes);l.requestId=details.id;l.headersValidated=false;pending.set(details.id,l);return {requestId:details.id,residual,bytes:bytes.length};}catch(error){wipe(l);throw error;}}
|
||||
function validateHeaders(details,context){expire();const l=pending.get(details.id);if(!l||l.finalized)fail();try{authority(l,context,'headers',details);native(l,details,context);const headers=details.requestHeaders;if(!headers||Object.keys(headers).length>128)fail();const names=Object.keys(headers);if(new Set(names.map(k=>k.toLowerCase())).size!==names.length)fail();const get=name=>{const found=names.filter(k=>k.toLowerCase()===name);if(found.length!==1)fail();const value=headers[found[0]];if(typeof value!=='string')fail();return value;};if(!/^application\/x-www-form-urlencoded(?:;\s*charset=utf-8)?$/i.test(get('content-type'))||names.some(k=>k.toLowerCase()==='content-encoding'))fail();if(details.uploadData&&hash(Buffer.concat(details.uploadData.map(p=>Buffer.from(p.bytes||[]))))!==l.bodyHash)fail();if(stage('credential.header-values',()=>containsCredential(l,details.url)||names.some(k=>containsCredential(l,k)||containsCredential(l,headers[k]))||containsCredential(l,names.map(k=>headers[k]).join(''))||containsCredential(l,names.join(''))||pairCredential(l,names.map(k=>headers[k]))||pairCredential(l,names)))fail();l.headersValidated=true;return {requestId:details.id};}catch(error){pending.delete(details.id);wipe(l);throw error;}}
|
||||
function finalize(id,context,details){expire();const l=pending.get(id);if(!l||l.finalized||!l.headersValidated)fail();try{authority(l,context,'headers',details);native(l,details,context);l.finalized=true;return {authorized:true};}catch(error){pending.delete(id);wipe(l);throw error;}}
|
||||
function validateRedirect(details,context){expire();const l=redirected.get(details.id);if(!l){if(blocked.has(details.id))fail();return false;}authority(l,context,'redirect',details);if(!['GET','HEAD'].includes(details.method)||details.uploadData?.length||details.resourceType!=='mainFrame'||details.webContentsId!==l.context.webContents.id||!details.frame||details.frame!==l.context.mainFrame||details.initiatorOrigin!==new URL(l.context.sourceURL).origin)fail();if(containsCredential(l,details.url))fail();if(details.requestHeaders){const headers=details.requestHeaders,names=Object.keys(headers);if(names.length>128||new Set(names.map(k=>k.toLowerCase())).size!==names.length)fail();for(const name of names)if(containsCredential(l,name)||containsCredential(l,headers[name]))fail();if(containsCredential(l,names.map(k=>headers[k]).join(''))||containsCredential(l,names.join(''))||pairCredential(l,names.map(k=>headers[k]))||pairCredential(l,names))fail();}return true;}
|
||||
function guardIssued(details,context){expire();if(pending.has(details.id)||redirected.has(details.id))return false;if(blocked.has(details.id))fail();const l=issued.get(details.webContentsId);if(!l)return false;let matched=false;try{authority(l,context,'watch',details);if(details.webContentsId!==l.context.webContents.id)fail();matched=containsCredential(l,details.url);if(details.requestHeaders){const entries=Object.entries(Object.getOwnPropertyDescriptors(details.requestHeaders));if(entries.length>128)fail();const names=[],values=[];for(const [name,descriptor]of entries){if(!('value'in descriptor)||typeof descriptor.value!=='string')fail();names.push(name);values.push(descriptor.value);if(containsCredential(l,name)||containsCredential(l,descriptor.value))matched=true;}if(new Set(names.map(n=>n.toLowerCase())).size!==names.length)fail();if(containsCredential(l,values.join(''))||containsCredential(l,names.join(''))||pairCredential(l,values)||pairCredential(l,names))matched=true;}if(!matched)return false;}catch(error){issued.delete(details.webContentsId);blocked.set(details.id,now()+60000);wipe(l);throw error;}issued.delete(details.webContentsId);blocked.set(details.id,now()+60000);wipe(l);fail();}
|
||||
function complete(id){let released=false;for(const map of [pending,redirected]){const l=map.get(id);if(l){released=released||l.finalized===true;map.delete(id);wipe(l);}}blocked.delete(id);return {released};}
|
||||
function redirect(id){const l=pending.get(id);if(!l)return;pending.delete(id);if(!l.finalized){blocked.set(id,now()+60000);wipe(l);return;}redirected.set(id,l);blocked.delete(id);}
|
||||
function revoke(){for(const l of issued.values())wipe(l);for(const map of [pending,redirected]){for(const [id,l]of map){blocked.set(id,now()+60000);wipe(l);}map.clear();}issued.clear();}
|
||||
function cancel(context){const matches=l=>l.context.webContents===context.webContents&&l.actor===JSON.stringify(context.actor??null);for(const [id,l]of issued)if(matches(l)){issued.delete(id);wipe(l);}for(const map of [pending,redirected])for(const [id,l]of map)if(matches(l)){map.delete(id);blocked.set(id,now()+60000);wipe(l);}}
|
||||
const has=id=>{expire();return pending.has(id);},isBlocked=id=>{expire();return blocked.has(id);};
|
||||
const close=()=>{if(closed)return;closed=true;clearIntervalImpl(expiryTimer);revoke();blocked.clear();};
|
||||
return {issue,claim,validateHeaders,validateRedirect,guardIssued,finalize,complete,redirect,revoke,cancel,has,isBlocked,close};
|
||||
}
|
||||
module.exports={createCredentialSubmissionBroker};
|
||||
@@ -14,7 +14,8 @@ const names=new Set(['browser.window','browser.network','browser.ipc','browser.a
|
||||
const operations=new Set(['audit.deliver','evidence.observer.install','evidence.observer.drain','evidence.observer.dispose','policy.verify','policy.bookmarks','policy.agent-reconcile','policy.page-configure','policy.audit','evidence.capture','evidence.authorize','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack','agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','inspect','scroll','click','type','select','check','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']);
|
||||
const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']);
|
||||
for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation);
|
||||
names.add('browser.vault');
|
||||
names.add('browser.vault');names.add('browser.credential');
|
||||
for(const operation of ['vault:prepare-submit','vault-prepare-submit','credential.issue','credential.claim','credential.headers','credential.authorize','credential.redirect','credential.complete','credential.failure','credential.authority','credential.native','credential.parse','credential.match','credential.residual','credential.header-values'])operations.add(operation);
|
||||
for(const operation of ['vault:status','vault:list','vault:fill','vault.inventory.inspect','vault.fill.visual','vault.fill.native'])operations.add(operation);
|
||||
// Session collection stages use fixed identifiers; names and URLs are never attributes.
|
||||
names.add('browser.session');
|
||||
|
||||
@@ -464,7 +464,7 @@
|
||||
</form>
|
||||
</details>
|
||||
<small>
|
||||
Logins fill only when you click Fill. Automatic page-load filling is disabled.
|
||||
Fill login fills only. Prepare approved login also permits one approved POST for 15 seconds. Neither button submits the form.
|
||||
</small>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
+68
-19
@@ -179,9 +179,9 @@ async function pageTheme(tab=activeTab()){
|
||||
if(!currentTabContext(context)||!result)return;const hex=/^#[a-f0-9]{6}$/i.test(result.color)?result.color:null,rgb=/^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(result.background||'');tab.color=hex||(rgb&&rgb.slice(1).every(v=>+v<=255)?'#'+rgb.slice(1).map(v=>(+v).toString(16).padStart(2,'0')).join(''):'#edf1f6');tab.title=contents.getTitle().slice(0,200);if(tab===activeTab()){pageColor=tab.color;pageTitle=tab.title;}emit();
|
||||
}catch{}
|
||||
}
|
||||
function lockVault(){const unlocked=vault&&!vault.status().locked;vault?.lock();if(unlocked)audit('vault.locked');emit();}
|
||||
function lockVault(){const unlocked=vault&&!vault.status().locked;credentialBroker?.revoke();vault?.lock();if(unlocked)audit('vault.locked');emit();}
|
||||
function quarantine(reason){ready=false;fileUploadGate?.cancelAll();if(policy&&Date.parse(policy.expiresAt)<=Date.now()){configureDiagnostics({enabled:false});const expiredService=agentService;agentService=null;agentServiceMode=undefined;expiredService?.revoke();void expiredService?.close().catch(()=>{});}for(const [controller,route] of managementRequests)if(route==='/api/device/ai')controller.abort();enforceEvidencePolicy();message=reason;vaultPanel=false;vault?.lock();tabs?.quarantine();emit();}
|
||||
function vaultSettings(){return {enabled:ready&&!!policy?.passwordManager?.enabled,allowSaving:!!policy?.passwordManager?.allowSaving,allowAutofill:!!policy?.passwordManager?.allowAutofill,idleLockSeconds:policy?.passwordManager?.idleLockSeconds||300};}
|
||||
function vaultSettings(){return {enabled:ready&&!!policy?.passwordManager?.enabled,allowSaving:!!policy?.passwordManager?.allowSaving,allowAutofill:!!policy?.passwordManager?.allowAutofill,allowControlledSubmission:!!policy?.passwordManager?.controlledSubmission?.enabled,idleLockSeconds:policy?.passwordManager?.idleLockSeconds||300};}
|
||||
function vaultStatus(){return {...(vault?.status()||{exists:fs.existsSync(path.join(app.getPath('userData'),'password-vault.json')),locked:true}),...vaultSettings()};}
|
||||
function requireVault(){
|
||||
requireFeatureSection('passwords');
|
||||
@@ -207,12 +207,12 @@ async function flushAudit(){
|
||||
try{for(let n=0;n<100&&auditQueue.length;n++){const record=auditQueue[0];await diagnostics.withCorrelation(record,()=>diagnosticSpan('browser.management',{operation:'audit.deliver'},()=>require('./evidence-delivery.cjs').deliverAuditRecord({record,send:(route,body)=>request(device.server,route,body,device.token),getContext:()=>({ready,policy,deviceId:device.id})})));auditQueue.shift();persistAudit();}}
|
||||
catch{}finally{flushing=false;}
|
||||
}
|
||||
async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts){
|
||||
async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts,origin){
|
||||
if(!device||!policy)return;
|
||||
// Operational correlation contains no rule text, hostname or forensic payload.
|
||||
try{diagnostics.event('browser.security',diagnosticContext({decision:kind.endsWith('.block')?'block':kind.endsWith('.denied')?'deny':kind.endsWith('.authorized')||kind.endsWith('.released')?'allow':'unknown',reason:ruleId}));}catch{}
|
||||
if(auditQueue.length>=10000){quarantine('Security event queue is full. Contact your administrator.');return;}
|
||||
try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;const interaction=interactionFacts?require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.parse(interactionFacts):undefined;const evidenceType=evidencePayload?.action?'interaction':'download';auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(interaction?{interaction}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy,type:evidenceType})?{protectedEvidence:{deviceId:device.id,type:evidenceType,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload,evidenceType)}}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;}
|
||||
try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;const interaction=interactionFacts?require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.parse(interactionFacts):undefined;const evidenceType=evidencePayload?.action?'interaction':'download';auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(interaction?{interaction}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy,type:evidenceType})?{protectedEvidence:{deviceId:device.id,type:evidenceType,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload,evidenceType)}}:{}),...diagnosticCorrelation(),policyVersion:Number.isSafeInteger(origin?.policyVersion)&&origin.policyVersion>0?origin.policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;}
|
||||
catch{quarantine('Security events cannot be persisted. Contact your administrator.');}
|
||||
}
|
||||
function auditAgent(event){
|
||||
@@ -245,7 +245,7 @@ async function applyPolicyImpl(envelope){
|
||||
app.configureHostResolver(resolverOptions(next.enterprise?.dns));
|
||||
await browserSession.clearHostResolverCache();recentRequests.clear();
|
||||
await managementSession.clearHostResolverCache();
|
||||
policy=next;enforceEvidencePolicy();device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics);
|
||||
credentialBroker?.revoke();policy=next;enforceEvidencePolicy();device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics);
|
||||
await diagnosticSpan('browser.policy',{operation:'policy.page-configure'},async()=>{for(const tab of tabs.all()){await configureVisualPage(next.security.visualPhishing.enabled,tab);if(!next.browser.allowDevTools){tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools();}}});
|
||||
await diagnosticSpan('browser.policy',{operation:'policy.agent-reconcile'},()=>reconcileAgentPolicy());
|
||||
await diagnosticSpan('browser.policy',{operation:'policy.audit'},()=>audit('policy.applied'));
|
||||
@@ -419,7 +419,7 @@ async function ensureAgentAPI(){
|
||||
Object.assign(actions,agentSessionControls());
|
||||
for(const action of ['click','type','select','check']){const original=actions[action];actions[action]=async(...args)=>{const tab=tabs.get(args[0]?.sessionId||activeTab().id),context=tabContext(tab.id);const checked=await checkVisual(tab);if(!currentTabContext(context)||checked.decision!=='allow')throw Error('Visual login protection has not approved this page');return original(...args);};}
|
||||
for(const action of Object.keys(actions)){const original=actions[action];actions[action]=(args,context={})=>agentActorStorage.run({...context,action},()=>diagnosticSpan('browser.agent',{operation:action},async()=>{
|
||||
if(context.principalId&&['navigate','back','forward','reload','history:go','vault:fill','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
|
||||
if(context.principalId&&['navigate','back','forward','reload','history:go','vault:fill','vault:prepare-submit','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
|
||||
return original(args,context);
|
||||
}));}
|
||||
agentServiceMode=policy.agents.identityMode;agentService=await startAgentControl({actions,principalMode:agentServiceMode,principals:agentServiceMode==='individual'?effectiveAgentPolicy().agents.grants:[],
|
||||
@@ -485,9 +485,12 @@ function hooks(){
|
||||
return diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,p,'upload'));
|
||||
})});
|
||||
for(const event of ['onCompleted','onErrorOccurred'])browserSession.webRequest[event]({urls:['<all_urls>']},details=>{
|
||||
credentialBroker?.complete(details.id);const record=credentialRequestCorrelations.get(details.id);credentialRequestCorrelations.delete(details.id);
|
||||
if(record?.released&&record.deviceId===device?.id)diagnostics.withCorrelation(record.ids,()=>diagnosticSpan('browser.credential',{operation:event==='onCompleted'?'credential.complete':'credential.failure',policyVersion:record.policyVersion,sessionId:record.sessionId},()=>audit(event==='onCompleted'?'vault.submission-completed':'vault.submission.failure',undefined,undefined,undefined,undefined,undefined,undefined,undefined,record))).catch(()=>{});
|
||||
if(!fileUploadGate.has(details.id))return;
|
||||
diagnosticSpan('browser.upload.release',{},()=>fileUploadGate.terminal(details.id)).catch(()=>{uploadLeaseFatal=true;quarantine('Upload protection cleanup failed. Restart the browser.');diagnosticSpan('browser.upload.fault',{decision:'block',reason:'upload-lease-release-failed'},()=>audit('upload.block','upload-lease-release-failed')).catch(()=>{});});
|
||||
});
|
||||
browserSession.webRequest.onBeforeRedirect({urls:['<all_urls>']},details=>credentialBroker?.redirect(details.id));
|
||||
const download=createDownloadGate({getContext:id=>{const tab=tabs.fromContents(id);return tab?{ready,policy,webContentsId:id,token:tabContext(tab.id,{foreground:false})}:null;},isCurrent:context=>!!context&¤tTabContext(context.token)&&policy===context.policy,
|
||||
fetchResponse:(url,options)=>diagnosticSpan('browser.request',{operation:'download'},()=>browserSession.fetch(url,options)),scan:(bytes,p)=>diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanDownloadEnterprise(bytes,p,localInspector?{inspect:(data,pol)=>diagnosticSpan('browser.inspect.local',{bytes:data.length},()=>localInspector.inspect(data,pol)),scanDownload:(data,pol)=>diagnosticSpan('browser.yara',{operation:'scan',bytes:data.length},()=>localInspector.scanDownload(data,pol))}:null)),
|
||||
choosePath:async name=>{const chosen=await dialog.showSaveDialog(win,{title:'Save scanned download (choose a new file)',defaultPath:path.join(app.getPath('downloads'),name)});return chosen.canceled?null:chosen.filePath;},
|
||||
@@ -510,10 +513,11 @@ function hooks(){
|
||||
const initialPolicy=policy,source=details.webContentsId>0?tabs.fromContents(details.webContentsId):null,sourceContext=source?tabContext(source.id,{foreground:false}):null;
|
||||
const sourceCurrent=()=>require('./request-source.cjs').requestSourceCurrent({details,source,token:sourceContext,registry:tabs,currentDocument:currentTabContext});let timer;
|
||||
// Async gates complete before Chromium may transmit the request.
|
||||
diagnosticSpan('browser.request',{requestType:details.resourceType,...(source?{sessionId:source.id}:{})},async()=>{try{
|
||||
requestDiagnostic(details,{requestType:details.resourceType,...(source?{sessionId:source.id}:{})},async()=>{try{
|
||||
timer=setTimeout(()=>{if(called)return;finish(true);audit('navigation.block','request-inspection-timeout',new URL(details.url).hostname).catch(()=>{});},40000);
|
||||
if(!ready||!policy||Date.parse(policy.expiresAt)<=Date.now()||!agentRequestOwnerAllowed(source)||(details.webContentsId>0&&(!source||!source.guarded))){if(ready&&policy&&(!agentRequestOwnerAllowed(source)||details.webContentsId>0&&(!source||!source.guarded)))await audit('navigation.block','invalid-source-owner',new URL(details.url).hostname);finish(true);return;}
|
||||
if(!core.destinationAllowed(details.url,policy)||(source?.security.agentAuthority&&!actorDestinationAllowed(details.url,source.security.agentAuthority))){audit('navigation.block',null,new URL(details.url).hostname);finish(true);return;}
|
||||
if(credentialBroker&&source){try{credentialBroker.guardIssued(details,credentialContext(source));credentialBroker.validateRedirect(details,credentialContext(source));}catch{await audit('vault.submission-denied');message='Login submission blocked by company policy. Contact your administrator.';emit();finish(true);return;}}
|
||||
recentRequests.set(`${details.webContentsId}:${details.url}`,{method:details.method,time:Date.now()});if(recentRequests.size>2000)for(const [url,value]of recentRequests)if(value.time<Date.now()-120000||recentRequests.size>2000)recentRequests.delete(url);
|
||||
try{await dnsGate(details.url,initialPolicy);}catch(error){audit('dns.block',error.message,new URL(details.url).hostname);finish(true);return;}
|
||||
if(called||!ready||policy!==initialPolicy||!sourceCurrent()){if(!called&&ready&&policy===initialPolicy&&!sourceCurrent())await audit('navigation.block','request-source-context-changed',new URL(details.url).hostname);finish(true);return;}
|
||||
@@ -544,17 +548,21 @@ function hooks(){
|
||||
const requestFacts=require('../../packages/core/request-transfer-facts.cjs').requestFactsForTransfer(transfer);
|
||||
const releaseUpload=async()=>{if(called)return;await audit('upload.authorized',null,new URL(details.url).hostname,undefined,requestFacts);finish(!ready||policy!==initialPolicy||!sourceCurrent());};
|
||||
if(transfer.decision!=='allow'){await audit('upload.block',transfer.ruleId,new URL(details.url).hostname,undefined,requestFacts);requestBytes.fill(0);finish(true);return;}
|
||||
const verdict=core.inspect(requestBytes,policy);requestBytes.fill(0);
|
||||
if(verdict.decision==='block'&&verdict.ruleId!=='uninspectable'){audit('dlp.block',verdict.ruleId,new URL(details.url).hostname);finish(true);return;}
|
||||
let claim;
|
||||
try{claim=credentialBroker&&source?await diagnosticSpan('browser.credential',{operation:'credential.claim'},()=>credentialBroker.claim(details,requestBytes,credentialContext(source))):null;if(claim){for(const [id,row]of credentialRequestCorrelations)if(!row.released&&row.expires<=Date.now())credentialRequestCorrelations.delete(id);if(credentialRequestCorrelations.size>=256){credentialBroker.cancel(credentialContext(source));throw Error('Controlled login audit capacity reached');}credentialRequestCorrelations.set(details.id,{ids:diagnosticCorrelation(),policyVersion:initialPolicy.version,deviceId:device.id,sessionId:source.id,expires:Date.now()+60000,released:false});}}catch{requestBytes.fill(0);claim?.residual.fill(0);await audit('vault.submission-denied');message='Login submission blocked by company policy. Contact your administrator.';emit();finish(true);return;}
|
||||
try{const scanBytes=claim?claim.residual:requestBytes;
|
||||
const verdict=core.inspect(scanBytes,policy);requestBytes.fill(0);
|
||||
if(verdict.decision==='block'&&verdict.ruleId!=='uninspectable'){claim?.residual.fill(0);const reference=await audit('dlp.block',verdict.ruleId,new URL(details.url).hostname);if(claim)await audit('vault.submission-denied');if(details.resourceType==='mainFrame'&&details.method==='POST'){message='Submission blocked by company data policy.'+(reference?' Event: '+reference:'');emit();}finish(true);return;}
|
||||
if(verdict.ruleId==='uninspectable'||policy.dlp.semantic.enabled||policy.security.yara.enabled||icapRequired(policy,'upload')){
|
||||
const needsLocal=verdict.ruleId==='uninspectable'||policy.dlp.semantic.enabled||policy.security.yara.enabled;
|
||||
if(needsLocal&&!localInspector){audit('dlp.block','inspector-unavailable',new URL(details.url).hostname);finish(true);return;}
|
||||
const snapshot=Buffer.concat(uploads.map(p=>Buffer.from(p.bytes))),currentPolicy=policy;
|
||||
if(needsLocal&&!localInspector){claim?.residual.fill(0);audit('dlp.block','inspector-unavailable',new URL(details.url).hostname);finish(true);return;}
|
||||
const snapshot=claim?Buffer.from(claim.residual):Buffer.concat(uploads.map(p=>Buffer.from(p.bytes))),currentPolicy=policy;claim?.residual.fill(0);
|
||||
try{let result=needsLocal?await diagnosticSpan('browser.inspect.local',{bytes:snapshot.length},()=>localInspector.inspect(snapshot,currentPolicy)):{decision:'allow'};if(result.decision==='allow')result=await diagnosticSpan('browser.inspect.enterprise',{bytes:snapshot.length},()=>scanEnterprise(snapshot,currentPolicy,'upload'));
|
||||
const cancel=called||!ready||policy.version!==currentPolicy.version||!sourceCurrent()||result.decision!=='allow';
|
||||
if(cancel){audit('dlp.block',result.ruleId||'policy-changed',new URL(details.url).hostname);finish(true);}else await releaseUpload();
|
||||
}catch{audit('dlp.block','inspection-incomplete',new URL(details.url).hostname);finish(true);}finally{snapshot.fill(0);}return;
|
||||
}else{await releaseUpload();return;}
|
||||
}else{claim?.residual.fill(0);await releaseUpload();return;}
|
||||
}finally{requestBytes.fill(0);claim?.residual.fill(0);}
|
||||
}
|
||||
finish(false);
|
||||
}catch{finish(true);}finally{clearTimeout(timer);}}).catch(()=>finish(true));
|
||||
@@ -566,10 +574,12 @@ function hooks(){
|
||||
const sourceCurrent=()=>require('./request-source.cjs').requestSourceCurrent({details,source,token:sourceContext,registry:tabs,currentDocument:currentTabContext});
|
||||
const current=()=>!called&&ready&&policy===initialPolicy&&Date.parse(initialPolicy?.expiresAt)>Date.now()&&sourceCurrent();
|
||||
let timer;
|
||||
diagnosticSpan('browser.request',{requestType:details.resourceType,...(source?{sessionId:source.id}:{})},async()=>{
|
||||
requestDiagnostic(details,{requestType:details.resourceType,...(source?{sessionId:source.id}:{})},async()=>{
|
||||
timer=setTimeout(()=>{if(called)return;finish(true);audit('dlp.block','headers-inspection-timeout',new URL(details.url).hostname).catch(()=>{});},40000);
|
||||
try{
|
||||
if(!current()||!agentRequestOwnerAllowed(source)||(details.webContentsId>0&&(!source||!source.guarded))||!core.destinationAllowed(details.url,initialPolicy)||(source?.security.agentAuthority&&!actorDestinationAllowed(details.url,source.security.agentAuthority))){if(ready&&policy===initialPolicy&&(!agentRequestOwnerAllowed(source)||details.webContentsId>0&&(!source||!source.guarded)))await audit('navigation.block','invalid-source-owner',new URL(details.url).hostname);finish(true);return;}
|
||||
const controlled=credentialBroker?.has(details.id)===true;
|
||||
if(credentialBroker&&source)await diagnosticSpan('browser.credential',{operation:controlled?'credential.headers':'credential.redirect'},()=>{if(controlled)return credentialBroker.validateHeaders(details,credentialContext(source));credentialBroker.guardIssued(details,credentialContext(source));return credentialBroker.validateRedirect(details,credentialContext(source));});
|
||||
const {snapshotHeaders,inspectHeaders}=require('./header-inspection.cjs');
|
||||
let snapshot;try{snapshot=snapshotHeaders(details.requestHeaders,initialPolicy);}catch(error){await audit('dlp.block',error.message==='headers-size-limit'?'headers-size-limit':'headers-uninspectable',new URL(details.url).hostname);finish(true);return;}
|
||||
const verdict=await diagnosticSpan('browser.request.headers',{bytes:snapshot.bytes,count:snapshot.count,requestType:details.resourceType},()=>inspectHeaders(snapshot,{policy:initialPolicy,inspect:core.inspect,isCurrent:current,
|
||||
@@ -577,6 +587,7 @@ function hooks(){
|
||||
inspectEnterprise:(bytes,p)=>diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,p,'upload'))}));
|
||||
if(!current()){if(ready&&policy===initialPolicy&&!sourceCurrent())await audit('navigation.block','header-source-context-changed',new URL(details.url).hostname);finish(true);return;}
|
||||
if(verdict.decision!=='allow'){await audit('dlp.block',verdict.ruleId||'headers-inspection-incomplete',new URL(details.url).hostname);finish(true);return;}
|
||||
if(controlled){await audit('vault.submission-authorized');if(!current())throw Error('Controlled login state changed');diagnostics.spanSync('browser.credential',diagnosticContext({operation:'credential.authorize'}),()=>{if(!current())throw Error('Controlled login state changed');credentialBroker.finalize(details.id,credentialContext(source),details);finish(false,snapshot.headers);const record=credentialRequestCorrelations.get(details.id);if(record)record.released=true;});return;}
|
||||
// Chromium receives the exact immutable snapshot that was approved.
|
||||
finish(false,snapshot.headers);
|
||||
}catch{if(current())await audit('dlp.block','headers-inspection-incomplete',new URL(details.url).hostname);finish(true);}
|
||||
@@ -647,6 +658,35 @@ function updateManagedAppearance(input){requireManagedSection('appearance');appe
|
||||
async function closeNativeTab(id){tabs.remove(id);if(!tabs.all().length)await createNativeTab();else selectTab(tabs.active().id);emit();return state();}
|
||||
function groupNativeTab(input){requireManagedSection('library');if(libraryOrganizer?.isBusy())throw Error('Library organization is running');if(!input||typeof input.group!=='string'||input.group.length>40)throw Error('Invalid native tab group');tabs.get(input.id).group=input.group;emit();return state();}
|
||||
let sessionSnapshotStore;
|
||||
let credentialBroker;
|
||||
const credentialRequestCorrelations=new Map();
|
||||
function requestDiagnostic(details,attributes,fn){const record=credentialRequestCorrelations.get(details.id);return record?diagnostics.withCorrelation(record.ids,()=>diagnosticSpan('browser.request',{...attributes,policyVersion:record.policyVersion},fn)):diagnosticSpan('browser.request',attributes,fn);}
|
||||
function credentialContext(tab){
|
||||
const contents=tab.contents,authority=tab.security.agentAuthority,actor=authority?.principalId?{agentId:authority.principalId,generation:authority.generation}:tab.security.credentialActor||{agentId:'human-ui',generation:0};
|
||||
return {ready,policy,token:tabContext(tab.id),sessionId:tab.id,webContents:contents,mainFrame:contents.mainFrame,sourceURL:contents.getURL(),actor,vault};
|
||||
}
|
||||
function credentialAuthorityCurrent(context,{phase},details){
|
||||
try{
|
||||
const tab=tabs.get(context.sessionId),contents=tab.contents,live=tabContext(tab.id),settings=managedSettings();
|
||||
if(!ready||policy!==context.policy||Date.parse(policy.expiresAt)<=Date.now()||!policy.passwordManager.enabled||!policy.passwordManager.allowAutofill||!policy.passwordManager.controlledSubmission.enabled||!featureAllowed(settings,'passwords')||tab!==activeTab()||contents!==context.webContents||contents.isDestroyed()||contents.mainFrame!==context.mainFrame||contents.mainFrame.processId!==context.frameProcessId||contents.mainFrame.routingId!==context.frameRoutingId||contents.getURL()!==context.sourceURL||live.activation!==context.token.activation||live.paused||vault!==context.vault||vault?.status().locked)return false;
|
||||
if(JSON.stringify(credentialContext(tab).actor)!==JSON.stringify(context.actor))return false;
|
||||
if(context.actor.agentId!=='human-ui'&&!actorGrantAllowed(context.authority,'vault:prepare-submit',details?.url||context.sourceURL))return false;
|
||||
if(phase==='issued')return currentTabContext(context.token);
|
||||
if(phase==='watch')return tab.security.interactionDocumentGeneration===context.documentGeneration||tab.security.interactionDocumentGeneration===context.documentGeneration+1;
|
||||
return phase==='redirect'?tab.security.interactionDocumentGeneration>=context.documentGeneration+1:tab.security.interactionDocumentGeneration===context.documentGeneration+1;
|
||||
}catch{return false;}
|
||||
}
|
||||
function getCredentialBroker(){return credentialBroker??=require('./credential-submission.cjs').createCredentialSubmissionBroker({isCurrent:credentialAuthorityCurrent,traceOperation:(operation,fn)=>diagnostics.spanSync('browser.credential',diagnosticContext({operation}),fn)});}
|
||||
async function armCredentialSubmission({context,formId,credential,vault:current,vaultGeneration}){
|
||||
const tab=tabs.get(context.sessionId||context.token.tabId),actor=context.actor||{agentId:'human-ui',generation:0};tab.security.credentialActor=Object.freeze({...actor});
|
||||
const snapshot={...credentialContext(tab),documentGeneration:tab.security.interactionDocumentGeneration,frameProcessId:tab.contents.mainFrame.processId,frameRoutingId:tab.contents.mainFrame.routingId,vault:current,authority:actor.agentId==='human-ui'?undefined:actor.agentId==='legacy-paired-agent'?{action:'vault:prepare-submit'}:{principalId:actor.agentId,generation:actor.generation,action:'vault:prepare-submit'}};
|
||||
const result=await diagnosticSpan('browser.credential',{operation:'credential.issue',sessionId:tab.id},()=>getCredentialBroker().issue({context:snapshot,formId,username:credential.username,password:credential.password,vault:current,vaultGeneration}));
|
||||
await audit('vault.submission-prepared');if(!credentialAuthorityCurrent(snapshot,{phase:'issued'})){getCredentialBroker().cancel(snapshot);throw Error('Controlled login state changed');}return {...result,cancel:()=>getCredentialBroker().cancel(snapshot)};
|
||||
}
|
||||
async function handoffVault(){
|
||||
const token=require('node:crypto').randomUUID();
|
||||
await new Promise((resolve,reject)=>{const handler=(event,value)=>{if(win&&!win.isDestroyed()&&event.sender===win.webContents&&event.senderFrame===win.webContents.mainFrame&&event.senderFrame?.url===uiUrl&&value===token){clearTimeout(timer);ipcMain.removeListener('managed:vault-handoff-ack',handler);resolve();}};const timer=setTimeout(()=>{ipcMain.removeListener('managed:vault-handoff-ack',handler);reject(Error('Trusted vault interface unavailable'));},1500);ipcMain.on('managed:vault-handoff-ack',handler);win.webContents.send('managed:vault-handoff',token);});
|
||||
}
|
||||
function agentSessionControls(){
|
||||
requireEncryption();sessionSnapshotStore??=require('./session-snapshot.cjs').createSessionSnapshotStore({filePath:path.join(app.getPath('userData'),'managed-sessions.bin'),protect:bytes=>safeStorage.encryptString(bytes.toString('base64')),unprotect:bytes=>Buffer.from(safeStorage.decryptString(bytes),'base64')});
|
||||
const sessionContext=id=>{const context=agentContext(id),actor=agentActorStorage.getStore();return {...context,ready:context.ready&&actorGrantAllowed(actor,actor?.action),actor:context.actor||Object.freeze({agentId:'legacy-paired-agent',generation:1})};};
|
||||
@@ -663,6 +703,7 @@ function agentAppControls(){
|
||||
const vaultControls=require('./vault-agent-controls.cjs').createVaultAgentControls({
|
||||
getContext:id=>{const context=agentContext(id);return {...context,ready:context.ready&&featureAllowed(managedSettings(),'passwords'),actor:context.actor||Object.freeze({agentId:'legacy-paired-agent',generation:1})};},
|
||||
contextCurrent:currentTabContext,getVault:requireVault,checkVisual:context=>checkVisual(tabs.get(context.sessionId)),inspect:inspectManagedAgentText,
|
||||
beforeFill:handoffVault,afterFill:()=>{vaultPanel=false;emit();},prepareSubmission:armCredentialSubmission,
|
||||
audit:event=>audit(event.action),traceOperation:(operation,fn)=>diagnosticSpan('browser.vault',{operation},fn)
|
||||
});
|
||||
const checked=(operation)=>async(args,{signal,policyVersion}={})=>{
|
||||
@@ -673,6 +714,7 @@ function agentAppControls(){
|
||||
'vault:status':checked((args,options)=>vaultControls.status(args,options)),
|
||||
'vault:list':checked((args,options)=>vaultControls.list(args,options)),
|
||||
'vault:fill':checked((args,options)=>vaultControls.fill(args,options)),
|
||||
'vault:prepare-submit':checked((args,options)=>vaultControls.prepare(args,options)),
|
||||
'window:state':checked((args,options)=>windowControls.state(args,options)),
|
||||
'window:control':checked((args,options)=>windowControls.control(args,options)),
|
||||
'application:state':checked(()=>({ready,policyVersion:policy.version,activeTabId:activeTab()?.id,tabCount:tabs.all().length})),
|
||||
@@ -798,25 +840,32 @@ app.whenReady().then(async()=>{
|
||||
ipc('managed:vault-save',async entry=>{if(!vaultSettings().allowSaving)throw Error('Saving passwords is disabled by company policy');const result=requireVault().upsert(entry);await audit('vault.saved');emit();return result;});
|
||||
ipc('managed:vault-delete',async id=>{if(!vaultSettings().allowSaving)throw Error('Password changes are disabled by company policy');const result=requireVault().delete(id);await audit('vault.deleted');return result;});
|
||||
ipc('managed:vault-generate',()=>{const current=requireVault();if(!vaultSettings().allowSaving||current.status().locked)throw Error('Unlock the vault before generating a password');return current.generatePassword();});
|
||||
ipc('managed:vault-fill',async id=>{
|
||||
const fillManagedVault=async(id,controlled=false)=>{
|
||||
const context=tabContext(),tab=tabs.get(context.tabId),contents=context.webContents;
|
||||
if(startPage||overviewPanel||toolsOpen)throw Error('Show the saved login website before filling credentials');
|
||||
if((await checkVisual(tab)).decision!=='allow'||!currentTabContext(context))throw Error('Visual login protection has not approved this page');
|
||||
if(startPage||overviewPanel||toolsOpen)throw Error('The login website is no longer visible');
|
||||
const current=requireVault(),initial=policy,url=context.url;
|
||||
if(!initial.passwordManager.allowAutofill||!core.destinationAllowed(url,initial)||new URL(url).protocol!=='https:')throw Error('Password filling requires an approved HTTPS destination');
|
||||
if(controlled&&!initial.passwordManager.controlledSubmission.enabled)throw Error('Controlled login submission is disabled by company policy');
|
||||
let credential;
|
||||
try{
|
||||
const origin=new URL(url).origin;credential={...current.credential(id,origin),origin};
|
||||
await audit('vault.fill-requested');
|
||||
if(!currentTabContext(context)||startPage||overviewPanel||toolsOpen||policy!==initial||Date.parse(initial.expiresAt)<=Date.now()||current.status().locked)throw Error('Browser state changed');
|
||||
await handoffVault();if(!currentTabContext(context)||policy!==initial||current.status().locked)throw Error('Browser state changed');tab.security.agentAuthority=null;tab.security.credentialActor=Object.freeze({agentId:'human-ui',generation:0});
|
||||
contents.focus();if(!currentTabContext(context))throw Error('Browser state changed');
|
||||
const result=await diagnosticSpan('browser.password.fill',{sessionId:context.tabId},()=>contents.executeJavaScriptInIsolatedWorld(999,[{code:fillScript(url,credential,Date.parse(initial.expiresAt))}],false));
|
||||
const code=controlled?require('./password-fill.cjs').prepareFillScript(url,credential,initial.passwordManager.controlledSubmission.forms,Date.parse(initial.expiresAt)):fillScript(url,credential,Date.parse(initial.expiresAt));
|
||||
const result=await diagnosticSpan('browser.password.fill',{sessionId:context.tabId},()=>contents.executeJavaScriptInIsolatedWorld(999,[{code}],false));
|
||||
if(!currentTabContext(context)||startPage||overviewPanel||toolsOpen||policy!==initial||current.status().locked||result?.completed!==true)throw Error('Browser state changed');
|
||||
await audit('vault.filled');return {completed:true};
|
||||
}catch{throw Error('Cannot fill this login. Unlock the vault and use its exact HTTPS origin with a single visible same-origin POST login form.');}
|
||||
await audit('vault.filled');if(!currentTabContext(context)||policy!==initial||current.status().locked)throw Error('Browser state changed');
|
||||
if(controlled)await armCredentialSubmission({context:{sessionId:tab.id,token:context},formId:result.formId,credential,vault:current,vaultGeneration:current.accessGeneration()});
|
||||
vaultPanel=false;emit();return {completed:true,...(controlled?{prepared:true}:{})};
|
||||
}catch{credentialBroker?.revoke();vaultPanel=false;emit();throw Error('Cannot fill this login. Unlock the vault and use its exact approved HTTPS origin with a single visible same-origin POST login form.');}
|
||||
finally{if(credential){credential.username='';credential.password='';}}
|
||||
});
|
||||
};
|
||||
ipc('managed:vault-fill',id=>fillManagedVault(id));
|
||||
ipc('managed:vault-prepare-submit',id=>fillManagedVault(id,true));
|
||||
ipc('managed:ai',managedAI);
|
||||
ipc('managed:agent-connection',async principalId=>{
|
||||
requireFeatureSection('agents');
|
||||
@@ -849,7 +898,7 @@ app.whenReady().then(async()=>{
|
||||
return {name:path.basename(file),...result};
|
||||
});
|
||||
win.on('close',()=>tabs.close());
|
||||
win.on('resize',layout);win.on('maximize',emit);win.on('unmaximize',emit);win.on('blur',lockVault);win.on('minimize',lockVault);win.on('closed',()=>{vault?.lock();clearInterval(vaultTimer);win=null;clearInterval(syncTimer);localInspector?.stop();agentService?.close();});
|
||||
win.on('resize',layout);win.on('maximize',emit);win.on('unmaximize',emit);win.on('blur',lockVault);win.on('minimize',lockVault);win.on('closed',()=>{credentialBroker?.close();vault?.lock();clearInterval(vaultTimer);win=null;clearInterval(syncTimer);localInspector?.stop();agentService?.close();});
|
||||
loadAppearance();
|
||||
await win.loadFile(path.join(__dirname,'index.html'));layout();
|
||||
try{device=loadDevice();const queuePath=path.join(app.getPath('userData'),'audit-queue.bin');if(device&&fs.existsSync(queuePath)){auditQueue=require('../../packages/core/request-transfer-facts.cjs').migrateQueuedRequestFacts(JSON.parse(safeStorage.decryptString(fs.readFileSync(queuePath))));persistAudit();}if(device){await sync();await audit('browser.started');}}catch(e){quarantine(`Enrollment could not load: ${e.message}`);}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use strict';
|
||||
// Runs only in the isolated main-frame world. No arbitrary selectors or scripts from callers.
|
||||
function fillDocument(expectedUrl,origin,username,password,expiresAt){
|
||||
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=>{
|
||||
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;
|
||||
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)&&element.autocomplete!=='new-password');
|
||||
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');
|
||||
@@ -25,6 +25,14 @@ function fillDocument(expectedUrl,origin,username,password,expiresAt){
|
||||
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.
|
||||
@@ -33,7 +41,7 @@ function fillDocument(expectedUrl,origin,username,password,expiresAt){
|
||||
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};
|
||||
return {completed:true,...(approved?{formId:approved.id}:{})};
|
||||
}
|
||||
function fillScript(expectedUrl,credential,expiresAt=Date.now()+10000){
|
||||
const url=new URL(expectedUrl);
|
||||
@@ -42,3 +50,10 @@ function fillScript(expectedUrl,credential,expiresAt=Date.now()+10000){
|
||||
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;
|
||||
@@ -30,6 +30,8 @@ contextBridge.exposeInMainWorld('safetynet', {
|
||||
vaultSave:entry=>ipcRenderer.invoke('managed:vault-save',entry),
|
||||
vaultDelete:id=>ipcRenderer.invoke('managed:vault-delete',id),
|
||||
vaultFill:id=>ipcRenderer.invoke('managed:vault-fill',id),
|
||||
vaultPrepareSubmit:id=>ipcRenderer.invoke('managed:vault-prepare-submit',id),
|
||||
vaultGenerate:()=>ipcRenderer.invoke('managed:vault-generate'),
|
||||
onVaultHandoff:callback=>{ipcRenderer.on('managed:vault-handoff',(_event,token)=>{callback();ipcRenderer.send('managed:vault-handoff-ack',token);});},
|
||||
onState:callback=>{const listener=(_event,data)=>callback(data);ipcRenderer.on('managed:state',listener);return()=>ipcRenderer.removeListener('managed:state',listener);}
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
const $=id=>document.getElementById(id);let panel=false,vaultPanel=false,lastState,vaultState,vaultBusy=false,vaultRevision=0;
|
||||
window.safetynet.onVaultHandoff(()=>{vaultRevision++;vaultPanel=false;$('vault').hidden=true;$('passwords').setAttribute('aria-expanded','false');clearVaultFields();$('vault-list').replaceChildren();});
|
||||
let sidebarOpen=false,startPageOpen=false,overviewOpen=false,tabs=[],activeTab=null,groupFilter='',navigationRevision=0,pendingNavigation=null,lastLayoutKey='',lastTabsKey='',lastLibraryKey='',nativeTabsRevision=-1;
|
||||
const preference=(key,fallback)=>{try{return JSON.parse(localStorage.getItem(`sbrowser.${key}`))??fallback;}catch{return fallback;}};
|
||||
const remember=(key,value)=>{try{localStorage.setItem(`sbrowser.${key}`,JSON.stringify(value));}catch{}};
|
||||
@@ -213,7 +214,7 @@ async function refreshVault(){
|
||||
if(!status.enabled||status.locked||!status.exists){if(!status.enabled||previous?.locked===false)clearVaultFields();$('vault-list').replaceChildren();return;}
|
||||
const entries=await call('vaultList');if(!vaultPanel||revision!==vaultRevision)return;$('vault-list').replaceChildren();
|
||||
if(!entries.length){const empty=document.createElement('div');empty.className='empty-vault';empty.textContent='No saved logins yet.';$('vault-list').append(empty);}
|
||||
for(const item of entries){const card=document.createElement('div');card.className='login-card';const label=document.createElement('strong');label.textContent=item.label||item.origin;const origin=document.createElement('p');origin.textContent=item.origin;const username=document.createElement('p');username.textContent=item.username||'No username';const buttons=document.createElement('div');buttons.className='login-buttons';const fill=document.createElement('button');fill.textContent='Fill login';fill.disabled=!status.allowAutofill;fill.onclick=vaultTask(async()=>{await call('vaultFill',item.id);$('vault-error').textContent='Login filled in the current page.';});const remove=document.createElement('button');remove.className='secondary';remove.textContent='Delete';remove.disabled=!status.allowSaving;remove.onclick=vaultTask(async()=>{await call('vaultDelete',item.id);await refreshVault();});buttons.append(fill,remove);card.append(label,origin,username,buttons);$('vault-list').append(card);}
|
||||
for(const item of entries){const card=document.createElement('div');card.className='login-card';const label=document.createElement('strong');label.textContent=item.label||item.origin;const origin=document.createElement('p');origin.textContent=item.origin;const username=document.createElement('p');username.textContent=item.username||'No username';const buttons=document.createElement('div');buttons.className='login-buttons';const fill=document.createElement('button');fill.textContent='Fill login';fill.disabled=!status.allowAutofill;fill.onclick=vaultTask(async()=>{await call('vaultFill',item.id);$('vault-error').textContent='Login filled. Submission remains subject to company DLP policy.';});const remove=document.createElement('button');remove.className='secondary';remove.textContent='Delete';remove.disabled=!status.allowSaving;remove.onclick=vaultTask(async()=>{await call('vaultDelete',item.id);await refreshVault();});const prepare=document.createElement('button');prepare.textContent='Prepare approved login';prepare.title='Fill and permit one approved login POST for up to 15 seconds. Submit on the website; other fields still pass company DLP.';prepare.hidden=!status.allowControlledSubmission;prepare.disabled=!status.allowAutofill;prepare.onclick=vaultTask(async()=>{await call('vaultPrepareSubmit',item.id);});buttons.append(fill,prepare,remove);card.append(label,origin,username,buttons);$('vault-list').append(card);}
|
||||
}
|
||||
$('passwords').onclick=vaultTask(async()=>{requireSection('passwords',true);if(vaultPanel){await closeVault();return;}await closeAI();vaultPanel=true;$('vault').hidden=false;$('passwords').setAttribute('aria-expanded','true');await call('toggleVault',true);await refreshVault();});
|
||||
$('vault-close').onclick=vaultTask(closeVault);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use strict';
|
||||
const {randomUUID}=require('node:crypto');
|
||||
const {fillScript}=require('./password-fill.cjs');
|
||||
const {fillScript,prepareFillScript}=require('./password-fill.cjs');
|
||||
const refused=()=>{throw Error('Managed vault operation refused');};
|
||||
const idPattern=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const hostMatch=(name,pattern)=>pattern.startsWith('*.')?name.endsWith(pattern.slice(1))&&name!==pattern.slice(2):name===pattern;
|
||||
function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisual,inspect,audit,traceOperation=async(_name,fn)=>fn(),now=Date.now}={}) {
|
||||
function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisual,inspect,audit,beforeFill=async()=>{},afterFill=()=>{},prepareSubmission,traceOperation=async(_name,fn)=>fn(),now=Date.now}={}) {
|
||||
if([getContext,contextCurrent,getVault,checkVisual,inspect,audit,traceOperation,now].some(fn=>typeof fn!=='function'))throw TypeError('Vault boundary callbacks required');
|
||||
const references=new Map();
|
||||
const allowed=(origin,c)=>{
|
||||
@@ -13,7 +13,7 @@ function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisua
|
||||
return u.protocol==='https:'&&!u.username&&!u.password&&u.origin===origin&&Array.isArray(hosts)&&(!hosts.length||hosts.some(p=>typeof p==='string'&&hostMatch(u.hostname.toLowerCase(),p.toLowerCase())))&&c.destinationAllowed(origin,c.policy)===true;
|
||||
};
|
||||
function snapshot(args,signal,action){
|
||||
const fields=action==='vault:fill'?['sessionId','credentialRef']:['sessionId'];
|
||||
const fields=['vault:fill','vault:prepare-submit'].includes(action)?['sessionId','credentialRef']:['sessionId'];
|
||||
if(!args||typeof args!=='object'||Array.isArray(args)||Object.keys(args).some(key=>!fields.includes(key))||(args.sessionId!==undefined&&!idPattern.test(args.sessionId)))refused();
|
||||
const c=getContext(args.sessionId);
|
||||
if(signal?.aborted||!c?.ready||!c.token||(!c.actor||typeof c.actor.agentId!=='string'||!c.actor.agentId||!Number.isSafeInteger(c.actor.generation))||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||!c.policy.passwordManager?.enabled||!Number.isFinite(Date.parse(c.policy.expiresAt))||Date.parse(c.policy.expiresAt)<=now()||typeof c.destinationAllowed!=='function')refused();
|
||||
@@ -45,10 +45,11 @@ function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisua
|
||||
if(references.size+Math.min(safe.length,32)>32)refused();
|
||||
return safe.slice(0,32).map(row=>{const credentialRef=randomUUID();references.set(credentialRef,{...row,sessionId:c.sessionId,webContents:c.webContents,actor:{...c.actor},generation:vault.accessGeneration(),policy:c.policy,token:c.token,vault,expires:now()+60000});return {credentialRef,origin:row.origin};});
|
||||
};
|
||||
const fill=async(args={}, {signal}={})=>{
|
||||
const fill=async(args={}, {signal}={},action='vault:fill')=>{
|
||||
let credential;
|
||||
try{
|
||||
const c=snapshot(args,signal,'vault:fill'),vault=unlocked(c),ref=references.get(args.credentialRef);
|
||||
const c=snapshot(args,signal,action),vault=unlocked(c),ref=references.get(args.credentialRef);
|
||||
const controlled=action==='vault:prepare-submit';if(controlled&&(!c.policy.passwordManager.controlledSubmission?.enabled||typeof prepareSubmission!=='function'))refused();
|
||||
if(!idPattern.test(args.credentialRef||'')||!ref||ref.sessionId!==c.sessionId||ref.webContents!==c.webContents||ref.expires<=now()||(ref.actor.agentId!==c.actor.agentId||ref.actor.generation!==c.actor.generation)||ref.generation!==vault.accessGeneration()||ref.policy!==c.policy||ref.vault!==vault||!contextCurrent(ref.token)||c.foreground!==true||c.interactive===false||!c.policy.passwordManager.allowAutofill||!c.webContents||c.webContents.isDestroyed?.())refused();
|
||||
const url=c.webContents.getURL(),u=new URL(url);
|
||||
if(u.username||u.password||u.protocol!=='https:'||u.origin!==ref.origin||!allowed(ref.origin,c))refused();
|
||||
@@ -58,13 +59,16 @@ function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisua
|
||||
// Consume once before the native effect: failures never authorize an automatic retry.
|
||||
references.delete(args.credentialRef);
|
||||
credential={...vault.credential(ref.id,ref.origin),origin:ref.origin};recheck();
|
||||
c.webContents.focus();recheck();
|
||||
const result=await traceOperation('vault.fill.native',()=>c.webContents.executeJavaScriptInIsolatedWorld(999,[{code:fillScript(url,credential,Date.parse(c.policy.expiresAt))}],false));
|
||||
await beforeFill(c);recheck();c.webContents.focus();recheck();
|
||||
const code=controlled?prepareFillScript(url,credential,c.policy.passwordManager.controlledSubmission.forms,Date.parse(c.policy.expiresAt)):fillScript(url,credential,Date.parse(c.policy.expiresAt));
|
||||
const result=await traceOperation('vault.fill.native',()=>c.webContents.executeJavaScriptInIsolatedWorld(999,[{code}],false));
|
||||
recheck();if(result?.completed!==true)refused();
|
||||
await audit({action:'vault.filled',outcome:'completed',sessionId:c.sessionId});recheck();return {completed:true};
|
||||
await audit({action:'vault.filled',outcome:'completed',sessionId:c.sessionId});recheck();
|
||||
if(controlled){const resultLease=await prepareSubmission({context:c,formId:result.formId,credential,vault,vaultGeneration:ref.generation});try{recheck();if(resultLease?.prepared!==true)refused();afterFill();return {completed:true,prepared:true};}catch(error){resultLease?.cancel?.();throw error;}}
|
||||
afterFill();return {completed:true};
|
||||
}catch{refused();}finally{if(credential){credential.username='';credential.password='';credential.origin='';}}
|
||||
};
|
||||
const safe=fn=>async(...args)=>{try{return await fn(...args);}catch{refused();}};
|
||||
return {status:safe(status),list:safe(list),fill:safe(fill),clear:()=>references.clear()};
|
||||
return {status:safe(status),list:safe(list),fill:safe(fill),prepare:safe((args,options)=>fill(args,options,'vault:prepare-submit')),clear:()=>references.clear()};
|
||||
}
|
||||
module.exports={createVaultAgentControls};
|
||||
Reference in new issue
Block a user