fastify 3.29.2 → 3.29.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fastify.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const VERSION = '3.29.2'
3
+ const VERSION = '3.29.4'
4
4
 
5
5
  const Avvio = require('avvio')
6
6
  const http = require('http')
@@ -6,6 +6,7 @@ let lru = require('tiny-lru')
6
6
  // See https://github.com/fastify/fastify/issues/2356
7
7
  // and https://github.com/fastify/fastify/discussions/2907.
8
8
  lru = typeof lru === 'function' ? lru : lru.default
9
+ const { parse: parseContentType } = require('content-type')
9
10
 
10
11
  const secureJson = require('secure-json-parse')
11
12
  const {
@@ -32,10 +33,11 @@ const warning = require('./warnings')
32
33
 
33
34
  function ContentTypeParser (bodyLimit, onProtoPoisoning, onConstructorPoisoning) {
34
35
  this[kDefaultJsonParse] = getDefaultJsonParser(onProtoPoisoning, onConstructorPoisoning)
35
- this.customParsers = {}
36
- this.customParsers['application/json'] = new Parser(true, false, bodyLimit, this[kDefaultJsonParse])
37
- this.customParsers['text/plain'] = new Parser(true, false, bodyLimit, defaultPlainTextParser)
38
- this.parserList = ['application/json', 'text/plain']
36
+ // using a map instead of a plain object to avoid prototype hijack attacks
37
+ this.customParsers = new Map()
38
+ this.customParsers.set('application/json', new Parser(true, false, bodyLimit, this[kDefaultJsonParse]))
39
+ this.customParsers.set('text/plain', new Parser(true, false, bodyLimit, defaultPlainTextParser))
40
+ this.parserList = [new ParserListItem('application/json'), new ParserListItem('text/plain')]
39
41
  this.parserRegExpList = []
40
42
  this.cache = lru(100)
41
43
  }
@@ -65,38 +67,56 @@ ContentTypeParser.prototype.add = function (contentType, opts, parserFn) {
65
67
  )
66
68
 
67
69
  if (contentTypeIsString && contentType === '*') {
68
- this.customParsers[''] = parser
70
+ this.customParsers.set('', parser)
69
71
  } else {
70
72
  if (contentTypeIsString) {
71
- this.parserList.unshift(contentType)
73
+ this.parserList.unshift(new ParserListItem(contentType))
72
74
  } else {
73
75
  this.parserRegExpList.unshift(contentType)
74
76
  }
75
- this.customParsers[contentType] = parser
77
+ this.customParsers.set(contentType.toString(), parser)
76
78
  }
77
79
  }
78
80
 
79
81
  ContentTypeParser.prototype.hasParser = function (contentType) {
80
- return contentType in this.customParsers
82
+ return this.customParsers.has(typeof contentType === 'string' ? contentType : contentType.toString())
81
83
  }
82
84
 
