Harden Windows DLP enforcement and Electron runtime packaging

This commit is contained in:
Karl committed 2026-10-13 23:19:22 +02:00
1 parent e7d778aeac
commit 8a253eda89
120 files changed
+3569 -135

No files matched your search

+3 -2
View File
@@ -139,7 +139,7 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his
};
const dom = mode => async (args,{signal}={}) => {
const initial = snapshot(args,signal,mode);
if (mode!=='read' && (typeof args.selector!=='string' || args.selector.length>512 || !/^[a-zA-Z0-9_#.*\[\]="' >+~-]+$/.test(args.selector))) throw Error('Invalid selector');
if (mode!=='read' && (typeof args.selector!=='string' || args.selector.length>512 || !/^[a-zA-Z0-9_#.*[\]="' >+~-]+$/.test(args.selector))) throw Error('Invalid selector');
if (mode==='type' && (typeof args.text!=='string' || args.text.length>16384)) throw Error('Invalid text');
if (mode==='select' && (typeof args.value!=='string' || args.value.length>16384)) throw Error('Invalid value');
if (mode==='check' && typeof args.checked!=='boolean') throw Error('Invalid checked state');
@@ -200,6 +200,7 @@ await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.
for(const [id,item] of historySnapshots)if(item.expires<=Date.now())historySnapshots.delete(id);
while(historySnapshots.size>=32)historySnapshots.delete(historySnapshots.keys().next().value);
const snapshotId=randomUUID();historySnapshots.set(snapshotId,{initial,binding,actorBinding,entries,expires:Date.now()+60000});
// eslint-disable-next-line no-unused-vars -- Strip private native indexes and navigation flags from released metadata.
return {snapshotId,entries:entries.map(({index,navigable,...row})=>row)};
},
'history:go':async(args={}, {signal}={})=>{
@@ -237,7 +238,7 @@ await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.
...appAdapters,
...historyAdapters,
...historyAdaptersExtra,
sessions:async(args={}, {signal}={})=>{
sessions:async(_args={}, {signal}={})=>{
const c=getContext();
if(signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.policy.agents.scopes?.includes('sessions') || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable');
const version=c.policyVersion??c.policy.version;
+5 -1
View File
@@ -7,6 +7,7 @@ const APP_ACTIONS=Object.freeze(['vault:prepare-submit','sessions:save','session
const SCOPES = Object.freeze({ screenshot:'browser:screenshot', 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) => {
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
if (typeof value !== 'string' || !value.length || value.length > max || /\u0000/.test(value)) throw fail(400, `Invalid ${name}`);
return value;
};
@@ -48,8 +49,9 @@ function validate(body) {
if (['click', 'type','select','check'].includes(action)) {
clean.selector = bounded(args.selector, 512, 'selector');
// A small CSS subset prevents pseudo-elements, escapes and control characters.
if (!/^[a-zA-Z0-9_#.*\[\]="' >+~-]+$/.test(clean.selector) || /password|autocomplete/i.test(clean.selector)) throw fail(400, 'Unsupported selector');
if (!/^[a-zA-Z0-9_#.*[\]="' >+~-]+$/.test(clean.selector) || /password|autocomplete/i.test(clean.selector)) throw fail(400, 'Unsupported selector');
}
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
if(action==='select'){if(typeof args.value!=='string'||args.value.length>1024||/\u0000/.test(args.value))throw fail(400,'Invalid option value');clean.value=args.value;}
if(action==='check'){if(typeof args.checked!=='boolean')throw fail(400,'Invalid checked state');clean.checked=args.checked;}
if(action==='scroll'){for(const key of ['x','y']){if(!Number.isSafeInteger(args[key])||Math.abs(args[key])>100000)throw fail(400,'Invalid scroll');clean[key]=args[key];}}
@@ -57,12 +59,14 @@ function validate(body) {
if (action === 'ai') clean.prompt = bounded(args.prompt, 16384, 'prompt');
if(action.startsWith('tabs:')&&action!=='tabs:create')clean.sessionId=bounded(args.sessionId,128,'sessionId');
if(action==='tabs:create'&&args.url!==undefined){let url;try{url=new URL(bounded(args.url,4096,'url'));}catch{throw fail(400,'Invalid URL');}if(!['http:','https:'].includes(url.protocol)||url.username||url.password)throw fail(400,'Unsupported URL');clean.url=url.href;}
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
if(['tabs:create','tabs:group'].includes(action)&&args.group!==undefined){if(typeof args.group!=='string'||args.group.length>40||/[\u0000-\u001f\u007f]/.test(args.group))throw fail(400,'Invalid group');clean.group=args.group;}
if(action==='tabs:group'&&args.group===undefined)throw fail(400,'Invalid group');
if(action==='settings:update'){try{clean.appearance=require('./managed-settings.cjs').validateAppearance(args.appearance);}catch{throw fail(400,'Invalid appearance settings');}}
if(action==='bookmarks:mutate'){
if(!['add','update','delete'].includes(args.action)||!Number.isSafeInteger(args.expectedRevision)||args.expectedRevision<0||!Number.isSafeInteger(args.policyVersion)||args.policyVersion<1)throw fail(400,'Invalid bookmark operation');
clean.action=args.action;clean.expectedRevision=args.expectedRevision;clean.policyVersion=args.policyVersion;
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
if(args.action==='delete')clean.id=bounded(args.id,80,'bookmark id');else{const item=args.bookmark;if(!item||typeof item!=='object'||Array.isArray(item)||Object.keys(item).some(key=>!['id','title','url','folder'].includes(key)))throw fail(400,'Invalid bookmark');const folder=item.folder===undefined?'':item.folder;if(typeof folder!=='string'||folder.length>80||/[\u0000-\u001f\u007f]/.test(folder))throw fail(400,'Invalid bookmark folder');clean.bookmark={id:bounded(item.id,80,'bookmark id'),title:bounded(item.title,120,'bookmark title'),url:bounded(item.url,2048,'bookmark URL'),folder};}
}
if(action==='library:organize-apply'){clean.proposalId=bounded(args.proposalId,128,'proposal id');if(args.confirm!==undefined&&typeof args.confirm!=='boolean'||args.autoApply!==undefined&&typeof args.autoApply!=='boolean')throw fail(400,'Invalid organization confirmation');if(args.confirm!==undefined)clean.confirm=args.confirm;if(args.autoApply!==undefined)clean.autoApply=args.autoApply;}
+1
View File
@@ -59,6 +59,7 @@ async function persistConnection({filePath,connection,protect,isCurrent}){
guard(target);fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;
if(!isCurrent())throw Error('Agent credential context changed');guard(target);fs.renameSync(temporary,target);
return target;
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
}finally{bytes.fill(0);if(fd!==undefined)fs.closeSync(fd);try{fs.unlinkSync(temporary);}catch(error){if(error.code!=='ENOENT')throw error;}}
}
module.exports={activeGrants,authorizeGrant,reconcileGenerationFloor,descriptorPath,persistConnection};
+1
View File
@@ -1,6 +1,7 @@
'use strict';
const {randomUUID}=require('node:crypto');
const credential=/password|passwd|api.?key|access.?token|refresh.?token|client.?secret|authorization|credential|session.?id|-----BEGIN|\b(?:sk-|gh[pousr]_)[A-Za-z0-9_-]{16,}/i;
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
function safeText(value,max){if(typeof value!=='string'||value.length>max||/[\u0000-\u001f]/.test(value))throw Error('Invalid session metadata');let decoded=value;for(let i=0;i<4;i++){if(credential.test(decoded))throw Error('Sensitive session metadata');try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}return value;}
function sensitiveURL(value){for(let i=0;i<5;i++){if(/[?&#](?:password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|code|authorization|credential|session|sid|auth|samlresponse|samlrequest|assertion|ticket|id_token)=/i.test(value)||/\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|[?#]|$)/i.test(value))return true;try{const next=decodeURIComponent(value);if(next===value)break;value=next;}catch{return true;}}return false;}
function safeTab(row){if(!row||Object.keys(row).some(k=>!['url','title','group'].includes(k)))throw Error('Invalid session metadata');const title=safeText(row.title,200),raw=safeText(row.url,4096),url=new URL(raw);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||sensitiveURL(raw)||/\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|$)/i.test(decodeURIComponent(url.pathname)))throw Error('Unsafe session destination');return {url:url.href,title,...(row.group===undefined?{}:{group:safeText(row.group,80)})};}
+25
View File
@@ -0,0 +1,25 @@
'use strict';
const {randomUUID}=require('node:crypto');
const denied=()=>{const error=Error('Native agent session ownership refused');error.status=403;throw error;};
function identity(input){if(!input||typeof input!=='object'||Object.keys(input).sort().join(',')!=='deviceId,generation,principalId'||typeof input.deviceId!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(input.deviceId)||typeof input.principalId!=='string'||!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(input.principalId)||!Number.isSafeInteger(input.generation)||input.generation<1)denied();return Object.freeze({deviceId:input.deviceId.toLowerCase(),principalId:input.principalId,generation:input.generation});}
function createAgentSessionOwnership({createSession,installGuards,isLive,quarantineSession=()=>{},disposeSession=async session=>{await session.closeAllConnections();await session.clearStorageData();},maxSessions=32}={}){
if([createSession,installGuards,isLive,quarantineSession,disposeSession].some(fn=>typeof fn!=='function')||!Number.isInteger(maxSessions)||maxSessions<1||maxSessions>32)throw TypeError('Native ownership callbacks required');
const entries=new Map(),nativeEntries=new WeakMap(),revoked=new Set();let closed=false;
const live=(owner,options)=>{try{return isLive(owner,options)===true;}catch{return false;}};
const key=owner=>JSON.stringify([owner.deviceId,owner.principalId,owner.generation]);
const assertLive=(entry,options={})=>{if(closed||entry.active!==true||revoked.has(entry.key)||(!entry.ready&&!['acquire','install','installed'].includes(options.phase))||!live(entry.owner,{phase:options.phase||'network',url:options.url,background:options.background===true}))denied();};
const invalidate=entry=>{if(!entry.active)return;entry.active=false;entry.epoch++;revoked.add(entry.key);try{if(entry.session)quarantineSession(entry.session,entry.owner);}catch{/* Native release guards are already revoked. */}};
const cleanup=async entry=>{if(entry.cleanup)return entry.cleanup;entry.cleanup=(async()=>{if(entry.session&&!entry.disposed){await disposeSession(entry.session,entry.owner);entry.disposed=true;}if(entries.get(entry.key)===entry)entries.delete(entry.key);})();try{await entry.cleanup;}finally{entry.cleanup=null;}};
async function acquire(input,{signal}={}){const owner=identity(input),ownerKey=key(owner);if(closed||signal?.aborted||revoked.has(ownerKey)||revoked.size>=4096||!live(owner,{phase:'acquire',background:false}))denied();let entry=entries.get(ownerKey);if(!entry){if(entries.size>=maxSessions)denied();entry={owner,key:ownerKey,partition:'safetynet-agent-'+randomUUID(),active:true,ready:false,epoch:1,session:null,disposed:false};entries.set(ownerKey,entry);entry.promise=(async()=>{try{assertLive(entry,{phase:'acquire'});const session=await createSession(entry.partition,{cache:false});if(!session||typeof session!=='object'||nativeEntries.has(session))denied();entry.session=session;nativeEntries.set(session,entry);assertLive(entry,{phase:'install'});await installGuards(session,owner,options=>assertLive(entry,options));assertLive(entry,{phase:'installed'});entry.ready=true;return session;}catch(error){invalidate(entry);try{await cleanup(entry);}catch{}throw error;}})();}const aborted=()=>invalidate(entry);signal?.addEventListener('abort',aborted,{once:true});try{const session=await entry.promise;if(signal?.aborted)denied();assertLive(entry,{phase:'acquire'});if(!entry.ready)denied();return session;}finally{signal?.removeEventListener('abort',aborted);if(!entry.active)try{await cleanup(entry);}catch{}}}
function getOwner(session){const entry=nativeEntries.get(session);if(!entry||!entry.ready)denied();assertLive(entry,{phase:'owner'});return entry.owner;}
function guard(session,options={}){options=Object.freeze({phase:options.phase,url:options.url,background:options.background===true});const entry=nativeEntries.get(session);if(!entry||!entry.ready)denied();assertLive(entry,options);const epoch=entry.epoch;return Object.freeze({owner:entry.owner,assertCurrent:()=>{if(nativeEntries.get(session)!==entry||entry.epoch!==epoch)denied();assertLive(entry,options);}});}
async function revoke(input){const owner=identity(input),ownerKey=key(owner);revoked.add(ownerKey);const entry=entries.get(ownerKey);if(!entry)return;invalidate(entry);try{await entry.promise;}catch{}await cleanup(entry);}
async function close(){closed=true;const all=[...entries.values()];for(const entry of all)invalidate(entry);await Promise.allSettled(all.map(async entry=>{try{await entry.promise;}catch{}await cleanup(entry);}));}
function enumerateEntries(){return Object.freeze([...entries.values()].map(entry=>Object.freeze({owner:entry.owner,session:entry.session,ready:entry.ready,active:entry.active,epoch:entry.epoch,disposed:entry.disposed})));}
function liveSessions(){return Object.freeze([...entries.values()].filter(entry=>entry.active&&entry.ready&&live(entry.owner,{phase:'owner',background:false})).map(entry=>entry.session));}
async function sweep(){const all=[...entries.values()];for(const entry of all)if(!live(entry.owner,{phase:'reconcile',background:false}))invalidate(entry);return Promise.allSettled(all.filter(entry=>!entry.active).map(async entry=>{try{await entry.promise;}catch{}await cleanup(entry);}));}
return {acquire,getOwner,guard,revoke,close,enumerateEntries,liveSessions,sweep};
}
module.exports={createAgentSessionOwnership};
+1
View File
@@ -17,6 +17,7 @@ function validateMutation(input,{policy,ready,settings,destinationAllowed,now=Da
if(input.action==='add'&&rules.personal.items.some(item=>item.id===id)||input.action!=='add'&&!rules.personal.items.some(item=>item.id===id))throw Error('Bookmark identity is unavailable');
if(input.action==='delete')return {action:input.action,id,expectedRevision:input.expectedRevision,policyVersion:input.policyVersion};
const item=input.bookmark;
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
if(Object.keys(item).some(key=>!['id','title','url','folder'].includes(key))||typeof item.title!=='string'||!item.title.trim()||item.title.length>120||/[\u0000-\u001f\u007f]/.test(item.title)||typeof item.folder!=='string'||item.folder.length>80||/[\u0000-\u001f\u007f]/.test(item.folder)||typeof item.url!=='string'||item.url.length>2048)throw Error('Invalid bookmark details');
let url;try{url=new URL(item.url);}catch{throw Error('Invalid bookmark URL');}
if(!['https:','http:'].includes(url.protocol)||url.username||url.password||url.search||url.hash||!destinationAllowed(url.href,policy))throw Error('Bookmark destination is not approved');
+29 -2
View File
@@ -7,14 +7,31 @@ function clipboardDOM(mode,url,token,operation,payload,limit){
const sensitive=e=>{for(let p=e;p&&p!==document.body;p=p.parentElement)if(p.hasAttribute?.('data-sensitive')||/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|master|credit.?card|cc-number|cc-csc/i.test(['name','id','autocomplete','aria-label'].map(a=>p.getAttribute(a)||'').join(' ')))return true;return /^(password|file|hidden)$/i.test(e?.type||'');};
const visible=e=>{if(!e||e.closest('[hidden],[aria-hidden="true"],[inert]'))return false;let p=e,count=0;while(p&&count++<64){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0')return false;p=p.parentElement;}return !p&&e.getClientRects().length>0;};
const rangeSafe=range=>{const ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;if(!ancestor?.isConnected||!visible(ancestor)||sensitive(ancestor)||ancestor.closest('input,textarea,[contenteditable],script,style,template,noscript')||ancestor.querySelector?.('input,textarea,[contenteditable]'))return false;const walker=document.createTreeWalker(ancestor,NodeFilter.SHOW_ELEMENT);let e,visited=0;while((e=walker.nextNode())){if(++visited>2048)return false;if(range.intersectsNode(e)&&(!visible(e)||sensitive(e)||/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(e.tagName)))return false;}return true;};
// Editor support remains plain text: neither clipboard HTML nor a page's paste
// handler participates in mutation. Bind the exact DOM, focus and selection.
const editorRangeSafe=(e,range)=>{
if(!e?.isConnected||!e.isContentEditable||!visible(e)||sensitive(e)||e.closest('input,textarea,iframe'))return false;
if(!e.contains(range.startContainer)||!e.contains(range.endContainer))return false;
const walker=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT);let node,count=0;
while((node=walker.nextNode())){if(++count>2048)return false;if(range.intersectsNode(node)&&(!visible(node)||sensitive(node)||node.getAttribute('contenteditable')==='false'||/^(INPUT|TEXTAREA|IFRAME|SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(node.tagName)))return false;}
return true;
};
const targetFacts=(e,rect)=>{const target={tag:/^[A-Z][A-Z0-9]{0,31}$/.test(e.tagName)?e.tagName.toLowerCase():'unknown'};const role=e.getAttribute?.('role');if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(role))target.role=role;if(rect){const x=rect.left+rect.width/2,y=rect.top+rect.height/2;if(Number.isFinite(x)&&Number.isFinite(y)&&x>=0&&y>=0&&x<=innerWidth&&y<=innerHeight){target.x=Math.round(x);target.y=Math.round(y);}}return target;};
if(mode==='stage'){
const snapshot={token,operation,field};let text='';
const snapshot={token,operation,field};let text;
if(field&&(field.tagName==='INPUT'||field.tagName==='TEXTAREA'||field.isContentEditable)){
if(field.isContentEditable){
const selection=getSelection(),range=selection?.rangeCount===1?selection.getRangeAt(0):null;
if(!range||!editorRangeSafe(field,range))throw Error('Clipboard editor selection unsupported');
if(field.innerHTML.length>limit||field.textContent.length>limit)throw Error('Clipboard target size limit');
snapshot.target=targetFacts(field,field.getBoundingClientRect?.());snapshot.kind='editor';snapshot.html=field.innerHTML;snapshot.range=range.cloneRange();
text=operation==='paste'?'':range.toString();
}else{
if(!editable(field)||sensitive(field)||!visible(field))throw Error('Clipboard target denied');
snapshot.target=targetFacts(field,field.getBoundingClientRect?.());snapshot.kind='field';snapshot.value=field.value;if(snapshot.value.length>limit)throw Error('Clipboard target size limit');snapshot.start=field.selectionStart;snapshot.end=field.selectionEnd;
if(!Number.isInteger(snapshot.start)||!Number.isInteger(snapshot.end))throw Error('Clipboard selection unsupported');
text=operation==='paste'?'':snapshot.value.slice(snapshot.start,snapshot.end);
}
}else{
if(operation!=='copy')throw Error('Clipboard edit target unsupported');
const selection=getSelection();if(!selection||selection.rangeCount!==1||selection.isCollapsed)throw Error('Clipboard selection unavailable');
@@ -33,10 +50,20 @@ function clipboardDOM(mode,url,token,operation,payload,limit){
const value=operation==='paste'?payload:'';if(typeof value!=='string'||value.length>limit)throw Error('Clipboard payload denied');
e.setRangeText(value,snapshot.start,snapshot.end,'end');e.dispatchEvent(new Event('input',{bubbles:true}));
}
}else if(snapshot.kind==='editor'){
const e=snapshot.field,selection=getSelection(),range=selection?.rangeCount===1?selection.getRangeAt(0):null,original=snapshot.range;
if(e!==document.activeElement||e.innerHTML!==snapshot.html||!range||range.startContainer!==original.startContainer||range.endContainer!==original.endContainer||range.startOffset!==original.startOffset||range.endOffset!==original.endOffset||!editorRangeSafe(e,range))throw Error('Clipboard editor target changed');
if(mode!=='validate'&&operation!=='copy'){
const value=operation==='paste'?payload:'';
if(typeof value!=='string'||value.length>limit||value.includes('\0'))throw Error('Clipboard payload denied');
range.deleteContents();
if(value){const node=document.createTextNode(value);range.insertNode(node);range.setStartAfter(node);}
range.collapse(true);selection.removeAllRanges();selection.addRange(range);
e.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:operation==='paste'?'insertText':'deleteByCut',data:value}));
}
}else{
const selection=getSelection(),range=selection?.rangeCount===1?selection.getRangeAt(0):null,original=snapshot.range;
if(!range||range.startContainer!==original.startContainer||range.endContainer!==original.endContainer||range.startOffset!==original.startOffset||range.endOffset!==original.endOffset||range.toString()!==snapshot.text)throw Error('Clipboard selection changed');
const ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;
if(!rangeSafe(range))throw Error('Clipboard selection changed');
}
return {completed:true};
+3
View File
@@ -18,11 +18,14 @@ function createCredentialSubmissionBroker({isCurrent,traceOperation=(_operation,
// 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();});
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
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};}
// eslint-disable-next-line no-control-regex, no-useless-assignment -- Explicitly reject control characters in security-sensitive input. Explicitly clear sensitive transient text after use.
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;}
// eslint-disable-next-line no-useless-assignment -- Explicitly clear sensitive transient text after use.
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);}
+4 -5
View File
@@ -4,16 +4,14 @@ const {AsyncLocalStorage}=require('node:async_hooks');
const {performance}=require('node:perf_hooks');
const {randomInt}=require('node:crypto');
const http=require('node:http'),https=require('node:https');
const {TracerProvider,BatchSpanProcessor,TraceIdRatioBasedSampler,ParentBasedSampler}=require('@opentelemetry/sdk-trace');
const {LoggerProvider,BatchLogRecordProcessor}=require('@opentelemetry/sdk-logs');
const {resourceFromAttributes}=require('@opentelemetry/resources');
const {OTLPTraceExporter}=require('@opentelemetry/exporter-trace-otlp-http');
const {OTLPLogExporter}=require('@opentelemetry/exporter-logs-otlp-http');
let telemetryModules;
function loadTelemetryModules(){return telemetryModules??={...require('@opentelemetry/sdk-trace'),...require('@opentelemetry/sdk-logs'),...require('@opentelemetry/resources'),...require('@opentelemetry/exporter-trace-otlp-http'),...require('@opentelemetry/exporter-logs-otlp-http')};}
const {trace,ROOT_CONTEXT,SpanStatusCode,TraceFlags}=require('@opentelemetry/api');
const names=new Set(['browser.window','browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.request.headers','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security','browser.evidence']);
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']);
for(const operation of ['visual.probe','visual.capture','visual.inspect.local','visual.inspect.enterprise','visual.audit','visual.validate','visual.project','visual.release'])operations.add(operation);
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 ['text.local','text.enterprise'])operations.add(operation);
for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation);
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);
@@ -58,6 +56,7 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
const agentFactory=async protocol=>{const Base=protocol==='https:'?https.Agent:http.Agent;class PrivateAgent extends Base{addRequest(request,options){if(!current.active){request.destroy(Error('Diagnostics disabled'));return;}for(const key of request.getHeaderNames())if(!['host','content-type','content-length','user-agent'].includes(key.toLowerCase()))request.removeHeader(key);super.addRequest(request,options);}}
const agent=new PrivateAgent({keepAlive:false,maxSockets:1,rejectUnauthorized:true});current.agents.add(agent);return agent;};
const base=config.endpoint.replace(/\/$/,'');const exporterConfig=path=>({url:base+path,headers:{},timeoutMillis:config.exportTimeoutMs,concurrencyLimit:1,compression:'none',httpAgentOptions:agentFactory});
const {TracerProvider,BatchSpanProcessor,TraceIdRatioBasedSampler,ParentBasedSampler,LoggerProvider,BatchLogRecordProcessor,resourceFromAttributes,OTLPTraceExporter,OTLPLogExporter}=loadTelemetryModules();
const resource=resourceFromAttributes({'service.name':'safetynet-browser','service.version':'0.1.0','safetynet.diagnostics.mode':config.mode});
const limits={maxQueueSize:config.maxQueueSize,maxExportBatchSize:Math.min(config.maxQueueSize,128),scheduledDelayMillis:config.flushIntervalMs,exportTimeoutMillis:config.exportTimeoutMs};
current.tracerProvider=new TracerProvider({resource,sampler:new ParentBasedSampler({root:new TraceIdRatioBasedSampler(config.mode==='debug'?config.debugSampleRate:config.sampleRate)}),spanLimits:{attributeCountLimit:20,attributeValueLengthLimit:64,eventCountLimit:0,linkCountLimit:0},spanProcessors:[new BatchSpanProcessor({...limits,exporter:gatedExporter(new OTLPTraceExporter(exporterConfig('/v1/traces')),current)})]});
+2
View File
@@ -15,7 +15,9 @@ function buildDownloadEvidence({url,name,mime,facts,decision,ruleId},policy){
if(!facts||!Number.isSafeInteger(facts.size)||facts.size<0||!/^[a-f0-9]{64}$/.test(facts.sha256)||!['allow','block'].includes(decision))return null;
let source;try{source=new URL(url);if(!['http:','https:'].includes(source.protocol))return null;source.username='';source.password='';source.search='';source.hash='';}catch{return null;}
if(source.href.length>4096)return null;
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
const result={sourceUrl:source.href,name:safeFilename(name),sha256:facts.sha256,bytes:facts.size,mime:String(mime||'').split(';',1)[0].trim().toLowerCase().replace(/[\x00-\x1f\x7f]/g,'').slice(0,100),decision};
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
if(typeof ruleId==='string'&&ruleId.length<=100&&!/[\x00-\x1f\x7f]/.test(ruleId))result.ruleId=ruleId;
return validateDownloadEvidence(result)?result:null;
}
+1
View File
@@ -4,6 +4,7 @@ function evidenceType(record){return record?.protectedEvidence?.type??'download'
function policyAllows(context,type='download'){return ['download','interaction'].includes(type)&&context?.policy?.evidence?.enabled===true&&context.policy.evidence[type==='download'?'captureDownloads':'captureInteractions']===true&&Date.parse(context.policy.expiresAt)>Date.now();}
function collectionAllowed(context){return !!context?.ready&&policyAllows(context,context?.type??'download');}
function validatedEvidence(payload,type='download'){if(!(type==='download'?validateDownloadEvidence(payload):type==='interaction'&&validateInteractionEvidence(payload)))throw Error('Invalid protected evidence');return structuredClone(payload);}
// eslint-disable-next-line no-unused-vars -- Destructure only to strip protected evidence before releasing ordinary event metadata.
function stripPendingEvidence(queue,context){return queue.map(record=>{if(!record.protectedEvidence)return record;if(context&&policyAllows(context,evidenceType(record)))return record;const {protectedEvidence,...event}=record;return event;});}
async function deliverAuditRecord({record,send,getContext}){
const {protectedEvidence,...event}=record;await send('/api/device/events',event);
+1
View File
@@ -12,6 +12,7 @@ function snapshotHeaders(input,policy){
if(!('value'in descriptor)||!/^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,256}$/.test(name)||typeof descriptor.value!=='string')throw Error('headers-uninspectable');
const value=descriptor.value;
if(value.length>limit)throw Error('headers-size-limit');
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
if(/[\x00-\x08\x0A-\x1F\x7F]|[\uD800-\uDFFF]/u.test(value))throw Error('headers-uninspectable');
bytes+=Buffer.byteLength(name)+Buffer.byteLength(value)+4;
if(bytes>limit)throw Error('headers-size-limit');
@@ -0,0 +1,31 @@
'use strict';
let variants;
// Inspect decoded text independently; concatenating it could dilute a semantic match.
async function inspectTextRepresentations({bytes,policy,inspectLocal,inspectEnterprise,isCurrent,traceOperation=(_name,_facts,fn)=>fn()}){
const block=ruleId=>({decision:'block',ruleId});
let values;
try{const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes);variants??=import('../../packages/core/text-variants.mjs');values=(await variants).inspectionTextVariants(text,policy.dlp.maxBytes);}
catch{return block('normalization-uninspectable');}
const current=()=>{try{return isCurrent()===true;}catch{return false;}};
if(policy.enterprise?.icap?.enabled&&policy.enterprise.icap.scanUploads!==false&&typeof inspectEnterprise!=='function')return block('icap-unavailable');
for(let index=0;index<values.length;index++){
if(!current())return block('inspection-context-changed');
const data=Buffer.from(values[index]);
try{
if(policy.dlp.semantic.enabled||policy.security.yara.enabled){
if(typeof inspectLocal!=='function')return block('inspector-unavailable');
const local=await traceOperation('text.local',{bytes:data.length,index,count:values.length},()=>inspectLocal(data,policy));
if(!current())return block('inspection-context-changed');
if(local?.decision!=='allow')return local?.decision==='block'?local:block('inspection-failed');
if(local.coverage?.complete!==true)return block('inspection-incomplete');
}
if(typeof inspectEnterprise==='function'){
const external=await traceOperation('text.enterprise',{bytes:data.length,index,count:values.length},()=>inspectEnterprise(data,policy));
if(!current())return block('inspection-context-changed');
if(external?.decision!=='allow'||external.coverage?.complete!==true)return external?.decision==='block'?external:block('inspection-incomplete');
}
}finally{data.fill(0);}
}
return current()?{decision:'allow',ruleId:null}:block('inspection-context-changed');
}
module.exports={inspectTextRepresentations};
+1
View File
@@ -46,6 +46,7 @@ function startInspector(root,packaged=false,options={}){
if(!['allow','block'].includes(result.decision)||typeof result.coverage?.complete!=='boolean')throw Error('Inspector response rejected');
if(result.decision==='allow'&&!result.coverage.complete)throw Error('Incomplete content cannot be released');
const stages=result._diagnosticStages;delete result._diagnosticStages;if(policy?.diagnostics?.enabled===true&&policy.diagnostics.mode==='debug'&&typeof options.onStages==='function'){try{options.onStages(stages);}catch{}}return result;
// eslint-disable-next-line preserve-caught-error -- Expose only a fixed sanitized error; the original cause may contain sensitive paths or data.
}catch(error){if(error.name==='AbortError'){child.kill();throw Error('Content inspection exceeded its time budget');}throw error;}
finally{clearTimeout(timer);}
};
+1
View File
@@ -36,6 +36,7 @@ function createLibraryOrganization({capture,assertCurrent,propose,commit,applyTa
await audit('library.ai.applied',`bookmarks-${stored.plan.bookmarks.length}:tabs-${stored.plan.tabs.length}`);
return {completed:true,bookmarkRevision:result.revision,tabCount:stored.plan.tabs.length};
}catch(error){
// eslint-disable-next-line preserve-caught-error -- Expose only a fixed sanitized error; the original cause may contain sensitive paths or data.
if(committed){proposals.delete(input.proposalId);await audit('library.ai.reconcile','central-bookmarks-committed');throw Error('Bookmarks were organized centrally; tabs were unchanged because the browser context changed. Refresh the library.');}
throw error;
}finally{busy=false;changed();}
+61
View File
@@ -0,0 +1,61 @@
'use strict';
const http=require('node:http'),net=require('node:net'),tls=require('node:tls');
const {randomBytes,timingSafeEqual}=require('node:crypto');
function endpoint(value){
const u=new URL(value);if(!['http:','https:'].includes(u.protocol)||u.username||u.password||u.hash)throw Error('relay-target-refused');
return {url:u.href,scheme:u.protocol,host:u.hostname.replace(/^\[|\]$/g,''),port:Number(u.port||(u.protocol==='https:'?443:80)),path:u.pathname+u.search};
}
function upstream(value){
// Honor the first selected enterprise route. Never downgrade failed or
// unsupported routes to DIRECT, even when a PAC result lists fallback entries.
const first=String(value).split(';')[0].trim();if(first==='DIRECT')return {direct:true};
const m=/^(PROXY|HTTPS) (\[[a-fA-F0-9:]+\]|[a-zA-Z0-9.-]+):([0-9]{1,5})$/.exec(first);
if(!m||Number(m[3])<1||Number(m[3])>65535)throw Error('relay-upstream-unsupported');
return {direct:false,tls:m[1]==='HTTPS',host:m[2].replace(/^\[|\]$/g,''),port:Number(m[3])};
}
async function startLocalDlpRelay({authorize,isCurrent,resolveUpstream,resolveAddress,credentialsForProxy=async()=>null,timeoutMs=30000,maxConnections=128,maxBodyBytes=10485760}={}){
if([authorize,isCurrent,resolveUpstream,resolveAddress,credentialsForProxy].some(fn=>typeof fn!=='function')||!Number.isInteger(timeoutMs)||timeoutMs<100||timeoutMs>60000||!Number.isInteger(maxConnections)||maxConnections<1||maxConnections>256)throw TypeError('Relay authority callbacks required');
const username='safetynet-'+randomBytes(16).toString('hex'),password=randomBytes(32).toString('base64url'),authorization='Basic '+Buffer.from(username+':'+password).toString('base64');
const sockets=new Set(),clients=new Set();let closed=false,epoch=1;
const current=context=>{try{return !closed&&context.epoch===epoch&&isCurrent(context.authority)===true;}catch{return false;}};
const bounded=work=>new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(Error('relay-operation-timeout')),timeoutMs);Promise.resolve(work).then(value=>{clearTimeout(timer);resolve(value);},error=>{clearTimeout(timer);reject(error);});});
function check(context){if(!current(context))throw Error('relay-authority-changed');}
function track(socket){sockets.add(socket);socket.on('error',()=>{});socket.once('close',()=>sockets.delete(socket));socket.setTimeout(timeoutMs,()=>socket.destroy());return socket;}
function authenticated(req){const supplied=req.headers['proxy-authorization'];return req.socket.remoteAddress==='127.0.0.1'&&typeof supplied==='string'&&supplied.length===authorization.length&&timingSafeEqual(Buffer.from(supplied),Buffer.from(authorization));}
function headers(req){const h={...req.headers},nominated=String(req.headers.connection||'').split(',').map(value=>value.trim().toLowerCase());for(const key of [...nominated,'proxy-authorization','proxy-authenticate','proxy-connection','connection','keep-alive','upgrade','te','trailer','transfer-encoding'])delete h[key];return h;}
async function route(target,method){const generation=epoch,authority=await bounded(authorize(target.url,{method}));if(!authority)throw Error('relay-authority-refused');const context={authority,epoch:generation};check(context);const selected=upstream(await bounded(resolveUpstream(target.url)));check(context);return {context,selected};}
async function address(host,context){const ip=await bounded(resolveAddress(host,context.authority));check(context);if(typeof ip!=='string'||!net.isIP(ip))throw Error('relay-address-unapproved');return ip;}
async function dial(host,port,secure,context){const ip=await address(host,context);return new Promise((resolve,reject)=>{let socket;try{check(context);socket=track(secure?tls.connect({host:ip,port,servername:host,rejectUnauthorized:true}):net.connect({host:ip,port}));socket.once(secure?'secureConnect':'connect',()=>{try{check(context);resolve(socket);}catch(e){socket.destroy();reject(e);}});socket.once('error',reject);socket.once('close',()=>reject(Error('relay-socket-closed')));}catch(e){reject(e);}});}
async function proxyAuth(selected,context){const credentials=await bounded(credentialsForProxy(selected));check(context);if(!credentials)return null;if(typeof credentials.username!=='string'||typeof credentials.password!=='string'||credentials.username.length>1024||credentials.password.length>1024||credentials.username.includes(':')||/[\r\n]/.test(credentials.username+credentials.password))throw Error('relay-proxy-credentials-invalid');return 'Basic '+Buffer.from(credentials.username+':'+credentials.password).toString('base64');}
async function tunnel(target,selected,context){
const socket=await dial(selected.direct?target.host:selected.host,selected.direct?target.port:selected.port,!selected.direct&&selected.tls,context);
if(selected.direct)return socket;
const auth=await proxyAuth(selected,context),authority=(net.isIP(target.host)===6?'['+target.host+']':target.host)+':'+target.port;
check(context);socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${auth?'Proxy-Authorization: '+auth+'\r\n':''}\r\n`);
return new Promise((resolve,reject)=>{let bytes=Buffer.alloc(0);const data=chunk=>{bytes=Buffer.concat([bytes,chunk]);if(bytes.length>32768){socket.destroy();reject(Error('relay-proxy-response-limit'));return;}const end=bytes.indexOf('\r\n\r\n');if(end<0)return;socket.removeListener('data',data);try{check(context);if(!/^HTTP\/1\.[01] 200(?: |\r)/.test(bytes.subarray(0,end).toString('latin1')))throw Error('relay-proxy-connect-refused');const tail=bytes.subarray(end+4);if(tail.length)socket.unshift(tail);resolve(socket);}catch(e){socket.destroy();reject(e);}};socket.on('data',data);socket.once('error',reject);socket.once('close',()=>reject(Error('relay-proxy-connect-closed')));socket.once('end',()=>reject(Error('relay-proxy-connect-ended')));});
}
const server=http.createServer(async(req,res)=>{
if(!authenticated(req)){res.writeHead(407,{'Proxy-Authenticate':'Basic realm="S Browser private relay"'});res.end();req.resume();return;}
let remote;try{
const target=endpoint(req.url);if(target.scheme!=='http:')throw Error('relay-plain-scheme-refused');const {context,selected}=await route(target,req.method);
remote=await dial(selected.direct?target.host:selected.host,selected.direct?target.port:selected.port,!selected.direct&&selected.tls,context);check(context);
const outgoingHeaders=headers(req),auth=selected.direct?null:await proxyAuth(selected,context);outgoingHeaders.host=new URL(target.url).host;if(auth)outgoingHeaders['proxy-authorization']=auth;
const agent=new http.Agent({keepAlive:false});agent.createConnection=()=>remote;
const outgoing=http.request({method:req.method,path:selected.direct?target.path:target.url,headers:outgoingHeaders,agent},incoming=>{try{check(context);if(!selected.direct&&incoming.statusCode===407){incoming.destroy();agent.destroy();res.writeHead(502);res.end();return;}res.writeHead(incoming.statusCode,headers(incoming));incoming.on('data',chunk=>{if(!current(context)){incoming.destroy();res.destroy();return;}if(!res.write(chunk))incoming.pause();});res.on('drain',()=>incoming.resume());incoming.on('end',()=>{res.end();agent.destroy();});incoming.on('error',()=>res.destroy());}catch{incoming.destroy();res.destroy();}});
outgoing.on('error',()=>{remote.destroy();if(!res.headersSent)res.writeHead(502);res.end();});let size=0;
req.on('data',chunk=>{size+=chunk.length;if(size>maxBodyBytes||!current(context)){outgoing.destroy();req.destroy();return;}if(!outgoing.write(chunk))req.pause();});outgoing.on('drain',()=>req.resume());req.on('end',()=>outgoing.end());req.on('error',()=>outgoing.destroy());
}catch{remote?.destroy();if(!res.headersSent)res.writeHead(502);res.end();req.resume();}
});
server.on('connect',async(req,client,head)=>{
if(!authenticated(req)){client.end('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="S Browser private relay"\r\nConnection: close\r\n\r\n');return;}
let remote;try{if(!/^(?:\[[a-fA-F0-9:]+\]|[a-zA-Z0-9.-]+):[0-9]{1,5}$/.test(req.url))throw Error('relay-connect-target-refused');const target=endpoint('https://'+req.url),{context,selected}=await route(target,'CONNECT');remote=await tunnel(target,selected,context);check(context);if(client.destroyed){remote.destroy();return;}client.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)remote.write(head);
client.on('data',chunk=>{if(!current(context)){client.destroy();remote.destroy();return;}if(!remote.write(chunk))client.pause();});remote.on('drain',()=>client.resume());remote.on('data',chunk=>{if(!current(context)){client.destroy();remote.destroy();return;}if(!client.write(chunk))remote.pause();});client.on('drain',()=>remote.resume());client.once('end',()=>remote.end());remote.once('end',()=>client.end());client.once('close',()=>remote.destroy());remote.once('close',()=>client.destroy());
}catch{remote?.destroy();client.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');}
});
server.on('connection',socket=>{if(closed||socket.remoteAddress!=='127.0.0.1'||clients.size>=maxConnections){socket.destroy();return;}clients.add(socket);track(socket);socket.once('close',()=>clients.delete(socket));});server.on('clientError',(_error,socket)=>socket.destroy());
server.headersTimeout=timeoutMs;server.requestTimeout=timeoutMs;server.keepAliveTimeout=1000;
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
return {proxyRules:'127.0.0.1:'+server.address().port,proxyBypassRules:'<-loopback>',credentials:()=>({username,password}),invalidate(){epoch++;for(const socket of sockets)socket.destroy();},async close(){closed=true;epoch++;for(const socket of sockets)socket.destroy();await new Promise(resolve=>server.close(resolve));},status:()=>({closed,connections:[...clients].filter(socket=>!socket.destroyed).length})};
}
module.exports={startLocalDlpRelay};
+70 -17
View File
@@ -1,7 +1,10 @@
const { app, BrowserWindow, WebContentsView, ipcMain, session, safeStorage, shell, dialog, powerMonitor, clipboard, Menu } = require('electron');
const {assertSecureStartup}=require('./startup-security.cjs');
const {assertPolicyAssignment}=require('./policy-assignment.cjs');
assertSecureStartup(app.commandLine);
try{assertSecureStartup(app.commandLine);}catch{
console.error('Managed browser startup denied');
process.exit(1);
}
// Force Chromium sandboxing globally before any renderer is created. The subframe
// preload option below never grants a Node engine: native proof tests verify this.
app.enableSandbox();
@@ -20,7 +23,7 @@ function diagnosticSpan(name,attributes,operation){
let diagnosticsConfiguration='';
function configureDiagnostics(config){const value=config||{enabled:false},context=diagnosticContext(),key=JSON.stringify([value,context]);if(key===diagnosticsConfiguration)return;try{diagnostics.configure(value,context);diagnosticsConfiguration=key;}catch{diagnosticsConfiguration='';try{diagnostics.configure({enabled:false});}catch{}}}
function diagnosticCorrelation(){try{const ids=diagnostics.correlation?.();return /^[a-f0-9]{32}$/.test(ids?.traceId||'')&&/[1-9a-f]/.test(ids.traceId)&&/^[a-f0-9]{16}$/.test(ids?.spanId||'')&&/[1-9a-f]/.test(ids.spanId)?{traceId:ids.traceId,spanId:ids.spanId}:{};}catch{return {};}}
let win,view,browserSession,managementSession,core,device,policy,ready=false,syncing=false,aiPanel=false,vaultPanel=false,vault,vaultTimer,fileUploadGate,uploadLeaseFatal=false;
let win,view,browserSession,managementSession,localRelay,core,device,policy,ready=false,syncing=false,aiPanel=false,vaultPanel=false,vault,vaultTimer,fileUploadGate,uploadLeaseFatal=false;
let chromeHeight=104,leftSidebar=false,startPage=false,overviewPanel=false,toolsOpen=false,pageTitle='',pageColor='#edf1f6';
const {createTabRegistry}=require('./tab-registry.cjs');
let tabs,tabsRevision=0;
@@ -39,7 +42,7 @@ const {startAgentControl}=require('./agent-control.cjs');
const {createAgentAdapters}=require('./agent-adapters.cjs');
const {createVault}=require('./vault.cjs');
const {createDeveloperToolsGuard,developerAssetRequestAllowed}=require('./developer-tools.cjs');
const {settingsState,sectionVisible,sectionAllowed,featureAllowed,requireFeature,requireSection,validateAppearance}=require('./managed-settings.cjs');
const {settingsState,sectionAllowed,featureAllowed,requireFeature,requireSection,validateAppearance}=require('./managed-settings.cjs');
const {bookmarkState,validateMutation:validateBookmarkMutation,libraryVisible}=require('./bookmarks.cjs');
let bookmarkBusy=false,personalBookmarks={revision:0,items:[]},bookmarkSnapshotJSON='',libraryOrganizer;
function effectiveBookmarkPolicy(){return policy?{...policy,browser:{...policy.browser,bookmarks:{...policy.browser.bookmarks,personal:personalBookmarks}}}:null;}
@@ -180,7 +183,7 @@ async function pageTheme(tab=activeTab()){
}catch{}
}
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 quarantine(reason){ready=false;localRelay?.invalidate();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,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(){
@@ -240,8 +243,9 @@ async function applyPolicyImpl(envelope){
configureDiagnostics({enabled:false});quarantine('Applying company policy…');
const proxy=next.proxy;
const settings={mode:proxy.mode,...(proxy.mode==='fixed_servers'?{proxyRules:proxy.rules,proxyBypassRules:proxy.bypass}:{}),...(proxy.mode==='pac_script'?{pacScript:proxy.pacUrl}:{})};
await browserSession.closeAllConnections();await browserSession.setProxy(settings);
localRelay.invalidate();await browserSession.closeAllConnections();
await managementSession.closeAllConnections();await managementSession.setProxy(settings);
await browserSession.setProxy({mode:'fixed_servers',proxyRules:localRelay.proxyRules,proxyBypassRules:localRelay.proxyBypassRules});
app.configureHostResolver(resolverOptions(next.enterprise?.dns));
await browserSession.clearHostResolverCache();recentRequests.clear();
await managementSession.clearHostResolverCache();
@@ -375,6 +379,7 @@ function requireLibraryAI(){
if(!ready||!policy?.ai.enabled||!policy.library?.aiSuggestionsEnabled||Date.parse(policy.expiresAt)<=Date.now())throw Error('AI library organization is disabled by company policy');
if(!policy.browser.bookmarks.allowUserEdit)throw Error('Bookmark organization is controlled by your administrator');
}
// eslint-disable-next-line no-control-regex -- Strip control characters from security-sensitive metadata.
function nativeLibraryRows(){return tabs.all().filter(tab=>!tab.startPage&&/^https?:\/\//.test(tab.view.webContents.getURL())).map(tab=>({id:tab.id,title:String(tab.title).replace(/[\u0000-\u001f\u007f]/g,' ').slice(0,120),url:tab.view.webContents.getURL()}));}
function nativeLibraryEpoch(){return JSON.stringify(tabs.all().map(tab=>[tab.id,tab.navigationId,tab.title,tab.view.webContents.getURL(),tab.group]));}
function assertAgentLibraryDestinations(snapshot){if(agentActorStorage.getStore()?.principalId&&[...(snapshot.tabs||[]),...(snapshot.bookmarks||[])].some(row=>!actorDestinationAllowed(row.url)))throw Error('Agent destination denied');}
@@ -462,9 +467,46 @@ async function dnsGateImpl(url,currentPolicy){
const addresses=resolved.endpoints?.map(v=>v.address)||[];
if(!addresses.length||addresses.some(ip=>securityCore.dnsAddressBlocked(ip,currentPolicy)))throw Error('dns-ip-blocklist');
}
function inspectOutgoingText(bytes,currentPolicy,isCurrent){
return require('./inspect-text-representations.cjs').inspectTextRepresentations({bytes,policy:currentPolicy,isCurrent,
inspectLocal:localInspector?(data,p)=>localInspector.inspect(data,p):undefined,
inspectEnterprise:(data,p)=>scanEnterprise(data,p,'upload'),
traceOperation:(operation,facts,fn)=>diagnosticSpan(operation==='text.local'?'browser.inspect.local':'browser.inspect.enterprise',{operation,bytes:facts.bytes,count:facts.count},fn)});
}
function relayAuthorityCurrent(authority){return ready&&policy===authority.policy&&device?.id===authority.deviceId&&Date.parse(policy.expiresAt)>Date.now();}
async function initializeLocalRelay(){
localRelay=await require('./local-dlp-relay.cjs').startLocalDlpRelay({
authorize:url=>{const authority={policy,deviceId:device?.id};if(!relayAuthorityCurrent(authority)||!core.destinationAllowed(url,policy)||core.inspect(Buffer.from(url),policy).decision!=='allow')throw Error('relay-target-refused');return authority;},
isCurrent:relayAuthorityCurrent,
resolveUpstream:url=>managementSession.resolveProxy(url),
resolveAddress:async(host,authority)=>{const resolved=await managementSession.resolveHost(host,{cacheUsage:'allowed'}),addresses=resolved.endpoints?.map(endpoint=>endpoint.address)||[];if(!relayAuthorityCurrent(authority)||!addresses.length||addresses.some(address=>securityCore.dnsAddressBlocked(address,authority.policy)))throw Error('relay-address-refused');return addresses[0];},
credentialsForProxy:async selected=>require('./proxy-credentials.cjs').deploymentProxyCredentials(selected)
});
// Every managed URL resolves to a non-DIRECT native proxy, including loopback.
// Enterprise DIRECT/PAC/system choices apply only behind this private relay.
await browserSession.setProxy({mode:'fixed_servers',proxyRules:localRelay.proxyRules,proxyBypassRules:localRelay.proxyBypassRules});
}
function responsePolicyHeaders(input){
if(!input||typeof input!=='object'||Array.isArray(input))throw Error('Response policy headers unavailable');
const headers={...input},keys=Object.keys(headers).filter(key=>key.toLowerCase()==='connection-allowlist');
const values=keys.flatMap(key=>Array.isArray(headers[key])?headers[key]:[headers[key]]);
if(values.some(value=>typeof value!=='string'))throw Error('Response policy headers unavailable');
let existing=values.join(',');if(existing.length>65536)throw Error('Response policy headers unavailable');
for(const key of keys)delete headers[key];
if(existing){let quoted=false,escaped=false,depth=0,split=existing.length;
for(let index=0;index<existing.length;index++){const ch=existing[index];if(escaped){escaped=false;continue;}if(quoted&&ch==='\\'){escaped=true;continue;}if(ch==='"'){quoted=!quoted;continue;}if(quoted)continue;if(ch==='(')depth++;if(ch===')'&&--depth<0)throw Error('Response policy headers unavailable');if(ch===','&&depth===0){split=index;break;}}
if(quoted||depth!==0)throw Error('Response policy headers unavailable');
existing=existing.slice(0,split).trim()+'; webrtc=block'+existing.slice(split);
}else existing='("http://*:*/*" "https://*:*/*"); redirects=allow; webrtc=block';
headers['Connection-Allowlist']=[existing];return headers;
}
function hooks(){
browserSession.setPermissionRequestHandler((_wc,_permission,callback)=>callback(false));
browserSession.setPermissionCheckHandler(()=>false);
// Native Chromium policy blocks uninspected WebRTC; HTTP(S) retains normal DLP.
browserSession.webRequest.onHeadersReceived({urls:['http://*/*','https://*/*']},(details,callback)=>{
try{callback({cancel:false,responseHeaders:responsePolicyHeaders(details.responseHeaders)});}catch{callback({cancel:true});void audit('dlp.block','response-policy-uninspectable');}
});
const leaseRoot=app.isPackaged?process.resourcesPath:path.join(__dirname,'../..');
fileUploadGate=require('./file-upload-gate.cjs').createFileUploadGate({
maxBytes:context=>context.policy.dlp.maxBytes,
@@ -527,12 +569,18 @@ function hooks(){
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;}
if(details.resourceType==='webSocket'&&!policy.browser.allowWebSockets){audit('dlp.block','websocket-disabled',new URL(details.url).hostname);finish(true);return;}
const address=new URL(details.url),query=address.pathname+address.search;
const queryVerdict=core.inspect(Buffer.from(query),policy);
if(queryVerdict.decision==='block'){audit('dlp.block',queryVerdict.ruleId,new URL(details.url).hostname);finish(true);return;}
if(details.resourceType==='webSocket'){audit('dlp.block','websocket-disabled',new URL(details.url).hostname);finish(true);return;}
const address=new URL(details.url),urlBytes=Buffer.from(address.href);
try{
const urlVerdict=core.inspect(urlBytes,initialPolicy);
if(urlVerdict.decision!=='allow'){await audit('dlp.block',urlVerdict.ruleId,address.hostname);finish(true);return;}
const inspected=await inspectOutgoingText(urlBytes,initialPolicy,()=>!called&&ready&&policy===initialPolicy&&Date.parse(initialPolicy.expiresAt)>Date.now()&&sourceCurrent());
if(inspected.decision!=='allow'){await audit('dlp.block',inspected.ruleId||'inspection-incomplete',address.hostname);finish(true);return;}
}finally{urlBytes.fill(0);}
if(called||!ready||policy!==initialPolicy||!sourceCurrent()){finish(true);return;}
try{await dnsGate(details.url,initialPolicy);}catch(error){audit('dns.block',error.message,address.hostname);finish(true);return;}
if(called||!ready||policy!==initialPolicy||!sourceCurrent()){finish(true);return;}
const uploads=details.uploadData||[];
if(policy.security.visualPhishing.enabled&&(uploads.length||(address.search&&details.resourceType==='xhr')||details.resourceType==='webSocket')){
const checked=source?await checkVisual(source):{decision:'unknown'};if(checked.decision!=='allow'||!ready||policy!==initialPolicy||!sourceCurrent()){finish(true);return;}
@@ -565,7 +613,7 @@ function hooks(){
const needsLocal=verdict.ruleId==='uninspectable'||policy.dlp.semantic.enabled||policy.security.yara.enabled;
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'));
try{let result;if(claim||transfer.detectedType==='text'){result=await inspectOutgoingText(snapshot,currentPolicy,()=>!called&&ready&&policy===currentPolicy&&Date.parse(currentPolicy.expiresAt)>Date.now()&&sourceCurrent());}else{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;
@@ -748,6 +796,7 @@ app.whenReady().then(async()=>{
core=await import('../../packages/core/policy.mjs');
clipboardCore=await import('../../packages/core/clipboard.mjs');
ipcMain.on('managed:clipboard-sandbox-failure',event=>{if(tabs?.fromContents(event.sender.id)||event.sender===win?.webContents){clipboardFatal=true;quarantine('Managed renderer security failed. Restart this browser.');}});
ipcMain.on('managed:transport-guard-failure',event=>{if(tabs?.fromContents(event.sender.id)){clipboardFatal=true;quarantine('Managed renderer security failed. Restart this browser.');void diagnosticSpan('browser.security',{decision:'block',reason:'transport-guard-unavailable'},()=>audit('navigation.block','transport-guard-unavailable')).catch(()=>{});}});
powerMonitor.on('lock-screen',lockVault);powerMonitor.on('suspend',lockVault);
securityCore=await import('../../packages/core/security.mjs');
localInspector=startInspector(app.isPackaged?process.resourcesPath:path.join(__dirname,'../..'),app.isPackaged,{onStages:stages=>diagnostics.importStages(stages)});
@@ -755,6 +804,8 @@ app.whenReady().then(async()=>{
// Management has its own cookie jar and no website request gate. It uses the
// same company proxy and Chromium DNS configuration as managed website traffic.
managementSession=session.fromPartition('persist:safetynet-management',{cache:false});
await initializeLocalRelay();
browserSession.registerPreloadScript({type:'frame',id:'managed-transport-deny',filePath:path.join(__dirname,'transport-deny.cjs')});
browserSession.registerPreloadScript({type:'frame',id:'managed-clipboard-deny',filePath:path.join(__dirname,'clipboard-deny.cjs')});
session.defaultSession.registerPreloadScript({type:'frame',id:'shell-clipboard-deny',filePath:path.join(__dirname,'clipboard-deny.cjs')});
Menu.setApplicationMenu(null);
@@ -767,7 +818,7 @@ app.whenReady().then(async()=>{
stage:async args=>{const tab=tabs.fromContents(args.webContents.id);if(tab&&args.operation==='paste'&&policy?.security.visualPhishing.enabled&&(await checkVisual(tab)).decision!=='allow')throw Error('Login protection holds paste');return executeClipboard('stage',args);},
validate:args=>executeClipboard('validate',args),commit:args=>executeClipboard('commit',args),
discard:args=>executeClipboard('discard',{...args,url:'',operation:'copy',limit:0}),
readClipboard:async limit=>{const items=await clipboard.read();if(items.length!==1)throw Error('Clipboard format unsupported');const item=items[0];if(!item.types.includes('text/plain')||item.types.some(type=>type!=='text/plain'&&!/^electron application\/osclipboard;format="(?:CF_TEXT|CF_UNICODETEXT|CF_LOCALE)"$/.test(type)))throw Error('Rich clipboard denied');const blob=await item.getType('text/plain');if(blob.size>limit)throw Error('Clipboard size limit');return blob.text();},
readClipboard:async limit=>{const items=await clipboard.read();if(items.length!==1)throw Error('Clipboard format unsupported');const item=items[0];if(!item.types.includes('text/plain')||item.types.some(type=>type!=='text/plain'&&type!=='text/html'&&!/^electron application\/osclipboard;format="(?:CF_TEXT|CF_UNICODETEXT|CF_LOCALE|HTML Format)"$/.test(type)))throw Error('Rich clipboard denied');const blob=await item.getType('text/plain');if(blob.size>limit)throw Error('Clipboard size limit');return blob.text();},
writeClipboard:text=>clipboard.writeText(text),
inspect:async(bytes,currentPolicy,operation)=>{let verdict=clipboardCore.inspectClipboard(bytes,currentPolicy,operation);if(verdict.decision!=='allow')return verdict;if(currentPolicy.dlp.semantic.enabled||currentPolicy.security.yara.enabled){if(!localInspector)return {decision:'block',ruleId:'clipboard-inspector-unavailable'};verdict=await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,currentPolicy));}if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,currentPolicy,'clipboard'));return external.decision==='allow'?verdict:external;},
onCompleted:async metadata=>{
@@ -921,13 +972,15 @@ app.whenReady().then(async()=>{
let wasLocked=true;vaultTimer=setInterval(()=>{const locked=vault?.status().locked??true;if(locked!==wasLocked){if(locked)audit('vault.locked');wasLocked=locked;emit();}},1000);
}).catch(error=>{console.error(error.stack);app.quit();});
app.on('window-all-closed',()=>app.quit());
app.on('before-quit',()=>{fileUploadGate?.dispose();tabs?.close();});
app.on('before-quit',()=>{void localRelay?.close().catch(()=>{});fileUploadGate?.dispose();tabs?.close();});
app.on('will-quit',()=>{Promise.resolve(diagnostics.shutdown()).catch(()=>{});vault?.lock();clearInterval(vaultTimer);clearInterval(networkTimer);localInspector?.stop();agentService?.close();});
app.on('certificate-error',(event,_wc,_url,_error,_certificate,callback)=>{event.preventDefault();callback(false);});
app.on('login',(event,_wc,_details,authInfo,callback)=>{
app.on('login',(event,wc,_details,authInfo,callback)=>{
event.preventDefault();
if(localRelay&&authInfo.isProxy&&authInfo.host==='127.0.0.1'&&authInfo.port===Number(localRelay.proxyRules.split(':').at(-1))){
if(ready&&policy&&Date.parse(policy.expiresAt)>Date.now()&&(!wc||wc.session===browserSession)){const credentials=localRelay.credentials();callback(credentials.username,credentials.password);}else callback();return;
}
// Proxy credentials are supplied by deployment environment, never a page or policy bundle.
if(authInfo.isProxy&&process.env.SAFETYNET_PROXY_USER&&process.env.SAFETYNET_PROXY_PASSWORD)callback(process.env.SAFETYNET_PROXY_USER,process.env.SAFETYNET_PROXY_PASSWORD);
else callback();
const credentials=authInfo.isProxy?require('./proxy-credentials.cjs').deploymentProxyCredentials(authInfo):null;
if(credentials)callback(credentials.username,credentials.password);else callback();
});
+8
View File
@@ -0,0 +1,8 @@
'use strict';
function deploymentProxyCredentials(selected,environment=process.env){
const {SAFETYNET_PROXY_USER:user,SAFETYNET_PROXY_PASSWORD:password,SAFETYNET_PROXY_AUTH_HOST:host,SAFETYNET_PROXY_AUTH_PORT:port}=environment;
if(!user||!password||!host||!port||!/^[0-9]{1,5}$/.test(port)||Number(port)<1||Number(port)>65535)return null;
if(typeof selected?.host!=='string'||selected.host.toLowerCase()!==host.toLowerCase()||Number(selected.port)!==Number(port))return null;
return {username:user,password};
}
module.exports={deploymentProxyCredentials};
+1
View File
@@ -3,6 +3,7 @@ const fs=require('node:fs'),path=require('node:path'),{randomUUID}=require('node
const MAX_FILE=1048576, UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const fail=()=>{throw Error('Managed session collection unavailable');};
const secret=s=>/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,})\b|\b(?:password|passwd|api[_ -]?key|access[_ -]?token|client[_ -]?secret)\s*[:=]\s*["']?[^\s"']{4,}/i.test(s);
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
function safeText(value,max){if(typeof value!=='string'||value.length>max||/[\u0000-\u001f\u007f]/.test(value))fail();let decoded=value;for(let i=0;i<5;i++){if(secret(decoded))fail();try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}if(secret(decoded))fail();return value;}
// Preserve benign query/fragment semantics; identifiable authentication material
// and authentication routes are never reusable collection destinations.
+12 -2
View File
@@ -2,18 +2,28 @@
// Check only switch names. Arguments, paths, credentials and agent payloads never enter errors.
const BLOCKED_SWITCHES=Object.freeze([
'no-sandbox','disable-gpu-sandbox','disable-web-security','allow-running-insecure-content',
'ignore-certificate-errors','ignore-certificate-errors-spki-list','allow-insecure-localhost',
'reduce-security-for-testing','unsafely-treat-insecure-origin-as-secure',
'remote-debugging-port','remote-debugging-pipe','remote-debugging-socket-name',
'load-extension','disable-extensions-except','disable-site-isolation-trials','disable-site-isolation-for-policy',
'single-process','in-process-gpu','renderer-cmd-prefix','utility-cmd-prefix','gpu-launcher',
'inspect','inspect-brk','inspect-wait','inspect-port','debug','debug-brk','debug-port'
]);
const failure=()=>{throw new Error('Unsafe browser startup configuration');};
function assertSecureStartup(commandLine,nodeExecArgv=process.execArgv){
function assertSecureStartup(commandLine,nodeExecArgv=process.execArgv,applicationArgv=process.argv){
try{
if(!commandLine||typeof commandLine.hasSwitch!=='function'||!Array.isArray(nodeExecArgv))return failure();
if(!commandLine||typeof commandLine.hasSwitch!=='function'||!Array.isArray(nodeExecArgv)||!Array.isArray(applicationArgv))return failure();
for(const name of BLOCKED_SWITCHES){const present=commandLine.hasSwitch(name);if(typeof present!=='boolean'||present)return failure();}
// Electron/Node can consume inspector flags before they reach Chromium's CommandLine.
for(const argument of nodeExecArgv){if(typeof argument!=='string'||/^--(?:inspect(?:-brk|-wait|-port)?|debug(?:-brk|-port)?)(?:=|$)/.test(argument))return failure();}
// User feature overrides can disable native enforcement. Inspect original argv,
// not Electron's internally populated disable-features switch.
for(const argument of applicationArgv){
if(typeof argument!=='string')return failure();
// Chromium trims Unicode whitespace before recognizing Windows switches.
const normalized=argument.replace(/^[\s\p{White_Space}]+|[\s\p{White_Space}]+$/gu,'');
if(/^(?:--?|\/)(?:disable-features|enable-features|disable-blink-features|enable-blink-features|enable-experimental-web-platform-features)(?:=|$)/i.test(normalized))return failure();
}
// NODE_OPTIONS can activate Node's inspector without appearing in process.execArgv.
if(require('node:inspector').url())return failure();
return true;
+14
View File
@@ -0,0 +1,14 @@
'use strict';
// Frame-only defense against unsupported native RTC paths. This exports no bridge
// and is additional to the browser-process connection policy, not message DLP.
function denyUninspectedRTC(){
const SecurityException=DOMException;
function blockedPeerConnection(){throw new SecurityException('Managed WebRTC inspection is unavailable','NotAllowedError');}
Object.freeze(blockedPeerConnection.prototype);Object.freeze(blockedPeerConnection);
for(const name of ['RTCPeerConnection','webkitRTCPeerConnection','mozRTCPeerConnection']){
if(name in globalThis)Object.defineProperty(globalThis,name,{value:blockedPeerConnection,writable:false,configurable:false,enumerable:true});
}
}
if(process.sandboxed!==true||process.contextIsolated!==true){require('electron').ipcRenderer.send('managed:transport-guard-failure');window.stop();throw Error('Managed transport isolation is required');}
try{require('electron').contextBridge.executeInMainWorld({func:denyUninspectedRTC});}
catch{require('electron').ipcRenderer.send('managed:transport-guard-failure');window.stop();throw Error('Managed transport guard could not be installed');}
+1
View File
@@ -12,6 +12,7 @@ function trustedFile(p){
}
function refused(){return Object.assign(new Error('Upload lease unavailable'),{code:'upload-lease-refused'});}
async function acquireUploadLease({paths,maxBytes=LIMIT,pythonPath,helperPath,onLost=()=>{},signal}){
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
if(process.platform!=='win32'||!Array.isArray(paths)||paths.length<1||paths.length>16||!Number.isInteger(maxBytes)||maxBytes<1||maxBytes>LIMIT||typeof onLost!=='function'||![pythonPath,helperPath].every(p=>typeof p==='string'&&path.win32.isAbsolute(p)&&!p.startsWith('\\\\'))||!paths.every(p=>typeof p==='string'&&/^[A-Za-z]:\\[^\x00-\x1f:]*$/.test(p)&&p.length<=240&&path.win32.normalize(p)===p))throw refused();
if(disabled||signal?.aborted||!trustedFile(pythonPath)||!trustedFile(helperPath))throw refused();
const child=spawn(pythonPath,['-I','-B',helperPath],{windowsHide:true,stdio:['pipe','pipe','pipe'],env:{SystemRoot:process.env.SystemRoot,WINDIR:process.env.WINDIR},shell:false});
+1 -1
View File
@@ -24,7 +24,7 @@ function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisua
if(next.policy!==c.policy||next.actor.agentId!==c.actor.agentId||next.actor.generation!==c.actor.generation||!contextCurrent(c.token)||next.sessionId!==c.sessionId||next.webContents!==c.webContents)refused();
return next;
}
function unlocked(c){const vault=getVault();if(!vault||typeof vault.accessGeneration!=='function'||vault.status().locked)refused();return vault;}
function unlocked(_c){const vault=getVault();if(!vault||typeof vault.accessGeneration!=='function'||vault.status().locked)refused();return vault;}
function currentVault(c,args,signal,vault){check(c,args,signal);if(getVault()!==vault||vault.status().locked)refused();}
const status=async(args={}, {signal}={})=>{
const c=snapshot(args,signal,'vault:status'),s=getVault().status();check(c,args,signal);