Files
safetynet/apps/browser/file-upload-gate.cjs
T

92 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};