Skip to content

Commit ac9a2de

Browse files
committed
fix: protect regex from redos
1 parent cce68b1 commit ac9a2de

3 files changed

Lines changed: 140 additions & 9 deletions

File tree

lib/index.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const internals = {};
1616
*/
1717

1818
// 1: type/subtype 2: params
19-
internals.contentTypeRegex = /^([^\/\s]+\/[^\s;]+)(.*)?$/;
19+
internals.contentTypeRegex = /^([^\/\s]+\/[^\s;]+)([ \t;][^\r\n]*)?$/;
2020

2121
// 1: "b" 2: b
2222
internals.charsetParamRegex = /;\s*charset=(?:"([^"]+)"|([^;"\s]+))/i;
@@ -82,10 +82,10 @@ exports.type = function (header) {
8282
*/
8383

8484

85-
internals.contentDispositionRegex = /^\s*form-data\s*(?:;\s*(.+))?$/i;
85+
internals.contentDispositionRegex = /^\s*form-data\s*(?:;\s*(\S.*))?$/i;
8686

8787
// 1: name 2: * 3: ext-value 4: quoted 5: token
88-
internals.contentDispositionParamRegex = /([^\=\*\s]+)(\*)?\s*\=\s*(?:([^;'"\s]+\'[\w-]*\'[^;\s]+)|(?:\"([^"]*)\")|([^;\s]*))(?:\s*(?:;\s*)|$)/g;
88+
internals.contentDispositionParamRegex = /([^\=\*\s]+)(\*)?\s*\=\s*(?:([^;'"\s]+\'[\w-]*\'[^;\s]+)|(?:\"([^"]*)\")|([^;\s]*))/g;
8989

9090
exports.disposition = function (header) {
9191

test/esm.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe('import()', () => {
1919

2020
it('exposes all methods and classes as named imports', () => {
2121

22-
expect(Object.keys(Content)).to.equal([
22+
expect(Object.keys(Content)).to.include([
2323
'default',
2424
'disposition',
2525
'type'

test/index.js

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,22 +95,22 @@ describe('type()', () => {
9595

9696
it('errors on missing header', () => {
9797

98-
expect(() => Content.type()).to.throw();
98+
expect(() => Content.type()).to.throw('Invalid content-type header');
9999
});
100100

101101
it('errors on invalid header', () => {
102102

103-
expect(() => Content.type('application; some')).to.throw();
103+
expect(() => Content.type('application; some')).to.throw('Invalid content-type header');
104104
});
105105

106106
it('errors on multipart missing boundary', () => {
107107

108-
expect(() => Content.type('multipart/form-data')).to.throw();
108+
expect(() => Content.type('multipart/form-data')).to.throw('Invalid content-type header: multipart missing boundary');
109109
});
110110

111111
it('errors on multipart missing boundary (other params)', () => {
112112

113-
expect(() => Content.type('multipart/form-data; some=thing')).to.throw();
113+
expect(() => Content.type('multipart/form-data; some=thing')).to.throw('Invalid content-type header: multipart missing boundary');
114114
});
115115

116116
it('handles multiple boundary params', () => {
@@ -128,6 +128,19 @@ describe('type()', () => {
128128
Content.type(header);
129129
expect(Date.now() - now).to.be.below(100);
130130
});
131+
132+
it('handles trailing newline in content-type without backtracking', () => {
133+
134+
const header = 'a/b' + 'c'.repeat(50000) + '\n';
135+
const now = Date.now();
136+
expect(() => Content.type(header)).to.throw('Invalid content-type header');
137+
expect(Date.now() - now).to.be.below(100);
138+
});
139+
140+
it('errors on content-type with embedded newline', () => {
141+
142+
expect(() => Content.type('application/json\n; charset=utf-8')).to.throw('Invalid content-type header');
143+
});
131144
});
132145

133146
describe('disposition()', () => {
@@ -164,7 +177,15 @@ describe('disposition()', () => {
164177

165178
const now = Date.now();
166179
const header = `form-data; x; ${new Array(5000).join(' ')};`;
167-
expect(() => Content.disposition(header)).to.throw();
180+
expect(() => Content.disposition(header)).to.throw('Invalid content-disposition header missing name parameter');
181+
expect(Date.now() - now).to.be.below(100);
182+
});
183+
184+
it('handles trailing newline in content-disposition without backtracking', () => {
185+
186+
const header = 'form-data;' + ' '.repeat(50000) + '\n';
187+
const now = Date.now();
188+
expect(() => Content.disposition(header)).to.throw('Invalid content-disposition header format');
168189
expect(Date.now() - now).to.be.below(100);
169190
});
170191

@@ -220,4 +241,114 @@ describe('disposition()', () => {
220241
const header = 'form-data; name="__proto__"; filename=file.jpg';
221242
expect(() => Content.disposition(header)).to.throw('Invalid content-disposition header format includes invalid parameters');
222243
});
244+
245+
it('handles ReDoS exploit (polynomial backtracking via crafted commas and spaces)', () => {
246+
247+
const N = 4000;
248+
const header = 'form-data;' + '=' + ','.repeat(N) + '"=' + ' '.repeat(N) + '= "';
249+
const now = Date.now();
250+
expect(() => Content.disposition(header)).to.throw('Invalid content-disposition header missing name parameter');
251+
expect(Date.now() - now).to.be.below(100);
252+
});
253+
254+
it('handles large number of spaces around equals', () => {
255+
256+
const now = Date.now();
257+
const header = `form-data; name${' '.repeat(10000)}=${' '.repeat(10000)}file`;
258+
Content.disposition(header);
259+
expect(Date.now() - now).to.be.below(100);
260+
});
261+
262+
it('handles large number of parameters', () => {
263+
264+
const params = Array.from({ length: 1000 }, (_, i) => `p${i}=v${i}`).join('; ');
265+
const header = `form-data; name="file"; ${params}`;
266+
const now = Date.now();
267+
Content.disposition(header);
268+
expect(Date.now() - now).to.be.below(100);
269+
});
270+
271+
it('parses header with spaces around equals', () => {
272+
273+
const header = 'form-data; name = "file" ; filename = file.jpg';
274+
expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'file.jpg' });
275+
});
276+
277+
it('parses header with empty token value', () => {
278+
279+
const header = 'form-data; name="file"; filename=';
280+
expect(Content.disposition(header)).to.equal({ name: 'file', filename: '' });
281+
});
282+
283+
it('parses header with multiple parameters', () => {
284+
285+
const header = 'form-data; name="file"; filename="test.jpg"; size=1234';
286+
expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg', size: '1234' });
287+
});
288+
289+
it('parses header with ext-value and regular params', () => {
290+
291+
const header = 'form-data; name="file"; filename*=utf-8\'en\'my%20file.jpg; size=999';
292+
expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'my file.jpg', size: '999' });
293+
});
294+
295+
it('parses header with no trailing semicolon', () => {
296+
297+
const header = 'form-data; name="file"; filename=test.jpg';
298+
expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg' });
299+
});
300+
301+
it('parses header with trailing semicolon', () => {
302+
303+
const header = 'form-data; name="file"; filename=test.jpg;';
304+
expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg' });
305+
});
306+
307+
it('parses header with extra whitespace between params', () => {
308+
309+
const header = 'form-data; name="file" ; filename=test.jpg ; size=100';
310+
expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg', size: '100' });
311+
});
312+
313+
it('parses header with hyphenated parameter name', () => {
314+
315+
const header = 'form-data; name="file"; file-name=test.jpg';
316+
expect(Content.disposition(header)).to.equal({ name: 'file', 'file-name': 'test.jpg' });
317+
});
318+
319+
it('parses header with dotted parameter name', () => {
320+
321+
const header = 'form-data; name="file"; file.name=test.jpg';
322+
expect(Content.disposition(header)).to.equal({ name: 'file', 'file.name': 'test.jpg' });
323+
});
324+
325+
it('parses header with ext-value without language tag', () => {
326+
327+
const header = 'form-data; name="file"; filename*=utf-8\'\'my%20file.jpg';
328+
expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'my file.jpg' });
329+
});
330+
331+
it('parses params even when followed by non-param garbage', () => {
332+
333+
const header = 'form-data; name="file"; filename=test.jpg GARBAGE';
334+
expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg' });
335+
});
336+
337+
it('parses params when a key without value follows', () => {
338+
339+
const header = 'form-data; name="file"; filename=test.jpg; notaparam';
340+
expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg' });
341+
});
342+
343+
it('parses header with trailing whitespace after last param', () => {
344+
345+
const header = 'form-data; name="file"; filename=test.jpg ';
346+
expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg' });
347+
});
348+
349+
it('parses header with trailing whitespace after last quoted param', () => {
350+
351+
const header = 'form-data; name="file"; filename="test.jpg" ';
352+
expect(Content.disposition(header)).to.equal({ name: 'file', filename: 'test.jpg' });
353+
});
223354
});

0 commit comments

Comments
 (0)