CVE-2022-39288 (published 2022-10-10T21:15Z)

fastify is a fast and low overhead web framework, for Node.js. Affected versions of fastify are subject to a denial of service via malicious use of the Content-Type header. An attacker can send an invalid Content-Type header that can cause the application to crash. This issue has been addressed in commit `fbb07e8d` and will be included in release version 4.8.1. Users are advised to upgrade. Users unable to upgrade may manually filter out http content with malicious Content-Type headers.

CVSS v3 base score: 7.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)

Weakness information:
cwe_id: CWE-754
cwe_name: Improper Check for Unusual or Exceptional Conditions
description: The product does not check or incorrectly checks for unusual or exceptional conditions that are not expected to occur frequently during day to day operation of the product.
extended_description: 
            The programmer may assume that certain events or conditions will never occur or do not need to be worried about, such as low memory conditions, lack of access to resources due to restrictive permissions, or misbehaving clients or components. However, attackers may intentionally trigger these unusual conditions, thus violating the programmer's assumptions, possibly introducing instability, incorrect behavior, or a vulnerability.
            Note that this entry is not exclusively about the use of exceptions and exception handling, which are mechanisms for both checking and handling unusual or unexpected conditions.
         
         

Fix information:
repo_url: https://github.com/fastify/fastify
commit_details: repo_url: https://github.com/fastify/fastify
author: Tomas Della Vedova
author_date: 2022-10-10 09:24:46+02:00
msg: Merge pull request from GHSA-455w-c45v-86rg

* Add safeguard against malicious content types

* Update lib/contentTypeParser.js

Co-authored-by: Manuel Spigolon <behemoth89@gmail.com>

* Apply spelling fixes from code review

Co-authored-by: James Sumners <james@sumners.email>

