91 lines
6.2 KiB
JavaScript
91 lines
6.2 KiB
JavaScript
'use strict';
|
|||
|
|
const {createHash}=require('node:crypto');
|
||
|
|
// Owns Windows read-only leases until Chromium reports a terminal request event.
|
||
|
|
// A denied callback is not a terminal event: releasing there would reopen a race.
|
||
|
|
function createFileUploadGate({createLease,inspectPart,isCurrent,abort,maxBytes}){
|
||
|
|
const pending=new Map();let disposed=false;
|
||
|
|
const deny=ruleId=>({decision:'block',ruleId});
|
||
|
|
function alive(state){return !disposed&&!state.cancelled&&isCurrent(state.context)&&state.leases.every(lease=>lease.isAlive());}
|
||
|
|
function cancel(state){state.cancelled=true;try{abort(state.id);}catch{} }
|
||
|
|
async function release(state){for(const lease of state.leases)await lease.release();state.leases.length=0;}
|
||
|
|
function snapshot(details,context){
|
||
|
|
const parts=details.uploadData,limit=maxBytes(context),copies=[];
|
||
|
|
try{
|
||
|
|
if(!Array.isArray(parts)||!parts.length||parts.length>256||!Number.isSafeInteger(limit)||limit<0)throw Error();
|
||
|
|
const hash=createHash('sha256');hash.update(JSON.stringify([details.url,details.method]));let rawBytes=0;
|
||
|
|
const input=parts.map(part=>{
|
||
|
|
if(part?.type==='rawData'&&!part.file&&!part.blobUUID&&Buffer.isBuffer(part.bytes)){
|
||
|
|
rawBytes+=part.bytes.length;if(rawBytes>limit)throw Error();
|
||
|
|
const bytes=Buffer.from(part.bytes);copies.push(bytes);hash.update(JSON.stringify(['raw',bytes.length]));hash.update(bytes);return {type:'rawData',bytes};
|
||
|
|
}
|
||
|
|
if(part?.type==='file'&&typeof part.file==='string'&&part.file.length&&part.offset===0&&part.length===-1&&!part.bytes&&!part.blobUUID){
|
||
|
|
hash.update(JSON.stringify(['file',part.file,0,-1]));return {type:'file',file:part.file,offset:0,length:-1};
|
||
|
|
}throw Error();
|
||
|
|
});
|
||
|
|
return {uploadData:input,copies,fingerprint:hash.digest('hex')};
|
||
|
|
}catch(error){for(const copy of copies)copy.fill(0);throw error;}
|
||
|
|
}
|
||
|
|
async function run(state,details){
|
||
|
|
const buffers=[...details.copies];
|
||
|
|
try{
|
||
|
|
if(!alive(state))return deny('policy-changed');
|
||
|
|
const parts=details.uploadData;
|
||
|
|
if(!Array.isArray(parts)||!parts.length||parts.length>256)return deny('uninspectable-upload');
|
||
|
|
if(parts.some(part=>!part||!(part.type==='rawData'&&!part.file&&!part.blobUUID&&Buffer.isBuffer(part.bytes)||part.type==='file'&&typeof part.file==='string'&&part.file.length&&part.offset===0&&part.length===-1&&!part.bytes&&!part.blobUUID)))return deny('uninspectable-upload');
|
||
|
|
const limit=maxBytes(state.context);
|
||
|
|
if(!Number.isSafeInteger(limit)||limit<0||parts.reduce((sum,part)=>sum+(part.type==='rawData'?part.bytes.length:0),0)>limit)return deny('size-limit');
|
||
|
|
const paths=parts.filter(part=>part?.type==='file').map(part=>part.file);
|
||
|
|
let lease;
|
||
|
|
if(paths.length){lease=await createLease({paths,maxBytes:limit,onLost:()=>cancel(state)});state.leases.push(lease);if(!alive(state))return deny('upload-lease-lost');}
|
||
|
|
let fileIndex=0,total=0;
|
||
|
|
for(const part of parts){
|
||
|
|
if(!part||typeof part!=='object')return deny('uninspectable-upload');
|
||
|
|
let bytes,kind;
|
||
|
|
if(part.type==='rawData'&&!part.file&&!part.blobUUID&&Buffer.isBuffer(part.bytes)){
|
||
|
|
bytes=part.bytes;kind='raw';
|
||
|
|
}else if(part.type==='file'&&typeof part.file==='string'&&part.file.length&&part.offset===0&&part.length===-1&&!part.bytes&&!part.blobUUID){
|
||
|
|
const file=lease.files[fileIndex++];
|
||
|
|
if(file?.path!==part.file)return deny('uninspectable-upload');
|
||
|
|
bytes=file.bytes;kind='file';
|
||
|
|
if(!Buffer.isBuffer(bytes))return deny('uninspectable-upload');
|
||
|
|
}else return deny('uninspectable-upload');
|
||
|
|
total+=bytes.length;
|
||
|
|
if(!Number.isSafeInteger(total)||total>maxBytes(state.context))return deny('size-limit');
|
||
|
|
state.parts.push({bytes,kind,filename:kind==='file'?part.file:undefined});
|
||
|
|
}
|
||
|
|
if(!alive(state))return deny('policy-changed');
|
||
|
|
// Inspect actual file bytes independently; multipart wrappers must not mask magic.
|
||
|
|
for(const part of state.parts){
|
||
|
|
const verdict=await inspectPart(part.bytes,{kind:part.kind,context:state.context,filename:part.filename});
|
||
|
|
if(!alive(state))return deny('policy-changed');
|
||
|
|
if(verdict?.decision!=='allow'||verdict.coverage?.complete!==true)return deny(verdict?.ruleId||'inspection-incomplete');
|
||
|
|
}
|
||
|
|
// Ordered raw segments are also joined: catches literals split at native boundaries.
|
||
|
|
const raw=Buffer.concat(state.parts.filter(part=>part.kind==='raw').map(part=>part.bytes));buffers.push(raw);
|
||
|
|
const verdict=await inspectPart(raw,{kind:'raw',context:state.context});
|
||
|
|
if(!alive(state))return deny('policy-changed');
|
||
|
|
if(verdict?.decision!=='allow'||verdict.coverage?.complete!==true)return deny(verdict?.ruleId||'inspection-incomplete');
|
||
|
|
return {decision:'allow',ruleId:null,request:{bytes:total,detectedType:'unknown'}};
|
||
|
|
}catch{return deny('inspection-incomplete');}
|
||
|
|
finally{for(const buffer of buffers)buffer.fill(0);for(const lease of state.leases)for(const file of lease.files||[])file.bytes?.fill(0);state.parts.length=0;}
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
inspect(details,context){
|
||
|
|
if(disposed)return Promise.resolve(deny('session-disposed'));
|
||
|
|
let input;try{input=snapshot(details,context);}catch{return Promise.resolve(deny('uninspectable-upload'));}
|
||
|
|
let state=pending.get(details.id);
|
||
|
|
if(state){for(const copy of input.copies)copy.fill(0);if(state.fingerprint!==input.fingerprint||state.context!==context||!alive(state)){cancel(state);return Promise.resolve(deny('request-changed'));}return state.promise;}
|
||
|
|
if(pending.size>=8){for(const copy of input.copies)copy.fill(0);return Promise.resolve(deny('upload-capacity'));}
|
||
|
|
state={id:details.id,fingerprint:input.fingerprint,context,leases:[],parts:[],cancelled:false,terminal:false};pending.set(details.id,state);
|
||
|
|
state.promise=run(state,input).then(verdict=>{if(verdict.decision!=='allow')cancel(state);return verdict;});return state.promise;
|
||
|
|
},
|
||
|
|
async terminal(id){const state=pending.get(id);if(!state)return;pending.delete(id);state.terminal=true;state.cancelled=true;await state.promise;await release(state);},
|
||
|
|
cancelAll(){for(const state of pending.values())cancel(state);},
|
||
|
|
// Disposal rejects new work but is not proof that native readers have stopped.
|
||
|
|
// Only a real terminal callback releases handles; process exit closes leftovers.
|
||
|
|
dispose(){disposed=true;for(const state of pending.values())cancel(state);},
|
||
|
|
size(){return pending.size;},
|
||
|
|
has(id){return pending.has(id);}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
module.exports={createFileUploadGate};
|