Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,20 @@ function fastifyMultipart (fastify, options, done) {
}
}

// Attach error listener immediately to prevent uncaught exceptions
// This is critical when there's an async operation before req.file() is called,
// allowing the file stream to emit errors before user code can attach listeners
// However, we only capture and propagate errors from malformed multipart data
// (e.g., "Part terminated early"), not errors from normal stream consumption
file.on('error', function (err) {
// Only propagate errors that indicate malformed multipart data
// These are the errors that would cause uncaught exceptions
if (err.message && err.message.includes('terminated early')) {
onError(err)
}
// Other errors are expected to be handled by the consumer of the stream
})

if (throwFileSizeLimit) {
file.on('limit', function () {
const err = new RequestFileTooLargeError()
Expand Down
170 changes: 170 additions & 0 deletions test/multipart-uncaught-error.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
'use strict'

const { test } = require('node:test')
const assert = require('node:assert')
const Fastify = require('fastify')
const multipart = require('..')

test('malformed request should not cause uncaught exception when await before req.file()', async function (t) {
const fastify = Fastify()

await fastify.register(multipart)

let errorListenerCalled = false
let errorCaught = false

fastify.post('/', async function (req, reply) {
// Simulate any async operation before req.file()
// This allows request parsing to start before we get the file
await new Promise(resolve => setImmediate(resolve))

try {
const data = await req.file()

if (data) {
// Attach error listener
data.file.on('error', (_err) => {
errorListenerCalled = true
})

// Try to consume the file
await data.toBuffer()
}

reply.code(200).send({ ok: true })
} catch (err) {
errorCaught = true
reply.code(400).send({ error: err.message })
}
})

await fastify.listen({ port: 0 })

// Send malformed multipart request (missing closing boundary)
// Manually construct malformed multipart data
const malformedData = '------MyBoundary\r\n' +
'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' +
'Content-Type: text/plain\r\n' +
'\r\n' +
'ABC'
// Note: Missing closing boundary (------MyBoundary--)

try {
const response = await fastify.inject({
method: 'POST',
url: '/',
headers: {
'content-type': 'multipart/form-data; boundary=----MyBoundary'
},
payload: malformedData
})

// The request should complete without crashing
assert.ok(response, 'request completed without uncaught exception')
} catch (err) {
// Even if there's an error, it should be catchable
assert.ok(err, 'error was catchable')
}

assert.ok(errorListenerCalled || errorCaught, 'error was handled either by listener or catch block')

await fastify.close()
})

test('malformed request with req.files() should not cause uncaught exception', async function (t) {
const fastify = Fastify()

await fastify.register(multipart)

fastify.post('/', async function (req, reply) {
await new Promise(resolve => setImmediate(resolve))

try {
const files = req.files()

for await (const file of files) {
file.file.on('error', () => {})
await file.toBuffer().catch(() => {})
}

reply.code(200).send({ ok: true })
} catch (err) {
reply.code(400).send({ error: err.message })
}
})

await fastify.listen({ port: 0 })

const malformedData = '------MyBoundary\r\n' +
'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' +
'Content-Type: text/plain\r\n' +
'\r\n' +
'ABC'

try {
const response = await fastify.inject({
method: 'POST',
url: '/',
headers: {
'content-type': 'multipart/form-data; boundary=----MyBoundary'
},
payload: malformedData
})

assert.ok(response, 'request completed without uncaught exception')
} catch (err) {
assert.ok(err, 'error was catchable')
}

await fastify.close()
})

test('malformed request with req.parts() should not cause uncaught exception', async function (t) {
const fastify = Fastify()

await fastify.register(multipart)

fastify.post('/', async function (req, reply) {
await new Promise(resolve => setImmediate(resolve))

try {
const parts = req.parts()

for await (const part of parts) {
if (part.file) {
part.file.on('error', () => {})
await part.toBuffer().catch(() => {})
}
}

reply.code(200).send({ ok: true })
} catch (err) {
reply.code(400).send({ error: err.message })
}
})

await fastify.listen({ port: 0 })

const malformedData = '------MyBoundary\r\n' +
'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' +
'Content-Type: text/plain\r\n' +
'\r\n' +
'ABC'

try {
const response = await fastify.inject({
method: 'POST',
url: '/',
headers: {
'content-type': 'multipart/form-data; boundary=----MyBoundary'
},
payload: malformedData
})

assert.ok(response, 'request completed without uncaught exception')
} catch (err) {
assert.ok(err, 'error was catchable')
}

await fastify.close()
})