2026-10-13 10:33:31 +02:00
'use strict' ;
const { safeFilename : filename , inspectFileTransfer } = require ( '../../packages/core/file-transfer.cjs' );
const { createHash } = require ( 'node:crypto' );
2026-10-13 16:58:15 +02:00
const { buildDownloadEvidence } = require ( './evidence-capture.cjs' );
2026-10-13 10:33:31 +02:00
function createDownloadGate ({ getContext , isCurrent , fetchResponse , scan , choosePath , writeFile , audit , status }){
let busy = false ;
return async function download ({ url , name , method , webContentsId }){
2026-10-13 16:58:15 +02:00
const context = getContext ( webContentsId ); let bytes , facts , mime ;
const auditFacts = ()=> facts ? { bytes : facts . size , detectedType : facts . detectedType , sha256 : facts . sha256 } : undefined ;
const evidence = ( decision , ruleId )=> context ? . ready && isCurrent ( context ) ? buildDownloadEvidence ({ url , name , mime , facts , decision , ruleId }, context . policy ) : null ;
const deny = async reason =>{ await audit ( 'download.block' , reason , new URL ( url ). hostname , auditFacts (), evidence ( 'block' , reason )); status ? .( `Download blocked: ${ reason } ` ); return { released : false , reason };};
2026-10-13 10:33:31 +02:00
if ( busy ) return deny ( 'download-busy' );
if ( ! context || context . webContentsId !== webContentsId ) return deny ( 'unmanaged-download' );
if ( ! context . ready ||! context . policy ? . browser . allowDownloads ) return deny ( 'downloads-disabled' );
const yara = context . policy . security ? . yara , icap = context . policy . enterprise ? . icap ;
const hasYara =!! yara ? . enabled &&!! yara . rules . length , hasIcap =!! icap ? . enabled && icap . scanDownloads !== false ;
if ( yara ? . enabled &&! hasYara ) return deny ( 'yara-required' );
if ( ! hasYara &&! hasIcap &&! context . policy . fileTransfers ? . download ? . allowWithoutMalwareScanner ) return deny ( 'malware-scanner-required' );
if ( method !== 'GET' ||! /^https?:\/\// . test ( url )) return deny ( 'unsupported-download' );
if ( ! isCurrent ( context )) return deny ( 'policy-changed' );
busy = true ; const controller = new AbortController (), timeout = setTimeout (()=> controller . abort (), 45000 );
try {
const response = await fetchResponse ( url ,{ signal : controller . signal , cache : 'no-store' , credentials : 'include' , redirect : 'error' });
if ( ! response . ok ||! response . body ) return deny ( 'download-fetch-failed' );
const limit = Math . min ( hasYara ? yara . maxScanBytes : 20 * 1024 * 1024 , hasIcap ? icap . maxBytes : 20 * 1024 * 1024 , context . policy . fileTransfers ? . download ? . maxBytes ?? 20 * 1024 * 1024 );
const length = response . headers . get ( 'content-length' ); if ( length && Number ( length ) > limit ) return deny ( 'download-size-limit' );
const reader = response . body . getReader (), chunks = []; let size = 0 ;
try { for (;;){ const { value , done } = await reader . read (); if ( done ) break ; size += value . length ; if ( size > limit ){ await reader . cancel (); return deny ( 'download-size-limit' );} chunks . push ( Buffer . from ( value ));}}
finally { reader . releaseLock ();}
bytes = Buffer . concat ( chunks ); const digest = createHash ( 'sha256' ). update ( bytes ). digest ( 'hex' );
if ( ! isCurrent ( context ) || controller . signal . aborted ) return deny ( 'policy-changed' );
2026-10-13 16:58:15 +02:00
mime = response . headers . get ( 'content-type' ); facts = inspectFileTransfer ( bytes , context . policy , 'download' ,{ filename : name , mime }); if ( facts . decision !== 'allow' ) return deny ( facts . ruleId );
2026-10-13 10:33:31 +02:00
const result = await scan ( bytes , context . policy );
if ( result . decision !== 'allow' || result . coverage ? . complete !== true || result . sha256 !== digest ) return deny ( result . ruleId || 'yara-scan-incomplete' );
if ( ! isCurrent ( context ) || controller . signal . aborted ) return deny ( 'policy-changed' );
const scanLabel = hasYara && hasIcap ? 'yara-icap-allow' : hasYara ? 'yara-allow' : hasIcap ? 'icap-allow' : 'explicit-no-antivirus' ;
await audit ( 'download.scan' , scanLabel , new URL ( url ). hostname , auditFacts ());
const selected = await choosePath ( filename ( name )); if ( ! selected ) return { released : false , reason : 'user-cancelled' };
if ( ! isCurrent ( context ) || controller . signal . aborted ) return deny ( 'policy-changed' );
// The only file-writing operation occurs after verified scanning of these exact bytes.
await writeFile ( selected , bytes ,()=> isCurrent ( context ) &&! controller . signal . aborted );
2026-10-13 16:58:15 +02:00
await audit ( 'download.released' , scanLabel , new URL ( url ). hostname , auditFacts (), evidence ( 'allow' , scanLabel )); status ? .( 'Download scanned and saved.' ); return { released : true , sha256 : digest , facts };
2026-10-13 10:33:31 +02:00
} catch { return deny ( 'download-inspection-failed' );}
finally { clearTimeout ( timeout ); bytes ? . fill ( 0 ); busy = false ;}
};
}
module . exports = { createDownloadGate , filename };