diff --git a/README.md b/README.md index 4747790..4ac00a4 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,8 @@ http.createServer((req, res) => { * **fileSize** - _integer_ - For multipart forms, the max file size (in bytes). **Default:** `Infinity`. + * **totalSize** - _integer_ - For multipart forms, the max total size (in bytes) of all file data combined. **Default:** `Infinity`. + * **files** - _integer_ - For multipart forms, the max number of file fields. **Default:** `Infinity`. * **parts** - _integer_ - For multipart forms, the max number of parts (fields + files). **Default:** `Infinity`. @@ -178,6 +180,8 @@ This function can throw exceptions if there is something wrong with the values i However, if you aren't accepting files, you can either simply not listen for the `'file'` event at all or set `limits.files` to `0`, and any/all files will be automatically skipped (these skipped files will still count towards any configured `limits.files` and `limits.parts` limits though). **Note:** If a configured `limits.fileSize` limit was reached for a file, `stream` will both have a boolean property `truncated` set to `true` (best checked at the end of the stream) and emit a `'limit'` event to notify you when this happens. + + Similarly, if a configured `limits.totalSize` limit (total size of all files) was reached, the affected file `stream` will have `truncated` set to `true` and emit a `'totalSizeLimit'` event. Once this limit is reached, all subsequent file uploads will be automatically skipped. * **field**(< _string_ >name, < _string_ >value, < _object_ >info) - Emitted for each new non-file field found. `name` contains the form field name. `value` contains the string value of the field. `info` contains the following properties: @@ -194,3 +198,5 @@ This function can throw exceptions if there is something wrong with the values i * **filesLimit**() - Emitted when the configured `limits.files` limit has been reached. No more `'file'` events will be emitted. * **fieldsLimit**() - Emitted when the configured `limits.fields` limit has been reached. No more `'field'` events will be emitted. + +* **totalSizeLimit**() - Emitted when the configured `limits.totalSize` limit has been reached. No more file data will be processed, and any subsequent file uploads will be automatically skipped. This event is emitted both on the affected file stream and on the busboy parser instance. diff --git a/lib/types/multipart.js b/lib/types/multipart.js index cc0d7bb..8035f43 100644 --- a/lib/types/multipart.js +++ b/lib/types/multipart.js @@ -254,6 +254,9 @@ class Multipart extends Writable { const fileSizeLimit = (limits && typeof limits.fileSize === 'number' ? limits.fileSize : Infinity); + const totalSizeLimit = (limits && typeof limits.totalSize === 'number' + ? limits.totalSize + : Infinity); const filesLimit = (limits && typeof limits.files === 'number' ? limits.files : Infinity); @@ -273,6 +276,7 @@ class Multipart extends Writable { this._fileStream = undefined; this._complete = false; let fileSize = 0; + let totalFilesSize = 0; let field; let fieldSize = 0; @@ -284,6 +288,7 @@ class Multipart extends Writable { let hitFilesLimit = false; let hitFieldsLimit = false; + let hitTotalSizeLimit = false; this._hparser = null; const hparser = new HeaderParser((header) => { @@ -345,6 +350,12 @@ class Multipart extends Writable { skipPart = true; return; } + + if (hitTotalSizeLimit) { + skipPart = true; + return; + } + ++files; if (this.listenerCount('file') === 0) { @@ -464,7 +475,11 @@ retrydata: if (!skipPart) { if (this._fileStream) { let chunk; - const actualLen = Math.min(end - start, fileSizeLimit - fileSize); + const actualLen = Math.min( + end - start, + fileSizeLimit - fileSize, + totalSizeLimit - totalFilesSize + ); if (!isDataSafe) { chunk = Buffer.allocUnsafe(actualLen); data.copy(chunk, 0, start, start + actualLen); @@ -473,12 +488,22 @@ retrydata: } fileSize += chunk.length; - if (fileSize === fileSizeLimit) { + totalFilesSize += chunk.length; + + if (fileSize === fileSizeLimit || totalFilesSize === totalSizeLimit) { if (chunk.length > 0) this._fileStream.push(chunk); - this._fileStream.emit('limit'); this._fileStream.truncated = true; skipPart = true; + if (totalFilesSize === totalSizeLimit) { + if (!hitTotalSizeLimit) { + hitTotalSizeLimit = true; + this.emit('totalSizeLimit'); + this._fileStream.emit('totalSizeLimit'); + } + } else { + this._fileStream.emit('limit'); + } } else if (!this._fileStream.push(chunk)) { if (this._writecb) this._fileStream._readcb = this._writecb; diff --git a/test/test-types-multipart.js b/test/test-types-multipart.js index 9755642..cb256a7 100644 --- a/test/test-types-multipart.js +++ b/test/test-types-multipart.js @@ -63,6 +63,7 @@ const tests = [ mimeType: 'application/octet-stream', }, limited: false, + totalSizeLimited: false, }, { type: 'file', name: 'upload_file_1', @@ -73,6 +74,7 @@ const tests = [ mimeType: 'application/octet-stream', }, limited: false, + totalSizeLimited: false, }, ], what: 'Fields and files' @@ -176,10 +178,151 @@ const tests = [ mimeType: 'application/octet-stream', }, limited: true, + totalSizeLimited: false, }, ], what: 'Fields and files (limits)' }, + { source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'super alpha file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_1"', + '', + 'super beta file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="1k_b.dat"', + 'Content-Type: application/octet-stream', + '', + '0123456789', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + limits: { + totalSize: 26, + }, + expected: [ + { type: 'field', + name: 'file_name_0', + val: 'super alpha file', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain', + }, + }, + { type: 'file', + name: 'upload_file_0', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: '1k_a.dat', + encoding: '7bit', + mimeType: 'application/octet-stream', + }, + limited: false, + totalSizeLimited: true, + }, + 'totalSizeLimit', + { type: 'field', + name: 'file_name_1', + val: 'super beta file', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain', + }, + }, + ], + what: 'Total size limit' + }, + { source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'super alpha file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_1"', + '', + 'super beta file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="1k_b.dat"', + 'Content-Type: application/octet-stream', + '', + '0123456789', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + limits: { + totalSize: 15, + fileSize: 13, + }, + expected: [ + { type: 'field', + name: 'file_name_0', + val: 'super alpha file', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain', + }, + }, + { type: 'file', + name: 'upload_file_0', + data: Buffer.from('ABCDEFGHIJKLM'), + info: { + filename: '1k_a.dat', + encoding: '7bit', + mimeType: 'application/octet-stream', + }, + limited: true, + totalSizeLimited: false, + }, + { type: 'field', + name: 'file_name_1', + val: 'super beta file', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain', + }, + }, + { type: 'file', + name: 'upload_file_1', + data: Buffer.from('01'), + info: { + filename: '1k_b.dat', + encoding: '7bit', + mimeType: 'application/octet-stream', + }, + totalSizeLimited: true, + limited: false, + }, + 'totalSizeLimit', + ], + what: 'Total size and file size (limits)' + }, { source: [ ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', 'Content-Disposition: form-data; name="file_name_0"', @@ -296,6 +439,7 @@ const tests = [ mimeType: 'application/octet-stream', }, limited: false, + totalSizeLimited: false, }, { type: 'file', name: 'upload_file_1', @@ -306,6 +450,7 @@ const tests = [ mimeType: 'application/octet-stream', }, limited: false, + totalSizeLimited: false, }, { type: 'file', name: 'upload_file_2', @@ -316,6 +461,7 @@ const tests = [ mimeType: 'application/octet-stream', }, limited: false, + totalSizeLimited: false, }, ], what: 'Files with filenames containing paths' @@ -354,6 +500,7 @@ const tests = [ mimeType: 'application/octet-stream', }, limited: false, + totalSizeLimited: false, }, { type: 'file', name: 'upload_file_1', @@ -364,6 +511,7 @@ const tests = [ mimeType: 'application/octet-stream', }, limited: false, + totalSizeLimited: false, }, { type: 'file', name: 'upload_file_2', @@ -374,6 +522,7 @@ const tests = [ mimeType: 'application/octet-stream', }, limited: false, + totalSizeLimited: false, }, ], what: 'Paths to be preserved through the preservePath option' @@ -427,6 +576,7 @@ const tests = [ mimeType: 'application/octet-stream', }, limited: false, + totalSizeLimited: false, }, ], what: 'Unicode filenames' @@ -499,6 +649,7 @@ const tests = [ mimeType: 'application/octet-stream', }, limited: false, + totalSizeLimited: false, err: 'Unexpected end of form', }, { error: 'Unexpected end of form' }, @@ -525,6 +676,7 @@ const tests = [ mimeType: 'application/octet-stream', }, limited: false, + totalSizeLimited: false, err: 'Unexpected end of form', }, { error: 'Unexpected end of form' }, @@ -552,6 +704,7 @@ const tests = [ mimeType: 'text/plain', }, limited: false, + totalSizeLimited: false, }, ], what: 'Text file with charset' @@ -578,6 +731,7 @@ const tests = [ mimeType: 'text/plain', }, limited: false, + totalSizeLimited: false, }, ], what: 'Folded header value' @@ -623,6 +777,7 @@ const tests = [ mimeType: 'text/plain', }, limited: false, + totalSizeLimited: false, }, ], events: [ 'file' ], @@ -723,6 +878,7 @@ const tests = [ mimeType: 'text/plain', }, limited: false, + totalSizeLimited: false, }, 'filesLimit', ], @@ -756,6 +912,7 @@ const tests = [ mimeType: 'text/plain', }, limited: false, + totalSizeLimited: false, }, ], what: 'Oversized part header' @@ -783,6 +940,7 @@ const tests = [ mimeType: 'text/plain', }, limited: false, + totalSizeLimited: false, }, ], what: 'Lookbehind data should not stall file streams' @@ -820,6 +978,7 @@ const tests = [ mimeType: 'text/plain', }, limited: false, + totalSizeLimited: false, }, { type: 'file', name: 'upload_file_1', @@ -830,6 +989,7 @@ const tests = [ mimeType: 'text/plain', }, limited: false, + totalSizeLimited: false, }, { type: 'file', name: 'upload_file_2', @@ -840,6 +1000,7 @@ const tests = [ mimeType: 'text/plain', }, limited: false, + totalSizeLimited: false, }, ], what: 'Header size limit should be per part' @@ -863,6 +1024,7 @@ const tests = [ mimeType: 'application/gzip', }, limited: false, + totalSizeLimited: false, }, ], what: 'Empty part' @@ -899,6 +1061,7 @@ for (const test of tests) { data: null, info, limited: false, + totalSizeLimited: false, }; results.push(file); stream.on('data', (d) => { @@ -906,9 +1069,11 @@ for (const test of tests) { nb += d.length; }).on('limit', () => { file.limited = true; + }).on('totalSizeLimit', () => { + file.totalSizeLimited = true; }).on('close', () => { file.data = Buffer.concat(data, nb); - assert.strictEqual(stream.truncated, file.limited); + assert.strictEqual(stream.truncated, file.limited || file.totalSizeLimited); }).once('error', (err) => { file.err = err.message; }); @@ -927,6 +1092,10 @@ for (const test of tests) { results.push('filesLimit'); }); + bb.on('totalSizeLimit', () => { + results.push('totalSizeLimit'); + }); + bb.on('fieldsLimit', () => { results.push('fieldsLimit'); }); @@ -983,6 +1152,7 @@ for (let test of tests) { data: null, info, limited: false, + totalSizeLimited: false, }; results.push(file); stream.on('data', (d) => { @@ -990,9 +1160,11 @@ for (let test of tests) { nb += d.length; }).on('limit', () => { file.limited = true; + }).on('totalSizeLimit', () => { + file.totalSizeLimited = true; }).on('close', () => { file.data = Buffer.concat(data, nb); - assert.strictEqual(stream.truncated, file.limited); + assert.strictEqual(stream.truncated, file.limited || file.totalSizeLimited); }).once('error', (err) => { file.err = err.message; }); @@ -1015,6 +1187,10 @@ for (let test of tests) { results.push('fieldsLimit'); }); + bb.on('totalSizeLimit', () => { + results.push('totalSizeLimit'); + }); + bb.on('close', () => { active.delete(test);