83
85
  ContentTypeParser.prototype.existingParser = function (contentType) {
84
- if (contentType === 'application/json') {
85
- return this.customParsers['application/json'] && this.customParsers['application/json'].fn !== this[kDefaultJsonParse]
86
+ if (contentType === 'application/json' && this.customParsers.has(contentType)) {
87
+ return this.customParsers.get(contentType).fn !== this[kDefaultJsonParse]
86
88
  }
87
- if (contentType === 'text/plain') {
88
- return this.customParsers['text/plain'] && this.customParsers['text/plain'].fn !== defaultPlainTextParser
89
+ if (contentType === 'text/plain' && this.customParsers.has(contentType)) {
90
+ return this.customParsers.get(contentType).fn !== defaultPlainTextParser
89
91
  }
90
92
 
91
- return contentType in this.customParsers
93
+ return this.hasParser(contentType)
92
94
  }
93
95
 
94
96
  ContentTypeParser.prototype.getParser = function (contentType) {
97
+ if (this.hasParser(contentType)) {
98
+ return this.customParsers.get(contentType)
99
+ }
100
+
101
+ const parser = this.cache.get(contentType)
102
+ // TODO not covered by tests, this is a security backport
103
+ /* istanbul ignore next */
104
+ if (parser !== undefined) return parser
105
+
106
+ const parsed = safeParseContentType(contentType)
107
+
108
+ // dummyContentType always the same object
109
+ // we can use === for the comparsion and return early
110
+ if (parsed === dummyContentType) {
111
+ return this.customParsers.get('')
112
+ }
113
+
95
114
  // eslint-disable-next-line no-var
96
115
  for (var i = 0; i !== this.parserList.length; ++i) {
97
- const parserName = this.parserList[i]
98
- if (contentType.indexOf(parserName) > -1) {
99
- const parser = this.customParsers[parserName]
116
+ const parserListItem = this.parserList[i]
117
+ if (compareContentType(parsed, parserListItem)) {
118
+ const parser = this.customParsers.get(parserListItem.name)
119
+ // we set request content-type in cache to reduce parsing of MIME type
100
120
  this.cache.set(contentType, parser)
101
121
  return parser
102
122
  }
@@ -105,18 +125,19 @@ ContentTypeParser.prototype.getParser = function (contentType) {
105
125
  // eslint-disable-next-line no-var
106
126
  for (var j = 0; j !== this.parserRegExpList.length; ++j) {
107
127
  const parserRegExp = this.parserRegExpList[j]
108
- if (parserRegExp.test(contentType)) {
109
- const parser = this.customParsers[parserRegExp]
128
+ if (compareRegExpContentType(contentType, parsed.type, parserRegExp)) {
129
+ const parser = this.customParsers.get(parserRegExp.toString())
130
+ // we set request content-type in cache to reduce parsing of MIME type
110
131
  this.cache.set(contentType, parser)
111
132
  return parser
112
133
  }
113
134
  }
114
135
 
115
- return this.customParsers['']
136
+ return this.customParsers.get('')
116
137
  }
117
138
 
118
139
  ContentTypeParser.prototype.removeAll = function () {
119
- this.customParsers = {}
140
+ this.customParsers = new Map()
120
141
  this.parserRegExpList = []
121
142
  this.parserList = []
122
143
  this.cache = lru(100)
@@ -125,7 +146,7 @@ ContentTypeParser.prototype.removeAll = function () {
125
146
  ContentTypeParser.prototype.remove = function (contentType) {
126
147
  if (!(typeof contentType === 'string' || contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE()
127
148
 
128
- delete this.customParsers[contentType]
149
+ this.customParsers.delete(contentType.toString())
129
150
 
130
151
  const parsers = typeof contentType === 'string' ? this.parserList : this.parserRegExpList
131
152
 
@@ -290,7 +311,7 @@ function Parser (asString, asBuffer, bodyLimit, fn) {
290
311
  function buildContentTypeParser (c) {
291
312
  const contentTypeParser = new ContentTypeParser()
292
313
  contentTypeParser[kDefaultJsonParse] = c[kDefaultJsonParse]
293
- Object.assign(contentTypeParser.customParsers, c.customParsers)
314
+ contentTypeParser.customParsers = new Map(c.customParsers.entries())
294
315
  contentTypeParser.parserList = c.parserList.slice()
295
316
  return contentTypeParser
296
317
  }
@@ -343,6 +364,63 @@ function removeAllContentTypeParsers () {
343
364
  this[kContentTypeParser].removeAll()
344
365
  }
345
366
 
367
+ // dummy here to prevent repeated object creation
368
+ const dummyContentType = { type: '', parameters: Object.create(null) }
369
+
370
+ function safeParseContentType (contentType) {
371
+ try {
372
+ return parseContentType(contentType)
373
+ } catch (err) {
374
+ return dummyContentType
375
+ }
376
+ }
377
+
378
+ function compareContentType (contentType, parserListItem) {
379
+ if (parserListItem.isEssence) {
380
+ // we do essence check
381
+ return contentType.type.indexOf(parserListItem) !== -1
382
+ } else {
383
+ // when the content-type includes parameters
384
+ // we do a full-text search
385
+ // reject essence content-type before checking parameters
386
+ if (contentType.type.indexOf(parserListItem.type) === -1) return false
387
+ for (const key of parserListItem.parameterKeys) {
388
+ // reject when missing parameters
389
+ if (!(key in contentType.parameters)) return false
390
+ // reject when parameters do not match
391
+ if (contentType.parameters[key] !== parserListItem.parameters[key]) return false
392
+ }
393
+ return true
394
+ }
395
+ }
396
+
397
+ function compareRegExpContentType (contentType, essenceMIMEType, regexp) {
398
+ if (regexp.source.indexOf(';') === -1) {
399
+ // we do essence check
400
+ return regexp.test(essenceMIMEType)
401
+ } else {
402
+ // when the content-type includes parameters
403
+ // we do a full-text match
404
+ return regexp.test(contentType)
405
+ }
406
+ }
407
+
408
+ function ParserListItem (contentType) {
409
+ this.name = contentType
410
+ // we pre-calculate all the needed information
411
+ // before content-type comparsion
412
+ const parsed = safeParseContentType(contentType)
413
+ this.type = parsed.type
414
+ this.parameters = parsed.parameters
415
+ this.parameterKeys = Object.keys(parsed.parameters)
416
+ this.isEssence = contentType.indexOf(';') === -1
417
+ }
418
+
419
+ // used in ContentTypeParser.remove
420
+ ParserListItem.prototype.toString = function () {
421
+ return this.name
422
+ }
423
+
346
424
  module.exports = ContentTypeParser
347
425
  module.exports.helpers = {
348
426
  buildContentTypeParser,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify",
3
- "version": "3.29.2",
3
+ "version": "3.29.4",
4
4
  "description": "Fast and low overhead web framework, for Node.js",
5
5
  "main": "fastify.js",
6
6
  "type": "commonjs",
@@ -187,6 +187,7 @@
187
187
  "light-my-request": "^4.2.0",
188
188
  "pino": "^6.13.0",
189
189
  "process-warning": "^1.0.0",
190
+ "content-type": "^1.0.4",
190
191
  "proxy-addr": "^2.0.7",
191
192
  "rfdc": "^1.1.4",
192
193
  "secure-json-parse": "^2.0.0",
@@ -181,7 +181,7 @@ test('add', t => {
181
181
  const contentTypeParser = fastify[keys.kContentTypeParser]
182
182
 
183
183
  contentTypeParser.add('*', {}, first)
184
- t.equal(contentTypeParser.customParsers[''].fn, first)
184
+ t.equal(contentTypeParser.customParsers.get('').fn, first)
185
185
  })
186
186
 
187
187
  t.end()
@@ -239,7 +239,7 @@ test('remove', t => {
239
239
 
240
240
  contentTypeParser.remove('image/png')
241
241
 
242
- t.same(Object.keys(contentTypeParser.customParsers).length, 2)
242
+ t.same(contentTypeParser.customParsers.size, 2)
243
243
  })
244
244
 
245
245
  t.end()
@@ -262,3 +262,283 @@ test('remove all should remove all existing parsers and reset cache', t => {
262
262
  t.same(contentTypeParser.parserRegExpList.length, 0)
263
263
  t.same(Object.keys(contentTypeParser.customParsers).length, 0)
264
264
  })
265
+
266
+ test('Safeguard against malicious content-type / 1', async t => {
267
+ const badNames = Object.getOwnPropertyNames({}.__proto__) // eslint-disable-line
268
+ t.plan(badNames.length)
269
+
270
+ const fastify = Fastify()
271
+
272
+ fastify.post('/', async () => {
273
+ return 'ok'
274
+ })
275
+
276
+ for (const prop of badNames) {
277
+ const response = await fastify.inject({
278
+ method: 'POST',
279
+ path: '/',
280
+ headers: {
281
+ 'content-type': prop
282
+ },
283
+ body: ''
284
+ })
285
+
286
+ t.same(response.statusCode, 415)
287
+ }
288
+ })
289
+
290
+ test('Safeguard against malicious content-type / 2', async t => {
291
+ t.plan(1)
292
+
293
+ const fastify = Fastify()
294
+
295
+ fastify.post('/', async () => {
296
+ return 'ok'
297
+ })
298
+
299
+ const response = await fastify.inject({
300
+ method: 'POST',
301
+ path: '/',
302
+ headers: {
303
+ 'content-type': '\\u0063\\u006fnstructor'
304
+ },
305
+ body: ''
306
+ })
307
+
308
+ t.same(response.statusCode, 415)
309
+ })
310
+
311
+ test('Safeguard against malicious content-type / 3', async t => {
312
+ t.plan(1)
313
+
314
+ const fastify = Fastify()
315
+
316
+ fastify.post('/', async () => {
317
+ return 'ok'
318
+ })
319
+
320
+ const response = await fastify.inject({
321
+ method: 'POST',
322
+ path: '/',
323
+ headers: {
324
+ 'content-type': 'constructor; charset=utf-8'
325
+ },
326
+ body: ''
327
+ })
328
+
329
+ t.same(response.statusCode, 415)
330
+ })
331
+
332
+ test('Safeguard against content-type spoofing - string', async t => {
333
+ t.plan(1)
334
+
335
+ const fastify = Fastify()
336
+ fastify.removeAllContentTypeParsers()
337
+ fastify.addContentTypeParser('text/plain', function (request, body, done) {
338
+ t.pass('should be called')
339
+ done(null, body)
340
+ })
341
+ fastify.addContentTypeParser('application/json', function (request, body, done) {
342
+ t.fail('shouldn\'t be called')
343
+ done(null, body)
344
+ })
345
+
346
+ fastify.post('/', async () => {
347
+ return 'ok'
348
+ })
349
+
350
+ await fastify.inject({
351
+ method: 'POST',
352
+ path: '/',
353
+ headers: {
354
+ 'content-type': 'text/plain; content-type="application/json"'
355
+ },
356
+ body: ''
357
+ })
358
+ })
359
+
360
+ test('Safeguard against content-type spoofing - regexp', async t => {
361
+ t.plan(1)
362
+
363
+ const fastify = Fastify()
364
+ fastify.removeAllContentTypeParsers()
365
+ fastify.addContentTypeParser(/text\/plain/, function (request, body, done) {
366
+ t.pass('should be called')
367
+ done(null, body)
368
+ })
369
+ fastify.addContentTypeParser(/application\/json/, function (request, body, done) {
370
+ t.fail('shouldn\'t be called')
371
+ done(null, body)
372
+ })
373
+
374
+ fastify.post('/', async () => {
375
+ return 'ok'
376
+ })
377
+
378
+ await fastify.inject({
379
+ method: 'POST',
380
+ path: '/',
381
+ headers: {
382
+ 'content-type': 'text/plain; content-type="application/json"'
383
+ },
384
+ body: ''
385
+ })
386
+ })
387
+
388
+ test('content-type match parameters - string 1', async t => {
389
+ t.plan(1)
390
+
391
+ const fastify = Fastify()
392
+ fastify.removeAllContentTypeParsers()
393
+ fastify.addContentTypeParser('text/plain; charset=utf8', function (request, body, done) {
394
+ t.fail('shouldn\'t be called')
395
+ done(null, body)
396
+ })
397
+ fastify.addContentTypeParser('application/json; charset=utf8', function (request, body, done) {
398
+ t.pass('should be called')
399
+ done(null, body)
400
+ })
401
+
402
+ fastify.post('/', async () => {
403
+ return 'ok'
404
+ })
405
+
406
+ await fastify.inject({
407
+ method: 'POST',
408
+ path: '/',
409
+ headers: {
410
+ 'content-type': 'application/json; charset=utf8'
411
+ },
412
+ body: ''
413
+ })
414
+ })
415
+
416
+ test('content-type match parameters - string 2', async t => {
417
+ t.plan(1)
418
+
419
+ const fastify = Fastify()
420
+ fastify.removeAllContentTypeParsers()
421
+ fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) {
422
+ t.pass('should be called')
423
+ done(null, body)
424
+ })
425
+ fastify.addContentTypeParser('text/plain; charset=utf8; foo=bar', function (request, body, done) {
426
+ t.fail('shouldn\'t be called')
427
+ done(null, body)
428
+ })
429
+
430
+ fastify.post('/', async () => {
431
+ return 'ok'
432
+ })
433
+
434
+ await fastify.inject({
435
+ method: 'POST',
436
+ path: '/',
437
+ headers: {
438
+ 'content-type': 'application/json; foo=bar; charset=utf8'
439
+ },
440
+ body: ''
441
+ })
442
+ })
443
+
444
+ test('content-type match parameters - regexp', async t => {
445
+ t.plan(1)
446
+
447
+ const fastify = Fastify()
448
+ fastify.removeAllContentTypeParsers()
449
+ fastify.addContentTypeParser(/application\/json; charset=utf8/, function (request, body, done) {
450
+ t.pass('should be called')
451
+ done(null, body)
452
+ })
453
+
454
+ fastify.post('/', async () => {
455
+ return 'ok'
456
+ })
457
+
458
+ await fastify.inject({
459
+ method: 'POST',
460
+ path: '/',
461
+ headers: {
462
+ 'content-type': 'application/json; charset=utf8'
463
+ },
464
+ body: ''
465
+ })
466
+ })
467
+
468
+ test('content-type fail when parameters not match - string 1', async t => {
469
+ t.plan(1)
470
+
471
+ const fastify = Fastify()
472
+ fastify.removeAllContentTypeParsers()
473
+ fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) {
474
+ t.fail('shouldn\'t be called')
475
+ done(null, body)
476
+ })
477
+
478
+ fastify.post('/', async () => {
479
+ return 'ok'
480
+ })
481
+
482
+ const response = await fastify.inject({
483
+ method: 'POST',
484
+ path: '/',
485
+ headers: {
486
+ 'content-type': 'application/json; charset=utf8'
487
+ },
488
+ body: ''
489
+ })
490
+
491
+ t.same(response.statusCode, 415)
492
+ })
493
+
494
+ test('content-type fail when parameters not match - string 2', async t => {
495
+ t.plan(1)
496
+
497
+ const fastify = Fastify()
498
+ fastify.removeAllContentTypeParsers()
499
+ fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) {
500
+ t.fail('shouldn\'t be called')
501
+ done(null, body)
502
+ })
503
+
504
+ fastify.post('/', async () => {
505
+ return 'ok'
506
+ })
507
+
508
+ const response = await fastify.inject({
509
+ method: 'POST',
510
+ path: '/',
511
+ headers: {
512
+ 'content-type': 'application/json; charset=utf8; foo=baz'
513
+ },
514
+ body: ''
515
+ })
516
+
517
+ t.same(response.statusCode, 415)
518
+ })
519
+
520
+ test('content-type fail when parameters not match - regexp', async t => {
521
+ t.plan(1)
522
+
523
+ const fastify = Fastify()
524
+ fastify.removeAllContentTypeParsers()
525
+ fastify.addContentTypeParser(/application\/json; charset=utf8; foo=bar/, function (request, body, done) {
526
+ t.fail('shouldn\'t be called')
527
+ done(null, body)
528
+ })
529
+
530
+ fastify.post('/', async () => {
531
+ return 'ok'
532
+ })
533
+
534
+ const response = await fastify.inject({
535
+ method: 'POST',
536
+ path: '/',
537
+ headers: {
538
+ 'content-type': 'application/json; charset=utf8'
539
+ },
540
+ body: ''
541
+ })
542
+
543
+ t.same(response.statusCode, 415)
544
+ })
@@ -1120,7 +1120,7 @@ test('The charset should not interfere with the content type handling', t => {
1120
1120
  url: 'http://localhost:' + fastify.server.address().port,
1121
1121
  body: '{"hello":"world"}',
1122
1122
  headers: {
1123
- 'Content-Type': 'application/json charset=utf-8'
1123
+ 'Content-Type': 'application/json; charset=utf-8'
1124
1124
  }
1125
1125
  }, (err, response, body) => {
1126
1126
  t.error(err)
@@ -1303,7 +1303,7 @@ test('contentTypeParser should add a custom parser with RegExp value', t => {
1303
1303
  url: 'http://localhost:' + fastify.server.address().port,
1304
1304
  body: '{"hello":"world"}',
1305
1305
  headers: {
1306
- 'Content-Type': 'weird-content-type+json'
1306
+ 'Content-Type': 'weird/content-type+json'
1307
1307
  }
1308
1308
  }, (err, response, body) => {
1309
1309
  t.error(err)
@@ -1333,7 +1333,7 @@ test('contentTypeParser should add multiple custom parsers with RegExp values',
1333
1333
  done(null, 'xml')
1334
1334
  })
1335
1335
 
1336
- fastify.addContentTypeParser(/.*\+myExtension$/, function (req, payload, done) {
1336
+ fastify.addContentTypeParser(/.*\+myExtension$/i, function (req, payload, done) {
1337
1337
  let data = ''
1338
1338
  payload.on('data', chunk => { data += chunk })
1339
1339
  payload.on('end', () => {