Files
safetynet/packages/core/file-transfer.cjs
T

29 lines
4.3 KiB
JavaScript
Raw Normal View History

2026-10-13 10:33:31 +02:00
'use strict';
const {createHash}=require('node:crypto');
const path=require('node:path');
const TYPES=['pe','elf','macho','pdf','zip','ole','png','jpeg','gif','webp','gzip','text','unknown'];
const EXT={exe:'pe',dll:'pe',sys:'pe',elf:'elf',pdf:'pdf',zip:'zip',docx:'zip',xlsx:'zip',pptx:'zip',docm:'zip',xlsm:'zip',xlsb:'zip',pptm:'zip',dotm:'zip',xlam:'zip',ppam:'zip',doc:'ole',xls:'ole',ppt:'ole',png:'png',jpg:'jpeg',jpeg:'jpeg',gif:'gif',webp:'webp',gz:'gzip',gzip:'gzip',txt:'text',csv:'text',json:'text',html:'text',js:'text',css:'text',xml:'text',md:'text'};
const MIME={'application/msword':'ole','application/vnd.ms-excel':'ole','application/vnd.ms-powerpoint':'ole','application/vnd.ms-word.document.macroenabled.12':'zip','application/vnd.ms-excel.sheet.macroenabled.12':'zip','application/vnd.ms-powerpoint.presentation.macroenabled.12':'zip','application/pdf':'pdf','application/zip':'zip','application/x-zip-compressed':'zip','application/vnd.openxmlformats-officedocument.wordprocessingml.document':'zip','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':'zip','application/vnd.openxmlformats-officedocument.presentationml.presentation':'zip','image/png':'png','image/jpeg':'jpeg','image/gif':'gif','image/webp':'webp','application/gzip':'gzip','application/x-gzip':'gzip','application/json':'text','application/xml':'text','application/javascript':'text','application/x-msdownload':'pe','application/vnd.microsoft.portable-executable':'pe'};
2026-10-13 10:33:31 +02:00
function safeFilename(value){const name=path.win32.basename(path.posix.basename(String(value||'download.bin'))).replace(/[<>:"/\\|?*\x00-\x1f\x7f]/g,'_').replace(/[. ]+$/g,'').slice(0,180);return !name||/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)?'download.bin':name;}
function detectType(value){
const b=Buffer.from(value);const starts=(hex)=>b.length>=hex.length/2&&b.subarray(0,hex.length/2).equals(Buffer.from(hex,'hex'));
// MZ is blocked as PE-family even if the header is corrupt or deliberately truncated.
if(starts('4d5a'))return 'pe';if(starts('7f454c46'))return 'elf';
if(['feedface','cefaedfe','feedfacf','cffaedfe','cafebabe','bebafeca','cafebabf','bfbafeca'].some(starts))return 'macho';
if(starts('d0cf11e0a1b11ae1'))return 'ole';if(starts('255044462d'))return 'pdf';if(['504b0304','504b0506','504b0708'].some(starts))return 'zip';
2026-10-13 10:33:31 +02:00
if(starts('89504e470d0a1a0a'))return 'png';if(starts('ffd8ff'))return 'jpeg';
if(b.subarray(0,6).toString('ascii')==='GIF87a'||b.subarray(0,6).toString('ascii')==='GIF89a')return 'gif';
if(b.length>=12&&b.subarray(0,4).toString('ascii')==='RIFF'&&b.subarray(8,12).toString('ascii')==='WEBP')return 'webp';if(starts('1f8b'))return 'gzip';
// Decode the entire bounded payload. Prefix-only heuristics miss binary tails.
try{const text=new TextDecoder('utf-8',{fatal:true}).decode(b);if(!/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text))return 'text';}catch{}
return 'unknown';
}
function inspectFileTransfer(bytes,policy,direction,metadata={}){
if(!['upload','download'].includes(direction))throw Error('Unsupported transfer direction');
const settings=policy.fileTransfers?.[direction]||{};const name=safeFilename(metadata.filename),mime=String(metadata.mime||'').split(';',1)[0].trim().toLowerCase().slice(0,160);
const detectedType=detectType(bytes),extensionType=EXT[path.extname(name).slice(1).toLowerCase()]||null,mimeType=mime.startsWith('text/')?'text':MIME[mime]||null;
const facts={filename:name,mimeclaimed:mime,detectedType,size:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex'),extensionMismatch:!!extensionType&&extensionType!==detectedType,mimeMismatch:!!mimeType&&mimeType!==detectedType};
let ruleId=null;if(bytes.length>(settings.maxBytes??20*1024*1024))ruleId='file-transfer-size-limit';else if(settings.blockedTypes?.includes(detectedType))ruleId='file-transfer-type-blocked';else if(settings.allowTypes?.length&&!settings.allowTypes.includes(detectedType))ruleId='file-transfer-type-not-allowed';else if(settings.blockUnknown&&detectedType==='unknown')ruleId='file-transfer-unknown';else if(settings.blockExtensionMismatch&&facts.extensionMismatch)ruleId='file-transfer-extension-mismatch';else if(settings.blockMimeMismatch&&facts.mimeMismatch)ruleId='file-transfer-mime-mismatch';
return {decision:ruleId?'block':'allow',ruleId,...facts};
}
module.exports={TYPES,safeFilename,detectType,inspectFileTransfer};