75 lines
4.7 KiB
JavaScript
75 lines
4.7 KiB
JavaScript
'use strict';
|
|||
|
|
const {createHash}=require('node:crypto');
|
||
|
|
const {detectType}=require('./file-transfer.cjs');
|
||
|
|
// Typed binary carriers supplement text normalization. No whole decoded file
|
||
|
|
// may be released on the strength of an allow verdict for its outer JSON text.
|
||
|
|
function extractEncodedDocuments(bytes,{maxDecodedBytes=16777216,maxParts=16,maxNodes=20000,maxDepth=32,jsonRequired=false}={}){
|
||
|
|
if(!Buffer.isBuffer(bytes)||bytes.length>16777216||![maxDecodedBytes,maxParts,maxNodes,maxDepth].every(Number.isSafeInteger)||maxDecodedBytes<1||maxDecodedBytes>16777216||maxParts<1||maxParts>64||maxNodes<1||maxNodes>20000||maxDepth<1||maxDepth>32)throw Error('encoded-document-configuration-invalid');
|
||
|
|
if(typeof jsonRequired!=='boolean')throw Error('encoded-document-configuration-invalid');
|
||
|
|
const parts=[],seen=new Set(),encodings=new Set();let total=0,nodes=0,complete=true;
|
||
|
|
const result=()=>Object.freeze({parts:Object.freeze(parts),complete,decodedBytes:total,visitedNodes:nodes,dispose});
|
||
|
|
const dispose=()=>{for(const part of parts)part.bytes.fill(0);};
|
||
|
|
function candidate(value){
|
||
|
|
if(!complete||typeof value!=='string')return;
|
||
|
|
let encoded=value.trim(),explicit=false,mime;
|
||
|
|
if(/^data:/i.test(encoded)){
|
||
|
|
const match=/^data:([a-z0-9.+/-]+)?(?:;[a-z0-9-]+=[a-z0-9._-]+)*;base64,([\s\S]*)$/i.exec(encoded);
|
||
|
|
if(!match){complete=false;return;}mime=(match[1]||'application/octet-stream').toLowerCase();encoded=match[2];explicit=true;
|
||
|
|
if(encoded.includes('%')){try{encoded=decodeURIComponent(encoded);}catch{complete=false;return;}}
|
||
|
|
}
|
||
|
|
if(!explicit&&encoded.length<16)return;
|
||
|
|
if(!/^[A-Za-z0-9+/_-]*={0,2}$/.test(encoded)||encoded.length%4===1){if(explicit)complete=false;return;}
|
||
|
|
const normalized=encoded.replace(/-/g,'+').replace(/_/g,'/').replace(/=+$/,'');
|
||
|
|
const encodingKey=createHash('sha256').update(normalized).update('\0'+(mime||'')).digest('hex');
|
||
|
|
if(encodings.has(encodingKey))return;
|
||
|
|
const projected=Math.floor(normalized.length*3/4);
|
||
|
|
if(total+projected>maxDecodedBytes){complete=false;return;}
|
||
|
|
const decoded=Buffer.from(normalized,'base64');
|
||
|
|
total+=decoded.length;encodings.add(encodingKey);
|
||
|
|
const type=detectType(decoded);
|
||
|
|
if(decoded.toString('base64').replace(/=+$/,'')!==normalized){decoded.fill(0);if(explicit||type!=='unknown')complete=false;return;}
|
||
|
|
if(!explicit&&type==='unknown'){decoded.fill(0);return;}
|
||
|
|
const digest=createHash('sha256').update(decoded).digest('hex');
|
||
|
|
const identity=digest+'\0'+(mime||'');
|
||
|
|
if(seen.has(identity)){decoded.fill(0);return;}
|
||
|
|
if(parts.length>=maxParts){decoded.fill(0);complete=false;return;}
|
||
|
|
seen.add(identity);parts.push(Object.freeze({bytes:decoded,...(mime?{mime}:{})}));
|
||
|
|
}
|
||
|
|
function scanText(value,percentDepth=0){
|
||
|
|
candidate(value);
|
||
|
|
const carriers=[];
|
||
|
|
for(const match of value.matchAll(/data:[^\s,"'<>]+,[A-Za-z0-9+/_=%-]+/gi)){if(++nodes>maxNodes){complete=false;break;}carriers.push([match.index,match.index+match[0].length]);candidate(match[0]);}
|
||
|
|
let carrier=0;
|
||
|
|
for(const match of value.matchAll(/[A-Za-z0-9+/_-]{16,}={0,2}/g)){
|
||
|
|
while(carrier<carriers.length&&carriers[carrier][1]<=match.index)carrier++;
|
||
|
|
if(carrier<carriers.length&&match.index>=carriers[carrier][0]&&match.index<carriers[carrier][1])continue;
|
||
|
|
if(++nodes>maxNodes){complete=false;break;}candidate(match[0]);
|
||
|
|
}
|
||
|
|
if(complete&&value.includes('%')){
|
||
|
|
if(percentDepth>=4){complete=false;return;}
|
||
|
|
try{const decoded=decodeURIComponent(value);if(decoded!==value)scanText(decoded,percentDepth+1);}catch{if(/data%3a/i.test(value))complete=false;}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
try{
|
||
|
|
const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes),trimmed=text.trim();
|
||
|
|
if(jsonRequired||trimmed.startsWith('{')||trimmed.startsWith('[')){
|
||
|
|
let root;try{root=JSON.parse(trimmed);}catch{if(jsonRequired)complete=false;else scanText(trimmed);return result();}
|
||
|
|
const stack=[{value:root,depth:0}];
|
||
|
|
while(stack.length&&complete){const {value,depth}=stack.pop();if(++nodes>maxNodes||depth>maxDepth){complete=false;break;}
|
||
|
|
if(typeof value==='string')scanText(value);
|
||
|
|
else if(Array.isArray(value)){
|
||
|
|
if(value.length>maxNodes-nodes||stack.length+value.length>maxNodes){complete=false;break;}
|
||
|
|
for(const child of value)stack.push({value:child,depth:depth+1});
|
||
|
|
}else if(value&&typeof value==='object'){
|
||
|
|
// Avoid Object.entries allocating an unbounded array of key/value pairs
|
||
|
|
// before the traversal budget is checked on a hostile JSON collection.
|
||
|
|
for(const key in value){if(!Object.hasOwn(value,key))continue;scanText(key);stack.push({value:value[key],depth:depth+1});if(stack.length>maxNodes||!complete){complete=false;break;}}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}else{
|
||
|
|
scanText(trimmed);
|
||
|
|
}
|
||
|
|
}catch{complete=false;}
|
||
|
|
return result();
|
||
|
|
}
|
||
|
|
module.exports={extractEncodedDocuments};
|