CVE-2022-41919 (JavaScript, published 2022-11-22T20:15Z)
Description: 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'.
Severity: nan (CVSS v3 base score: 8.8)
Weakness: CWE-352 \u2014 Cross-Site Request Forgery (CSRF)
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.
Repository: https://github.com/fastify/fastify
Files changed:
lib/contentTypeParser.js
package.json
test/content-parser.test.js
test/custom-parser.test.js
Commit message: Merge pull request from GHSA-3fjj-p79j-c9hh

* fix: content-type spoofing

* refactor: improve performance

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