CVE-2022-39288: Improper Check for Unusual or Exceptional Conditions (CWE-754)
value: 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.
CWE 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.
CVSS v3 base score: 7.5
Published: 2022-10-10T21:15Z
Repository: https://github.com/fastify/fastify
Language: JavaScript
Files changed: lib/contentTypeParser.js, test/content-parser.test.js
Commit date: 2022-10-10 09:24:46 +0200
Commit message: 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>
Diff: diff --git a/lib/contentTypeParser.js b/lib/contentTypeParser.js
index 44193a77..a1eebeb3 100644
--- a/lib/contentTypeParser.js
+++ b/lib/contentTypeParser.js
@@ -27,13 +27,14 @@ const {
   FST_ERR_CTP_EMPTY_JSON_BODY
 } = require('./errors')
 
 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)
 }
 
@@ -60,79 +61,79 @@ ContentTypeParser.prototype.add = function (contentType, opts, parserFn) {
     opts.bodyLimit,
     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)) {
     return this.cache.get(contentType)
   }
 
   // eslint-disable-next-line no-var
   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
     }
   }
 
   // eslint-disable-next-line no-var
   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)
 }
 
 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
 
   const idx = parsers.findIndex(ct => ct.toString() === contentType.toString())
 
@@ -287,11 +288,11 @@ 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
 }
 
 function addContentTypeParser (contentType, opts, parser) {
diff --git a/test/content-parser.test.js b/test/content-parser.test.js
index 87f79bd4..fd24d195 100644
--- a/test/content-parser.test.js
+++ b/test/content-parser.test.js
@@ -194,11 +194,11 @@ test('add', t => {
 
     const fastify = Fastify()
     const contentTypeParser = fastify[keys.kContentTypeParser]
 
     contentTypeParser.add('*', {}, first)
-    t.equal(contentTypeParser.customParsers[''].fn, first)
+    t.equal(contentTypeParser.customParsers.get('').fn, first)
   })
 
   t.end()
 })
 
@@ -304,11 +304,11 @@ test('remove', t => {
 
     const contentTypeParser = fastify[keys.kContentTypeParser]
 
     contentTypeParser.remove('image/png')
 
-    t.same(Object.keys(contentTypeParser.customParsers).length, 2)
+    t.same(contentTypeParser.customParsers.size, 2)
   })
 
   t.end()
 })
 
@@ -327,5 +327,71 @@ test('remove all should remove all existing parsers and reset cache', t => {
   t.same(contentTypeParser.cache.size, 0)
   t.same(contentTypeParser.parserList.length, 0)
   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)
+})

Vulnerable code:   this.customParsers = {}
  this.customParsers['application/json'] = new Parser(true, false, bodyLimit, this[kDefaultJsonParse])
  this.customParsers['text/plain'] = new Parser(true, false, bodyLimit, defaultPlainTextParser)
    this.customParsers[''] = parser
    this.customParsers[contentType] = parser
  return contentType in this.customParsers
  if (contentType === 'application/json') {
    return this.customParsers['application/json'] && this.customParsers['application/json'].fn !== this[kDefaultJsonParse]
  if (contentType === 'text/plain') {
    return this.customParsers['text/plain'] && this.customParsers['text/plain'].fn !== defaultPlainTextParser
  return contentType in this.customParsers
  if (contentType in this.customParsers) {
    return this.customParsers[contentType]
      const parser = this.customParsers[parserName]
      const parser = this.customParsers[parserRegExp]
  return this.customParsers['']
  this.customParsers = {}
  delete this.customParsers[contentType]
  Object.assign(contentTypeParser.customParsers, c.customParsers)
    t.equal(contentTypeParser.customParsers[''].fn, first)
    t.same(Object.keys(contentTypeParser.customParsers).length, 2)
Fixed code:   // 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.customParsers.set('', parser)
    this.customParsers.set(contentType.toString(), parser)
  return this.customParsers.has(typeof contentType === 'string' ? contentType : contentType.toString())
  if (contentType === 'application/json' && this.customParsers.has(contentType)) {
    return this.customParsers.get(contentType).fn !== this[kDefaultJsonParse]
  if (contentType === 'text/plain' && this.customParsers.has(contentType)) {
    return this.customParsers.get(contentType).fn !== defaultPlainTextParser
  return this.hasParser(contentType)
  if (this.hasParser(contentType)) {
    return this.customParsers.get(contentType)
      const parser = this.customParsers.get(parserName)
      const parser = this.customParsers.get(parserRegExp.toString())
  return this.customParsers.get('')
  this.customParsers = new Map()
  this.customParsers.delete(contentType.toString())
  contentTypeParser.customParsers = new Map(c.customParsers.entries())
    t.equal(contentTypeParser.customParsers.get('').fn, first)
    t.same(contentTypeParser.customParsers.size, 2)

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)
})