Files

93 lines
5.6 KiB
JavaScript

'use strict';
class MultipartError extends Error{
constructor(code){super(code);this.name='MultipartError';this.code=code;}
}
const fail=code=>{throw new MultipartError(code);};
const token=/^[!#$%&'*+.^_`|~0-9a-z-]+$/i;
function parameters(value){
const main=/^([^;]+)(.*)$/.exec(value);if(!main)fail('invalid-multipart');
const result=Object.create(null);let rest=main[2];
while(rest){
const key=/^;[ \t]*([!#$%&'*+.^_`|~0-9a-z-]+)[ \t]*=[ \t]*/i.exec(rest);
if(!key)fail('invalid-multipart');rest=rest.slice(key[0].length);
let parameter;
if(rest.startsWith('"')){
let index=1;parameter='';let closed=false;
for(;index<rest.length;index++){
const character=rest[index];
if(character==='"'){closed=true;index++;break;}
if(character==='\\'){
index++;if(!['"','\\'].includes(rest[index]))fail('invalid-multipart');parameter+=rest[index];
}else parameter+=character;
}
if(!closed)fail('invalid-multipart');rest=rest.slice(index);
}else{
const match=/^([^; \t]+)([ \t]*)(.*)$/.exec(rest);if(!match||!token.test(match[1]))fail('invalid-multipart');
parameter=match[1];rest=match[3];
}
const name=key[1].toLowerCase();if(Object.hasOwn(result,name))fail('invalid-multipart');
result[name]=parameter;rest=rest.replace(/^[ \t]+/,'');
}
return {main:main[1].trim().toLowerCase(),values:result};
}
function metadata(header){
let text;try{text=new TextDecoder('utf-8',{fatal:true}).decode(header);}catch{fail('invalid-multipart');}
const headers=Object.create(null);
for(const line of text.split('\r\n')){
const match=/^([^:]+):[ \t]*(.*)$/.exec(line);
if(!match||!token.test(match[1])||[...match[2]].some(character=>{const code=character.charCodeAt(0);return code<32&&code!==9||code===127;}))fail('invalid-multipart');
const name=match[1].toLowerCase();if(Object.hasOwn(headers,name))fail('invalid-multipart');headers[name]=match[2];
}
if(Object.keys(headers).some(name=>!['content-disposition','content-type'].includes(name)))fail('unsupported-multipart');
if(!headers['content-disposition'])fail('invalid-multipart');
const disposition=parameters(headers['content-disposition']),values=disposition.values;
if(Object.keys(values).some(name=>name.endsWith('*')))fail('unsupported-multipart');
if(disposition.main!=='form-data'||Object.keys(values).some(name=>!['name','filename'].includes(name))||typeof values.name!=='string'||!values.name||Buffer.byteLength(values.name)>256||[values.name,values.filename].some(value=>value!==undefined&&(Buffer.byteLength(value)>1024||[...value].some(character=>character.charCodeAt(0)<32||character.charCodeAt(0)===127))))fail('invalid-multipart');
let contentType;
if(headers['content-type']!==undefined){
if(headers['content-type'].length>256)fail('multipart-limit');
const type=parameters(headers['content-type']);
if(!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(type.main))fail('invalid-multipart');
if(type.main.startsWith('multipart/'))fail('unsupported-multipart');
contentType=headers['content-type'];
}
return {name:values.name,...values.filename!==undefined?{filename:values.filename}:{},...contentType!==undefined?{contentType}:{}};
}
/** Full-buffer parser. No filesystem access or content logging. Returned part
* buffers are independent private copies; dispose only after scanners finish.
* Strict unsupported forms fail closed instead of claiming partial coverage.
*/
function parseMultipart(body,contentType,{maxBytes=16777216,maxParts=64,maxHeaderBytes=8192}={}){
if(!Buffer.isBuffer(body)||typeof contentType!=='string'||![maxBytes,maxParts,maxHeaderBytes].every(Number.isSafeInteger)||maxBytes<0||maxBytes>16777216||maxParts<1||maxParts>256||maxHeaderBytes<1||maxHeaderBytes>32768)fail('invalid-multipart');
if(body.length>maxBytes||contentType.length>256)fail('multipart-limit');
const type=parameters(contentType);
if(type.main!=='multipart/form-data'||Object.keys(type.values).some(name=>name!=='boundary')||!/^[0-9a-z'()+_,./:=?-]{1,70}$/i.test(type.values.boundary||''))fail('invalid-multipart');
const boundary=Buffer.from('--'+type.values.boundary),separator=Buffer.from('\r\n--'+type.values.boundary),parts=[];
const dispose=()=>{for(const part of parts)part.body.fill(0);};
try{
if(!body.subarray(0,boundary.length).equals(boundary))fail('invalid-multipart');
let position=boundary.length;
while(true){
if(body.subarray(position,position+2).equals(Buffer.from('--'))){
position+=2;if(body.subarray(position).equals(Buffer.from('\r\n')))position+=2;
if(position!==body.length)fail('invalid-multipart');break;
}
if(!body.subarray(position,position+2).equals(Buffer.from('\r\n')))fail('invalid-multipart');position+=2;
if(parts.length>=maxParts)fail('multipart-limit');
const headerEnd=body.indexOf('\r\n\r\n',position);
if(headerEnd<0)fail('invalid-multipart');if(headerEnd-position>maxHeaderBytes)fail('multipart-limit');
const record=metadata(body.subarray(position,headerEnd));position=headerEnd+4;
let end=position;
while(true){
end=body.indexOf(separator,end);if(end<0)fail('invalid-multipart');
const suffix=body.subarray(end+separator.length,end+separator.length+2);
if(suffix.equals(Buffer.from('\r\n'))||suffix.equals(Buffer.from('--')))break;
end++;
}
parts.push(Object.freeze({...record,body:Buffer.from(body.subarray(position,end))}));position=end+separator.length;
}
return Object.freeze({coverage:Object.freeze({complete:true}),parts:Object.freeze(parts),dispose});
}catch(error){dispose();throw error;}
}
module.exports={parseMultipart,MultipartError};