CVE-2022-41919: Cross-Site Request Forgery (CSRF) (CWE-352)
value: Fastify is a web framework with minimal overhead and plugin architecture. The attacker can use the incorrect `Content-Type` to bypass the `Pre-Flight` checking of `fetch`. `fetch()` requests with Content-Type’s essence as "application/x-www-form-urlencoded", "multipart/form-data", or "text/plain", could potentially be used to invoke routes that only accepts `application/json` content type, thus bypassing any CORS protection, and therefore they could lead to a Cross-Site Request Forgery attack. This issue has been patched in version 4.10.2 and 3.29.4. As a workaround, implement Cross-Site Request Forgery protection using `@fastify/csrf'.
CWE description: The web application does not, or can not, sufficiently verify whether a well-formed, valid, consistent request was intentionally provided by the user who submitted the request.
CVSS v3 base score: 8.8
Published: 2022-11-22T20:15Z
Repository: https://github.com/fastify/fastify
Language: JavaScript
Files changed: lib/contentTypeParser.js, package.json, test/content-parser.test.js, test/custom-parser.test.js
Commit date: 2022-11-21 15:39:50 +0100
Commit message: Merge pull request from GHSA-3fjj-p79j-c9hh

* fix: content-type spoofing

* refactor: improve performance
Diff: diff --git a/lib/contentTypeParser.js b/lib/contentTypeParser.js
index 3326e192..b32cc098 100644
--- a/lib/contentTypeParser.js
+++ b/lib/contentTypeParser.js
@@ -1,9 +1,11 @@
 'use strict'
 
 const { AsyncResource } = require('async_hooks')
 const lru = require('tiny-lru').lru
+// TODO: find more perforamant solution
+const { parse: parseContentType } = require('content-type')
 
 const secureJson = require('secure-json-parse')
 const {
   kDefaultJsonParse,
   kContentTypeParser,
@@ -31,11 +33,11 @@ function ContentTypeParser (bodyLimit, onProtoPoisoning, onConstructorPoisoning)
   this[kDefaultJsonParse] = getDefaultJsonParser(onProtoPoisoning, onConstructorPoisoning)
   // 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.parserList = [new ParserListItem('application/json'), new ParserListItem('text/plain')]
   this.parserRegExpList = []
   this.cache = lru(100)
 }
 
 ContentTypeParser.prototype.add = function (contentType, opts, parserFn) {
@@ -64,11 +66,11 @@ ContentTypeParser.prototype.add = function (contentType, opts, parserFn) {
 
   if (contentTypeIsString && contentType === '*') {
     this.customParsers.set('', parser)
   } else {
     if (contentTypeIsString) {
-      this.parserList.unshift(contentType)
+      this.parserList.unshift(new ParserListItem(contentType))
     } else {
       this.parserRegExpList.unshift(contentType)
     }
     this.customParsers.set(contentType.toString(), parser)
   }
@@ -95,25 +97,35 @@ ContentTypeParser.prototype.getParser = function (contentType) {
   }
 
   const parser = this.cache.get(contentType)
   if (parser !== undefined) return parser
 
+  const parsed = safeParseContentType(contentType)
+
+  // dummyContentType always the same object
+  // we can use === for the comparsion and return early
+  if (parsed === dummyContentType) {
+    return this.customParsers.get('')
+  }
+
   // 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.get(parserName)
+    const parserListItem = this.parserList[i]
+    if (compareContentType(parsed, parserListItem)) {
+      const parser = this.customParsers.get(parserListItem.name)
+      // we set request content-type in cache to reduce parsing of MIME type
       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)) {
+    if (compareRegExpContentType(contentType, parsed.type, parserRegExp)) {
       const parser = this.customParsers.get(parserRegExp.toString())
+      // we set request content-type in cache to reduce parsing of MIME type
       this.cache.set(contentType, parser)
       return parser
     }
   }
 
@@ -344,10 +356,67 @@ function removeAllContentTypeParsers () {
   }
 
   this[kContentTypeParser].removeAll()
 }
 
+// dummy here to prevent repeated object creation
+const dummyContentType = { type: '', parameters: Object.create(null) }
+
+function safeParseContentType (contentType) {
+  try {
+    return parseContentType(contentType)
+  } catch (err) {
+    return dummyContentType
+  }
+}
+
+function compareContentType (contentType, parserListItem) {
+  if (parserListItem.isEssence) {
+    // we do essence check
+    return contentType.type.indexOf(parserListItem) !== -1
+  } else {
+    // when the content-type includes parameters
+    // we do a full-text search
+    // reject essence content-type before checking parameters
+    if (contentType.type.indexOf(parserListItem.type) === -1) return false
+    for (const key of parserListItem.parameterKeys) {
+      // reject when missing parameters
+      if (!(key in contentType.parameters)) return false
+      // reject when parameters do not match
+      if (contentType.parameters[key] !== parserListItem.parameters[key]) return false
+    }
+    return true
+  }
+}
+
+function compareRegExpContentType (contentType, essenceMIMEType, regexp) {
+  if (regexp.source.indexOf(';') === -1) {
+    // we do essence check
+    return regexp.test(essenceMIMEType)
+  } else {
+    // when the content-type includes parameters
+    // we do a full-text match
+    return regexp.test(contentType)
+  }
+}
+
+function ParserListItem (contentType) {
+  this.name = contentType
+  // we pre-calculate all the needed information
+  // before content-type comparsion
+  const parsed = safeParseContentType(contentType)
+  this.type = parsed.type
+  this.parameters = parsed.parameters
+  this.parameterKeys = Object.keys(parsed.parameters)
+  this.isEssence = contentType.indexOf(';') === -1
+}
+
+// used in ContentTypeParser.remove
+ParserListItem.prototype.toString = function () {
+  return this.name
+}
+
 module.exports = ContentTypeParser
 module.exports.helpers = {
   buildContentTypeParser,
   addContentTypeParser,
   hasContentTypeParser,
diff --git a/package.json b/package.json
index 386b3fdb..d213dd99 100644
--- a/package.json
+++ b/package.json
@@ -174,10 +174,11 @@
     "@fastify/ajv-compiler": "^3.3.1",
     "@fastify/error": "^3.0.0",
     "@fastify/fast-json-stringify-compiler": "^4.1.0",
     "abstract-logging": "^2.0.1",
     "avvio": "^8.2.0",
+    "content-type": "^1.0.4",
     "find-my-way": "^7.3.0",
     "light-my-request": "^5.6.1",
     "pino": "^8.5.0",
     "process-warning": "^2.0.0",
     "proxy-addr": "^2.0.7",
diff --git a/test/content-parser.test.js b/test/content-parser.test.js
index fd24d195..edf7d18b 100644
--- a/test/content-parser.test.js
+++ b/test/content-parser.test.js
@@ -393,5 +393,219 @@ test('Safeguard against malicious content-type / 3', async t => {
     body: ''
   })
 
   t.same(response.statusCode, 415)
 })
+
+test('Safeguard against content-type spoofing - string', async t => {
+  t.plan(1)
+
+  const fastify = Fastify()
+  fastify.removeAllContentTypeParsers()
+  fastify.addContentTypeParser('text/plain', function (request, body, done) {
+    t.pass('should be called')
+    done(null, body)
+  })
+  fastify.addContentTypeParser('application/json', function (request, body, done) {
+    t.fail('shouldn\'t be called')
+    done(null, body)
+  })
+
+  fastify.post('/', async () => {
+    return 'ok'
+  })
+
+  await fastify.inject({
+    method: 'POST',
+    path: '/',
+    headers: {
+      'content-type': 'text/plain; content-type="application/json"'
+    },
+    body: ''
+  })
+})
+
+test('Safeguard against content-type spoofing - regexp', async t => {
+  t.plan(1)
+
+  const fastify = Fastify()
+  fastify.removeAllContentTypeParsers()
+  fastify.addContentTypeParser(/text\/plain/, function (request, body, done) {
+    t.pass('should be called')
+    done(null, body)
+  })
+  fastify.addContentTypeParser(/application\/json/, function (request, body, done) {
+    t.fail('shouldn\'t be called')
+    done(null, body)
+  })
+
+  fastify.post('/', async () => {
+    return 'ok'
+  })
+
+  await fastify.inject({
+    method: 'POST',
+    path: '/',
+    headers: {
+      'content-type': 'text/plain; content-type="application/json"'
+    },
+    body: ''
+  })
+})
+
+test('content-type match parameters - string 1', async t => {
+  t.plan(1)
+
+  const fastify = Fastify()
+  fastify.removeAllContentTypeParsers()
+  fastify.addContentTypeParser('text/plain; charset=utf8', function (request, body, done) {
+    t.fail('shouldn\'t be called')
+    done(null, body)
+  })
+  fastify.addContentTypeParser('application/json; charset=utf8', function (request, body, done) {
+    t.pass('should be called')
+    done(null, body)
+  })
+
+  fastify.post('/', async () => {
+    return 'ok'
+  })
+
+  await fastify.inject({
+    method: 'POST',
+    path: '/',
+    headers: {
+      'content-type': 'application/json; charset=utf8'
+    },
+    body: ''
+  })
+})
+
+test('content-type match parameters - string 2', async t => {
+  t.plan(1)
+
+  const fastify = Fastify()
+  fastify.removeAllContentTypeParsers()
+  fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) {
+    t.pass('should be called')
+    done(null, body)
+  })
+  fastify.addContentTypeParser('text/plain; charset=utf8; foo=bar', function (request, body, done) {
+    t.fail('shouldn\'t be called')
+    done(null, body)
+  })
+
+  fastify.post('/', async () => {
+    return 'ok'
+  })
+
+  await fastify.inject({
+    method: 'POST',
+    path: '/',
+    headers: {
+      'content-type': 'application/json; foo=bar; charset=utf8'
+    },
+    body: ''
+  })
+})
+
+test('content-type match parameters - regexp', async t => {
+  t.plan(1)
+
+  const fastify = Fastify()
+  fastify.removeAllContentTypeParsers()
+  fastify.addContentTypeParser(/application\/json; charset=utf8/, function (request, body, done) {
+    t.pass('should be called')
+    done(null, body)
+  })
+
+  fastify.post('/', async () => {
+    return 'ok'
+  })
+
+  await fastify.inject({
+    method: 'POST',
+    path: '/',
+    headers: {
+      'content-type': 'application/json; charset=utf8'
+    },
+    body: ''
+  })
+})
+
+test('content-type fail when parameters not match - string 1', async t => {
+  t.plan(1)
+
+  const fastify = Fastify()
+  fastify.removeAllContentTypeParsers()
+  fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) {
+    t.fail('shouldn\'t be called')
+    done(null, body)
+  })
+
+  fastify.post('/', async () => {
+    return 'ok'
+  })
+
+  const response = await fastify.inject({
+    method: 'POST',
+    path: '/',
+    headers: {
+      'content-type': 'application/json; charset=utf8'
+    },
+    body: ''
+  })
+
+  t.same(response.statusCode, 415)
+})
+
+test('content-type fail when parameters not match - string 2', async t => {
+  t.plan(1)
+
+  const fastify = Fastify()
+  fastify.removeAllContentTypeParsers()
+  fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) {
+    t.fail('shouldn\'t be called')
+    done(null, body)
+  })
+
+  fastify.post('/', async () => {
+    return 'ok'
+  })
+
+  const response = await fastify.inject({
+    method: 'POST',
+    path: '/',
+    headers: {
+      'content-type': 'application/json; charset=utf8; foo=baz'
+    },
+    body: ''
+  })
+
+  t.same(response.statusCode, 415)
+})
+
+test('content-type fail when parameters not match - regexp', async t => {
+  t.plan(1)
+
+  const fastify = Fastify()
+  fastify.removeAllContentTypeParsers()
+  fastify.addContentTypeParser(/application\/json; charset=utf8; foo=bar/, function (request, body, done) {
+    t.fail('shouldn\'t be called')
+    done(null, body)
+  })
+
+  fastify.post('/', async () => {
+    return 'ok'
+  })
+
+  const response = await fastify.inject({
+    method: 'POST',
+    path: '/',
+    headers: {
+      'content-type': 'application/json; charset=utf8'
+    },
+    body: ''
+  })
+
+  t.same(response.statusCode, 415)
+})
diff --git a/test/custom-parser.test.js b/test/custom-parser.test.js
index 8986216a..a669c321 100644
--- a/test/custom-parser.test.js
+++ b/test/custom-parser.test.js
@@ -1051,11 +1051,11 @@ test('The charset should not interfere with the content type handling', t => {
     sget({
       method: 'POST',
       url: getUrl(fastify),
       body: '{"hello":"world"}',
       headers: {
-        'Content-Type': 'application/json charset=utf-8'
+        'Content-Type': 'application/json; charset=utf-8'
       }
     }, (err, response, body) => {
       t.error(err)
       t.equal(response.statusCode, 200)
       t.equal(body.toString(), '{"hello":"world"}')
@@ -1234,11 +1234,11 @@ test('contentTypeParser should add a custom parser with RegExp value', t => {
       sget({
         method: 'OPTIONS',
         url: getUrl(fastify),
         body: '{"hello":"world"}',
         headers: {
-          'Content-Type': 'weird-content-type+json'
+          'Content-Type': 'weird/content-type+json'
         }
       }, (err, response, body) => {
         t.error(err)
         t.equal(response.statusCode, 200)
         t.same(body.toString(), JSON.stringify({ hello: 'world' }))
@@ -1264,11 +1264,11 @@ test('contentTypeParser should add multiple custom parsers with RegExp values',
 
   fastify.addContentTypeParser(/.*\+xml$/, function (req, payload, done) {
     done(null, 'xml')
   })
 
-  fastify.addContentTypeParser(/.*\+myExtension$/, function (req, payload, done) {
+  fastify.addContentTypeParser(/.*\+myExtension$/i, function (req, payload, done) {
     let data = ''
     payload.on('data', chunk => { data += chunk })
     payload.on('end', () => {
       done(null, data + 'myExtension')
     })

Vulnerable code:   this.parserList = ['application/json', 'text/plain']
      this.parserList.unshift(contentType)
    const parserName = this.parserList[i]
    if (contentType.indexOf(parserName) !== -1) {
      const parser = this.customParsers.get(parserName)
    if (parserRegExp.test(contentType)) {
        'Content-Type': 'application/json charset=utf-8'
          'Content-Type': 'weird-content-type+json'
  fastify.addContentTypeParser(/.*\+myExtension$/, function (req, payload, done) {
Fixed code: // TODO: find more perforamant solution
const { parse: parseContentType } = require('content-type')
  this.parserList = [new ParserListItem('application/json'), new ParserListItem('text/plain')]
      this.parserList.unshift(new ParserListItem(contentType))
  const parsed = safeParseContentType(contentType)

  // dummyContentType always the same object
  // we can use === for the comparsion and return early
  if (parsed === dummyContentType) {
    return this.customParsers.get('')
  }

    const parserListItem = this.parserList[i]
    if (compareContentType(parsed, parserListItem)) {
      const parser = this.customParsers.get(parserListItem.name)
      // we set request content-type in cache to reduce parsing of MIME type
    if (compareRegExpContentType(contentType, parsed.type, parserRegExp)) {
      // we set request content-type in cache to reduce parsing of MIME type
// dummy here to prevent repeated object creation
const dummyContentType = { type: '', parameters: Object.create(null) }

function safeParseContentType (contentType) {
  try {
    return parseContentType(contentType)
  } catch (err) {
    return dummyContentType
  }
}

function compareContentType (contentType, parserListItem) {
  if (parserListItem.isEssence) {
    // we do essence check
    return contentType.type.indexOf(parserListItem) !== -1
  } else {
    // when the content-type includes parameters
    // we do a full-text search
    // reject essence content-type before checking parameters
    if (contentType.type.indexOf(parserListItem.type) === -1) return false
    for (const key of parserListItem.parameterKeys) {
      // reject when missing parameters
      if (!(key in contentType.parameters)) return false
      // reject when parameters do not match
      if (contentType.parameters[key] !== parserListItem.parameters[key]) return false
    }
    return true
  }
}

function compareRegExpContentType (contentType, essenceMIMEType, regexp) {
  if (regexp.source.indexOf(';') === -1) {
    // we do essence check
    return regexp.test(essenceMIMEType)
  } else {
    // when the content-type includes parameters
    // we do a full-text match
    return regexp.test(contentType)
  }
}

function ParserListItem (contentType) {
  this.name = contentType
  // we pre-calculate all the needed information
  // before content-type comparsion
  const parsed = safeParseContentType(contentType)
  this.type = parsed.type
  this.parameters = parsed.parameters
  this.parameterKeys = Object.keys(parsed.parameters)
  this.isEssence = contentType.indexOf(';') === -1
}

// used in ContentTypeParser.remove
ParserListItem.prototype.toString = function () {
  return this.name
}

    "content-type": "^1.0.4",

test('Safeguard against content-type spoofing - string', async t => {
  t.plan(1)

  const fastify = Fastify()
  fastify.removeAllContentTypeParsers()
  fastify.addContentTypeParser('text/plain', function (request, body, done) {
    t.pass('should be called')
    done(null, body)
  })
  fastify.addContentTypeParser('application/json', function (request, body, done) {
    t.fail('shouldn\'t be called')
    done(null, body)
  })

  fastify.post('/', async () => {
    return 'ok'
  })

  await fastify.inject({
    method: 'POST',
    path: '/',
    headers: {
      'content-type': 'text/plain; content-type="application/json"'
    },
    body: ''
  })
})

test('Safeguard against content-type spoofing - regexp', async t => {
  t.plan(1)

  const fastify = Fastify()
  fastify.removeAllContentTypeParsers()
  fastify.addContentTypeParser(/text\/plain/, function (request, body, done) {
    t.pass('should be called')
    done(null, body)
  })
  fastify.addContentTypeParser(/application\/json/, function (request, body, done) {
    t.fail('shouldn\'t be called')
    done(null, body)
  })

  fastify.post('/', async () => {
    return 'ok'
  })

  await fastify.inject({
    method: 'POST',
    path: '/',
    headers: {
      'content-type': 'text/plain; content-type="application/json"'
    },
    body: ''
  })
})

test('content-type match parameters - string 1', async t => {
  t.plan(1)

  const fastify = Fastify()
  fastify.removeAllContentTypeParsers()
  fastify.addContentTypeParser('text/plain; charset=utf8', function (request, body, done) {
    t.fail('shouldn\'t be called')
    done(null, body)
  })
  fastify.addContentTypeParser('application/json; charset=utf8', function (request, body, done) {
    t.pass('should be called')
    done(null, body)
  })

  fastify.post('/', async () => {
    return 'ok'
  })

  await fastify.inject({
    method: 'POST',
    path: '/',
    headers: {
      'content-type': 'application/json; charset=utf8'
    },
    body: ''
  })
})

test('content-type match parameters - string 2', async t => {
  t.plan(1)

  const fastify = Fastify()
  fastify.removeAllContentTypeParsers()
  fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) {
    t.pass('should be called')
    done(null, body)
  })
  fastify.addContentTypeParser('text/plain; charset=utf8; foo=bar', function (request, body, done) {
    t.fail('shouldn\'t be called')
    done(null, body)
  })

  fastify.post('/', async () => {
    return 'ok'
  })

  await fastify.inject({
    method: 'POST',
    path: '/',
    headers: {
      'content-type': 'application/json; foo=bar; charset=utf8'
    },
    body: ''
  })
})

test('content-type match parameters - regexp', async t => {
  t.plan(1)

  const fastify = Fastify()
  fastify.removeAllContentTypeParsers()
  fastify.addContentTypeParser(/application\/json; charset=utf8/, function (request, body, done) {
    t.pass('should be called')
    done(null, body)
  })

  fastify.post('/', async () => {
    return 'ok'
  })

  await fastify.inject({
    method: 'POST',
    path: '/',
    headers: {
      'content-type': 'application/json; charset=utf8'
    },
    body: ''
  })
})

test('content-type fail when parameters not match - string 1', async t => {
  t.plan(1)

  const fastify = Fastify()
  fastify.removeAllContentTypeParsers()
  fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) {
    t.fail('shouldn\'t be called')
    done(null, body)
  })

  fastify.post('/', async () => {
    return 'ok'
  })

  const response = await fastify.inject({
    method: 'POST',
    path: '/',
    headers: {
      'content-type': 'application/json; charset=utf8'
    },
    body: ''
  })

  t.same(response.statusCode, 415)
})

test('content-type fail when parameters not match - string 2', async t => {
  t.plan(1)

  const fastify = Fastify()
  fastify.removeAllContentTypeParsers()
  fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) {
    t.fail('shouldn\'t be called')
    done(null, body)
  })

  fastify.post('/', async () => {
    return 'ok'
  })

  const response = await fastify.inject({
    method: 'POST',
    path: '/',
    headers: {
      'content-type': 'application/json; charset=utf8; foo=baz'
    },
    body: ''
  })

  t.same(response.statusCode, 415)
})

test('content-type fail when parameters not match - regexp', async t => {
  t.plan(1)

  const fastify = Fastify()
  fastify.removeAllContentTypeParsers()
  fastify.addContentTypeParser(/application\/json; charset=utf8; foo=bar/, function (request, body, done) {
    t.fail('shouldn\'t be called')
    done(null, body)
  })

  fastify.post('/', async () => {
    return 'ok'
  })

  const response = await fastify.inject({
    method: 'POST',
    path: '/',
    headers: {
      'content-type': 'application/json; charset=utf8'
    },
    body: ''
  })

  t.same(response.statusCode, 415)
})
        'Content-Type': 'application/json; charset=utf-8'
          'Content-Type': 'weird/content-type+json'
  fastify.addContentTypeParser(/.*\+myExtension$/i, function (req, payload, done) {