2026-10-13 21:00:41 +02:00
'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 ();});
2026-10-13 23:19:22 +02:00
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
2026-10-13 21:00:41 +02:00
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 };}
2026-10-13 23:19:22 +02:00
// 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.
2026-10-13 21:00:41 +02:00
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 ;}
2026-10-13 23:19:22 +02:00
// eslint-disable-next-line no-useless-assignment -- Explicitly clear sensitive transient text after use.
2026-10-13 21:00:41 +02:00
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 };