93 lines
5.6 KiB
JavaScript
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};
|