Co-authored-by: Manuel Spigolon <behemoth89@gmail.com>
Co-authored-by: Frazer Smith <frazer.dev@outlook.com>
Co-authored-by: James Sumners <james@sumners.email>
repository: repo_url: https://github.com/fastify/fastify
repo_name: fastify/fastify
description: Fast and low overhead web framework, for Node.js
repo_language: JavaScript
owner: fastify
file_changes: filename: contentTypeParser.js
change_type: ModificationType.MODIFY
diff: @@ -29,9 +29,10 @@ const {
 
 function ContentTypeParser (bodyLimit, onProtoPoisoning, onConstructorPoisoning) {
   this[kDefaultJsonParse] = getDefaultJsonParser(onProtoPoisoning, onConstructorPoisoning)
-  this.customParsers = {}
-  this.customParsers['application/json'] = new Parser(true, false, bodyLimit, this[kDefaultJsonParse])
-  this.customParsers['text/plain'] = new Parser(true, false, bodyLimit, defaultPlainTextParser)
+  // using a map instead of a plain object to avoid prototype hijack attacks
+  this.customParsers = new Map()
+  this.customParsers.set('application/json', new Parser(true, false, bodyLimit, this[kDefaultJsonParse]))
+  this.customParsers.set('text/plain', new Parser(true, false, bodyLimit, defaultPlainTextParser))
   this.parserList = ['application/json', 'text/plain']
   this.parserRegExpList = []
   this.cache = lru(100)
@@ -62,35 +63,35 @@ ContentTypeParser.prototype.add = function (contentType, opts, parserFn) {
   )
 
   if (contentTypeIsString && contentType === '*') {
-    this.customParsers[''] = parser
+    this.customParsers.set('', parser)
   } else {
     if (contentTypeIsString) {
       this.parserList.unshift(contentType)
     } else {
       this.parserRegExpList.unshift(contentType)
     }
-    this.customParsers[contentType] = parser
+    this.customParsers.set(contentType.toString(), parser)
   }
 }
 
 ContentTypeParser.prototype.hasParser = function (contentType) {
-  return contentType in this.customParsers
+  return this.customParsers.has(typeof contentType === 'string' ? contentType : contentType.toString())
 }
 
 ContentTypeParser.prototype.existingParser = function (contentType) {
-  if (contentType === 'application/json') {
-    return this.customParsers['application/json'] && this.customParsers['application/json'].fn !== this[kDefaultJsonParse]
+  if (contentType === 'application/json' && this.customParsers.has(contentType)) {
+    return this.customParsers.get(contentType).fn !== this[kDefaultJsonParse]
   }
-  if (contentType === 'text/plain') {
-    return this.customParsers['text/plain'] && this.customParsers['text/plain'].fn !== defaultPlainTextParser
+  if (contentType === 'text/plain' && this.customParsers.has(contentType)) {
+    return this.customParsers.get(contentType).fn !== defaultPlainTextParser
   }
 
-  return contentType in this.customParsers
+  return this.hasParser(contentType)
 }
 
 ContentTypeParser.prototype.getParser = function (contentType) {
-  if (contentType in this.customParsers) {
-    return this.customParsers[contentType]
+  if (this.hasParser(contentType)) {
+    return this.customParsers.get(contentType)
   }
 
   if (this.cache.has(contentType)) {
@@ -101,7 +102,7 @@ ContentTypeParser.prototype.getParser = function (contentType) {
   for (var i = 0; i !== this.parserList.length; ++i) {
     const parserName = this.parserList[i]
     if (contentType.indexOf(parserName) !== -1) {
-      const parser = this.customParsers[parserName]
+      const parser = this.customParsers.get(parserName)
       this.cache.set(contentType, parser)
       return parser
     }
@@ -111,17 +112,17 @@ ContentTypeParser.prototype.getParser = function (contentType) {
   for (var j = 0; j !== this.parserRegExpList.length; ++j) {
     const parserRegExp = this.parserRegExpList[j]
     if (parserRegExp.test(contentType)) {
-      const parser = this.customParsers[parserRegExp]
+      const parser = this.customParsers.get(parserRegExp.toString())
       this.cache.set(contentType, parser)
       return parser
     }
   }
 
-  return this.customParsers['']
+  return this.customParsers.get('')
 }
 
 ContentTypeParser.prototype.removeAll = function () {
-  this.customParsers = {}
+  this.customParsers = new Map()
   this.parserRegExpList = []
   this.parserList = []
   this.cache = lru(100)
@@ -130,7 +131,7 @@ ContentTypeParser.prototype.removeAll = function () {
 ContentTypeParser.prototype.remove = function (contentType) {
   if (!(typeof contentType === 'string' || contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE()
 
-  delete this.customParsers[contentType]
+  this.customParsers.delete(contentType.toString())
 
   const parsers = typeof contentType === 'string' ? this.parserList : this.parserRegExpList
 
@@ -289,7 +290,7 @@ function Parser (asString, asBuffer, bodyLimit, fn) {
 function buildContentTypeParser (c) {
   const contentTypeParser = new ContentTypeParser()
   contentTypeParser[kDefaultJsonParse] = c[kDefaultJsonParse]
-  Object.assign(contentTypeParser.customParsers, c.customParsers)
+  contentTypeParser.customParsers = new Map(c.customParsers.entries())
   contentTypeParser.parserList = c.parserList.slice()
   return contentTypeParser
 }

programming_language: JavaScript
filename: content-parser.test.js
change_type: ModificationType.MODIFY
diff: @@ -196,7 +196,7 @@ test('add', t => {
     const contentTypeParser = fastify[keys.kContentTypeParser]
 
     contentTypeParser.add('*', {}, first)
-    t.equal(contentTypeParser.customParsers[''].fn, first)
+    t.equal(contentTypeParser.customParsers.get('').fn, first)
   })
 
   t.end()
@@ -306,7 +306,7 @@ test('remove', t => {
 
     contentTypeParser.remove('image/png')
 
-    t.same(Object.keys(contentTypeParser.customParsers).length, 2)
+    t.same(contentTypeParser.customParsers.size, 2)
   })
 
   t.end()
@@ -329,3 +329,69 @@ test('remove all should remove all existing parsers and reset cache', t => {
   t.same(contentTypeParser.parserRegExpList.length, 0)
   t.same(Object.keys(contentTypeParser.customParsers).length, 0)
 })
+
+test('Safeguard against malicious content-type / 1', async t => {
+  const badNames = Object.getOwnPropertyNames({}.__proto__) // eslint-disable-line
+  t.plan(badNames.length)
+
+  const fastify = Fastify()
+
+  fastify.post('/', async () => {
+    return 'ok'
+  })
+
+  for (const prop of badNames) {
+    const response = await fastify.inject({
+      method: 'POST',
+      path: '/',
+      headers: {
+        'content-type': prop
+      },
+      body: ''
+    })
+
+    t.same(response.statusCode, 415)
+  }
+})
+
+test('Safeguard against malicious content-type / 2', async t => {
+  t.plan(1)
+
+  const fastify = Fastify()
+
+  fastify.post('/', async () => {
+    return 'ok'
+  })
+
+  const response = await fastify.inject({
+    method: 'POST',
+    path: '/',
+    headers: {
+      'content-type': '\\u0063\\u006fnstructor'
+    },
+    body: ''
+  })
+
+  t.same(response.statusCode, 415)
+})
+
+test('Safeguard against malicious content-type / 3', async t => {
+  t.plan(1)
+
+  const fastify = Fastify()
+
+  fastify.post('/', async () => {
+    return 'ok'
+  })
+
+  const response = await fastify.inject({
+    method: 'POST',
+    path: '/',
+    headers: {
+      'content-type': 'constructor; charset=utf-8'
+    },
+    body: ''
+  })
+
+  t.same(response.statusCode, 415)
+})

programming_language: JavaScript