diff --git a/lib/index.js b/lib/index.js index 819aedc..fd0aa71 100755 --- a/lib/index.js +++ b/lib/index.js @@ -16,7 +16,7 @@ const internals = {}; */ // 1: type/subtype 2: params -internals.contentTypeRegex = /^([^\/\s]+\/[^\s;]+)(.*)?$/; +internals.contentTypeRegex = /^([^\/\s]+\/[^\s;]+)([ \t;][^\r\n]*)?$/; // 1: "b" 2: b internals.charsetParamRegex = /;\s*charset=(?:"([^"]+)"|([^;"\s]+))/i; @@ -82,10 +82,10 @@ exports.type = function (header) { */ -internals.contentDispositionRegex = /^\s*form-data\s*(?:;\s*(.+))?$/i; +internals.contentDispositionRegex = /^\s*form-data\s*(?:;\s*(\S.*))?$/i; // 1: name 2: * 3: ext-value 4: quoted 5: token -internals.contentDispositionParamRegex = /([^\=\*\s]+)(\*)?\s*\=\s*(?:([^;'"\s]+\'[\w-]*\'[^;\s]+)|(?:\"([^"]*)\")|([^;\s]*))(?:\s*(?:;\s*)|$)/g; +internals.contentDispositionParamRegex = /([^\=\*\s]+)(\*)?\s*\=\s*(?:([^;'"\s]+\'[\w-]*\'[^;\s]+)|(?:\"([^"]*)\")|([^;\s]*))/g; exports.disposition = function (header) { diff --git a/test/esm.js b/test/esm.js index eaad512..27ab6a7 100644 --- a/test/esm.js +++ b/test/esm.js @@ -19,7 +19,7 @@ describe('import()', () => { it('exposes all methods and classes as named imports', () => { - expect(Object.keys(Content)).to.equal([ + expect(Object.keys(Content)).to.include([ 'default', 'disposition', 'type' diff --git a/test/index.js b/test/index.js index 38c12cd..c386c26 100755 --- a/test/index.js +++ b/test/index.js @@ -95,22 +95,22 @@ describe('type()', () => { it('errors on missing header', () => { - expect(() => Content.type()).to.throw(); + expect(() => Content.type()).to.throw('Invalid content-type header'); }); it('errors on invalid header', () => { - expect(() => Content.type('application; some')).to.throw(); + expect(() => Content.type('application; some')).to.throw('Invalid content-type header'); }); it('errors on multipart missing boundary', () => { - expect(() => Content.type('multipart/form-data')).to.throw(); + expect(() => Content.type('multipart/form-data')).to.throw('Invalid content-type header: multipart missing boundary'); }); it('errors on multipart missing boundary (other params)', () => { - expect(() => Content.type('multipart/form-data; some=thing')).to.throw(); + expect(() => Content.type('multipart/form-data; some=thing')).to.throw('Invalid content-type header: multipart missing boundary'); }); it('handles multiple boundary params', () => { @@ -128,6 +128,19 @@ describe('type()', () => { Content.type(header); expect(Date.now() - now).to.be.below(100); }); + + it('handles trailing newline in content-type without backtracking', () => { + + const header = 'a/b' + 'c'.repeat(50000) + '\n'; + const now = Date.now(); + expect(() => Content.type(header)).to.throw('Invalid content-type header'); + expect(Date.now() - now).to.be.below(100); + }); + + it('errors on content-type with embedded newline', () => { + + expect(() => Content.type('application/json\n; charset=utf-8')).to.throw('Invalid content-type header'); + }); }); describe('disposition()', () => { @@ -164,7 +177,15 @@ describe('disposition()', () => { const now = Date.now(); const header = `form-data; x; ${new Array(5000).join(' ')};`; - expect(() => Content.disposition(header)).to.throw(); + expect(() => Content.disposition(header)).to.throw('Invalid content-disposition header missing name parameter'); + expect(Date.now() - now).to.be.below(100); + }); + + it('handles trailing newline in content-disposition without backtracking', () => { + + const header = 'form-data;' + ' '.repeat(50000) + '\n'; + const now = Date.now(); + expect(() => Content.disposition(header)).to.throw('Invalid content-disposition header format'); expect(Date.now() - now).to.be.below(100); }); @@ -220,4 +241,114 @@ describe('disposition()', () => { const header = 'form-data; name="__proto__"; filename=file.jpg'; expect(() => Content.disposition(header)).to.throw('Invalid content-disposition header format includes invalid parameters'); }); + + it('handles ReDoS exploit (polynomial backtracking via crafted commas and spaces)', () => { + + const N = 4000; + const header = 'form-data;' + '=' + ','.repeat(N) + '"=' + ' '.repeat(N) + '= "'; + const now = Date.now(); + expect(() => Content.disposition(header)).to.throw('Invalid content-disposition header missing name parameter'); + expect(Date.now() - now).to.be.below(100); + }); + + it('handles large number of spaces around equals', () => { + + const now = Date.now(); + const header = `form-data; name${' '.repeat(10000)}=${' '.repeat(10000)}file`; + Content.disposition(header); + expect(Date.now() - now).to.be.below(100); + }); + + it('handles large number of parameters', () => { + + const params = Array.from({ length: 1000 }, (_, i) => `p${i}=v${i}`).join('; '); + const header = `form-data; name="file"; ${params}`; + const now = Date.now(); + Content.disposition(header); + expect(Date.now() - now).to.be.below(100); + }); + + it('parses header with spaces around equals', () => { + + const header = 'form-data; name = "file" ; filename = file.jpg'; + expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'file.jpg' }); + }); + + it('parses header with empty token value', () => { + + const header = 'form-data; name="file"; filename='; + expect(Content.disposition(header)).to.equal({ name: 'file', filename: '' }); + }); + + it('parses header with multiple parameters', () => { + + const header = 'form-data; name="file"; filename="test.jpg"; size=1234'; + expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg', size: '1234' }); + }); + + it('parses header with ext-value and regular params', () => { + + const header = 'form-data; name="file"; filename*=utf-8\'en\'my%20file.jpg; size=999'; + expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'my file.jpg', size: '999' }); + }); + + it('parses header with no trailing semicolon', () => { + + const header = 'form-data; name="file"; filename=test.jpg'; + expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg' }); + }); + + it('parses header with trailing semicolon', () => { + + const header = 'form-data; name="file"; filename=test.jpg;'; + expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg' }); + }); + + it('parses header with extra whitespace between params', () => { + + const header = 'form-data; name="file" ; filename=test.jpg ; size=100'; + expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg', size: '100' }); + }); + + it('parses header with hyphenated parameter name', () => { + + const header = 'form-data; name="file"; file-name=test.jpg'; + expect(Content.disposition(header)).to.equal({ name: 'file', 'file-name': 'test.jpg' }); + }); + + it('parses header with dotted parameter name', () => { + + const header = 'form-data; name="file"; file.name=test.jpg'; + expect(Content.disposition(header)).to.equal({ name: 'file', 'file.name': 'test.jpg' }); + }); + + it('parses header with ext-value without language tag', () => { + + const header = 'form-data; name="file"; filename*=utf-8\'\'my%20file.jpg'; + expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'my file.jpg' }); + }); + + it('parses params even when followed by non-param garbage', () => { + + const header = 'form-data; name="file"; filename=test.jpg GARBAGE'; + expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg' }); + }); + + it('parses params when a key without value follows', () => { + + const header = 'form-data; name="file"; filename=test.jpg; notaparam'; + expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg' }); + }); + + it('parses header with trailing whitespace after last param', () => { + + const header = 'form-data; name="file"; filename=test.jpg '; + expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg' }); + }); + + it('parses header with trailing whitespace after last quoted param', () => { + + const header = 'form-data; name="file"; filename="test.jpg" '; + expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg' }); + }); });