CVE-2023-25576: Allocation of Resources Without Limits or Throttling (CWE-770)
value: @fastify/multipart is a Fastify plugin to parse the multipart content-type. Prior to versions 7.4.1 and 6.0.1, @fastify/multipart may experience denial of service due to a number of situations in which an unlimited number of parts are accepted. This includes the multipart body parser accepting an unlimited number of file parts, the multipart body parser accepting an unlimited number of field parts, and the multipart body parser accepting an unlimited number of empty parts as field parts. This is fixed in v7.4.1 (for Fastify v4.x) and v6.0.1 (for Fastify v3.x). There are no known workarounds.
CWE description: The product allocates a reusable resource or group of resources on behalf of an actor without imposing any restrictions on the size or number of resources that can be allocated, in violation of the intended security policy for that actor.
CVSS v3 base score: 7.5
Published: 2023-02-14T16:15Z
Repository: https://github.com/fastify/fastify-multipart
Language: JavaScript
Files changed: README.md, index.js, test/big.test.js, test/legacy/big.test.js, test/multipart-security.test.js
Commit date: 2023-02-14 11:15:48 +0100
Commit message: Merge pull request from GHSA-hpp2-2cr5-pf6g

* lib: implement default values for parts and fileSize

* fixup! lib: implement default values for parts and fileSize
Diff: diff --git a/README.md b/README.md
index 0207b70..fbcde2c 100644
--- a/README.md
+++ b/README.md
@@ -82,14 +82,17 @@ fastify.register(require('@fastify/multipart'), {
     fieldSize: 100,     // Max field value size in bytes
     fields: 10,         // Max number of non-file fields
     fileSize: 1000000,  // For multipart forms, the max file size in bytes
     files: 1,           // Max number of file fields
     headerPairs: 2000   // Max number of header key=>value pairs
+    parts: 1000         // For multipart forms, the max number of parts (fields + files)
   }
 });
 ```
 
+For security reasons, `@fastify/multipart` sets the limit for `parts` and `fileSize` being _1000_ and _1048576_ respectively.
+
 **Note**: if the file stream that is provided by `data.file` is not consumed, like in the example below with the usage of pump, the promise will not be fulfilled at the end of the multipart processing.
 This behavior is inherited from [`@fastify/busboy`](https://github.com/fastify/busboy).
 
 **Note**: if you set a `fileSize` limit and you want to know if the file limit was reached you can:
 - listen to `data.file.on('limit')`
diff --git a/index.js b/index.js
index f498f43..4d46e80 100644
--- a/index.js
+++ b/index.js
@@ -108,10 +108,16 @@ function busboy (options) {
     return errorEmitter
   }
 }
 
 function fastifyMultipart (fastify, options, done) {
+  options.limits = {
+    ...options.limits,
+    parts: options.limits?.parts || 1000,
+    fileSize: options.limits?.fileSize || fastify.initialConfig.bodyLimit
+  }
+
   const attachFieldsToBody = options.attachFieldsToBody
   if (options.addToBody === true) {
     if (typeof options.sharedSchemaId === 'string') {
       fastify.addSchema({
         $id: options.sharedSchemaId,
@@ -358,19 +364,25 @@ function fastifyMultipart (fastify, options, done) {
       .on('error', onEnd)
       .on('end', onEnd)
       .on('finish', onEnd)
 
     bb.on('partsLimit', function () {
-      onError(new PartsLimitError())
+      const err = new PartsLimitError()
+      onError(err)
+      process.nextTick(() => onEnd(err))
     })
 
     bb.on('filesLimit', function () {
-      onError(new FilesLimitError())
+      const err = new FilesLimitError()
+      onError(err)
+      process.nextTick(() => onEnd(err))
     })
 
     bb.on('fieldsLimit', function () {
-      onError(new FieldsLimitError())
+      const err = new FieldsLimitError()
+      onError(err)
+      process.nextTick(() => onEnd(err))
     })
 
     request.pipe(bb)
 
     function onField (name, fieldValue, fieldnameTruncated, valueTruncated, encoding, contentType) {
diff --git a/test/big.test.js b/test/big.test.js
index 32e6756..c104a60 100644
--- a/test/big.test.js
+++ b/test/big.test.js
@@ -18,11 +18,16 @@ test('should upload a big file in constant memory', { skip: process.env.CI }, fu
   const fastify = Fastify()
   const hashInput = crypto.createHash('sha256')
 
   t.teardown(fastify.close.bind(fastify))
 
-  fastify.register(multipart)
+  fastify.register(multipart, {
+    limits: {
+      fileSize: Infinity,
+      parts: Infinity
+    }
+  })
 
   fastify.post('/', async function (req, reply) {
     t.ok(req.isMultipart())
 
     for await (const part of req.parts()) {
diff --git a/test/legacy/big.test.js b/test/legacy/big.test.js
index c06656e..a585ba7 100644
--- a/test/legacy/big.test.js
+++ b/test/legacy/big.test.js
@@ -20,11 +20,16 @@ test('should upload a big file in constant memory', { skip: process.env.CI }, fu
   const hashInput = crypto.createHash('sha256')
   let sent = false
 
   t.teardown(fastify.close.bind(fastify))
 
-  fastify.register(multipart)
+  fastify.register(multipart, {
+    limits: {
+      fileSize: Infinity,
+      parts: Infinity
+    }
+  })
 
   fastify.post('/', function (req, reply) {
     t.ok(req.isMultipart())
 
     req.multipart(handler, function (err) {
diff --git a/test/multipart-security.test.js b/test/multipart-security.test.js
index ab1d2ad..af4dd1d 100644
--- a/test/multipart-security.test.js
+++ b/test/multipart-security.test.js
@@ -5,10 +5,13 @@ const FormData = require('form-data')
 const Fastify = require('fastify')
 const multipart = require('..')
 const http = require('http')
 const path = require('path')
 const fs = require('fs')
+const crypto = require('crypto')
+const EventEmitter = require('events')
+const { once } = EventEmitter
 
 const filePath = path.join(__dirname, '../README.md')
 
 test('should not allow __proto__ as file name', function (t) {
   t.plan(4)
@@ -98,5 +101,108 @@ test('should not allow __proto__ as field name', function (t) {
     form.append('__proto__', 'world')
 
     form.pipe(req)
   })
 })
+
+test('should use default for fileSize', async function (t) {
+  t.plan(4)
+
+  const fastify = Fastify()
+  t.teardown(fastify.close.bind(fastify))
+
+  fastify.register(multipart, {
+    throwFileSizeLimit: true
+  })
+
+  fastify.post('/', async function (req, reply) {
+    t.ok(req.isMultipart())
+
+    const part = await req.file()
+    t.pass('the file is not consumed yet')
+
+    try {
+      await part.toBuffer()
+      t.fail('it should throw')
+    } catch (error) {
+      t.ok(error)
+      reply.send(error)
+    }
+  })
+
+  await fastify.listen({ port: 0 })
+
+  // request
+  const form = new FormData()
+  const opts = {
+    hostname: '127.0.0.1',
+    port: fastify.server.address().port,
+    path: '/',
+    headers: form.getHeaders(),
+    method: 'POST'
+  }
+
+  const randomFileBuffer = Buffer.alloc(15_000_000)
+  crypto.randomFillSync(randomFileBuffer)
+
+  const req = http.request(opts)
+  form.append('upload', randomFileBuffer)
+
+  form.pipe(req)
+
+  try {
+    const [res] = await once(req, 'response')
+    t.equal(res.statusCode, 413)
+    res.resume()
+    await once(res, 'end')
+  } catch (error) {
+    t.error(error, 'request')
+  }
+})
+
+test('should use default for parts - 1000', function (t) {
+  const fastify = Fastify()
+  t.teardown(fastify.close.bind(fastify))
+
+  fastify.register(multipart)
+
+  fastify.post('/', async function (req, reply) {
+    try {
+      // eslint-disable-next-lint no-empty
+      for await (const _ of req.parts()) { console.assert(_) }
+      t.fail('should throw on 1001')
+      reply.code(200).send()
+    } catch (error) {
+      t.ok(error instanceof fastify.multipartErrors.PartsLimitError)
+      reply.code(500).send()
+    }
+  })
+
+  fastify.listen({ port: 0 }, async function () {
+    // request
+    const form = new FormData()
+    const opts = {
+      protocol: 'http:',
+      hostname: 'localhost',
+      port: fastify.server.address().port,
+      path: '/',
+      headers: form.getHeaders(),
+      method: 'POST'
+    }
+
+    const req = http.request(opts, (res) => {
+      t.equal(res.statusCode, 500)
+      res.resume()
+      res.on('end', () => {
+        t.pass('res ended successfully')
+        t.end()
+      })
+    })
+    for (let i = 0; i < 1000; ++i) {
+      form.append('hello' + i, 'world')
+    }
+    // Exceeds the default limit (1000)
+    form.append('hello', 'world')
+
+    form.pipe(req)
+  })
+})

Vulnerable code:       onError(new PartsLimitError())
      onError(new FilesLimitError())
      onError(new FieldsLimitError())
  fastify.register(multipart)
  fastify.register(multipart)
Fixed code:     parts: 1000         // For multipart forms, the max number of parts (fields + files)
For security reasons, `@fastify/multipart` sets the limit for `parts` and `fileSize` being _1000_ and _1048576_ respectively.

  options.limits = {
    ...options.limits,
    parts: options.limits?.parts || 1000,
    fileSize: options.limits?.fileSize || fastify.initialConfig.bodyLimit
  }

      const err = new PartsLimitError()
      onError(err)
      process.nextTick(() => onEnd(err))
      const err = new FilesLimitError()
      onError(err)
      process.nextTick(() => onEnd(err))
      const err = new FieldsLimitError()
      onError(err)
      process.nextTick(() => onEnd(err))
  fastify.register(multipart, {
    limits: {
      fileSize: Infinity,
      parts: Infinity
    }
  })
  fastify.register(multipart, {
    limits: {
      fileSize: Infinity,
      parts: Infinity
    }
  })
const crypto = require('crypto')
const EventEmitter = require('events')
const { once } = EventEmitter

test('should use default for fileSize', async function (t) {
  t.plan(4)

  const fastify = Fastify()
  t.teardown(fastify.close.bind(fastify))

  fastify.register(multipart, {
    throwFileSizeLimit: true
  })

  fastify.post('/', async function (req, reply) {
    t.ok(req.isMultipart())

    const part = await req.file()
    t.pass('the file is not consumed yet')

    try {
      await part.toBuffer()
      t.fail('it should throw')
    } catch (error) {
      t.ok(error)
      reply.send(error)
    }
  })

  await fastify.listen({ port: 0 })

  // request
  const form = new FormData()
  const opts = {
    hostname: '127.0.0.1',
    port: fastify.server.address().port,
    path: '/',
    headers: form.getHeaders(),
    method: 'POST'
  }

  const randomFileBuffer = Buffer.alloc(15_000_000)
  crypto.randomFillSync(randomFileBuffer)

  const req = http.request(opts)
  form.append('upload', randomFileBuffer)

  form.pipe(req)

  try {
    const [res] = await once(req, 'response')
    t.equal(res.statusCode, 413)
    res.resume()
    await once(res, 'end')
  } catch (error) {
    t.error(error, 'request')
  }
})

test('should use default for parts - 1000', function (t) {
  const fastify = Fastify()
  t.teardown(fastify.close.bind(fastify))

  fastify.register(multipart)

  fastify.post('/', async function (req, reply) {
    try {
      // eslint-disable-next-lint no-empty
      for await (const _ of req.parts()) { console.assert(_) }
      t.fail('should throw on 1001')
      reply.code(200).send()
    } catch (error) {
      t.ok(error instanceof fastify.multipartErrors.PartsLimitError)
      reply.code(500).send()
    }
  })

  fastify.listen({ port: 0 }, async function () {
    // request
    const form = new FormData()
    const opts = {
      protocol: 'http:',
      hostname: 'localhost',
      port: fastify.server.address().port,
      path: '/',
      headers: form.getHeaders(),
      method: 'POST'
    }

    const req = http.request(opts, (res) => {
      t.equal(res.statusCode, 500)
      res.resume()
      res.on('end', () => {
        t.pass('res ended successfully')
        t.end()
      })
    })
    for (let i = 0; i < 1000; ++i) {
      form.append('hello' + i, 'world')
    }
    // Exceeds the default limit (1000)
    form.append('hello', 'world')

    form.pipe(req)
  })
})