Skip to content

Commit d2019a6

Browse files
committed
feat: add multipart/form-data parser support
Add support for parsing multipart/form-data request bodies. The parser extracts text fields and automatically drops file fields, following the design pattern discussed in issue #88. The implementation uses the existing read utility (no external dependencies) and follows the same architecture as other parsers in the codebase. It validates individual field sizes and supports per-field verification callbacks. Closes #88 Addresses #258
1 parent 03f17c2 commit d2019a6

File tree

4 files changed

+591
-3
lines changed

4 files changed

+591
-3
lines changed

README.md

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ and `toString` may not be a function and instead a string or other user input.
1919

2020
[Learn about the anatomy of an HTTP transaction in Node.js](https://nodejs.org/en/learn/http/anatomy-of-an-http-transaction).
2121

22-
_This does not handle multipart bodies_, due to their complex and typically
23-
large nature. For multipart bodies, you may be interested in the following
24-
modules:
22+
_This module provides basic multipart/form-data support for text fields only._
23+
File fields are automatically dropped. For full file upload support, you may be
24+
interested in the following modules:
2525

2626
* [busboy](https://www.npmjs.com/package/busboy#readme) and
2727
[connect-busboy](https://www.npmjs.com/package/connect-busboy#readme)
@@ -33,6 +33,7 @@ modules:
3333
This module provides the following parsers:
3434

3535
* [JSON body parser](#bodyparserjsonoptions)
36+
* [Multipart/form-data body parser](#bodyparsermultipartoptions)
3637
* [Raw body parser](#bodyparserrawoptions)
3738
* [Text body parser](#bodyparsertextoptions)
3839
* [URL-encoded form body parser](#bodyparserurlencodedoptions)
@@ -300,6 +301,54 @@ form. Defaults to `false`.
300301

301302
The `depth` option is used to configure the maximum depth of the `qs` library when `extended` is `true`. This allows you to limit the amount of keys that are parsed and can be useful to prevent certain types of abuse. Defaults to `32`. It is recommended to keep this value as low as possible.
302303

304+
### bodyParser.multipart([options])
305+
306+
Returns middleware that only parses `multipart/form-data` bodies and only looks at
307+
requests where the `Content-Type` header matches the `type` option. This parser
308+
extracts text fields and automatically drops file fields. It supports automatic
309+
inflation of `gzip`, `br` (brotli) and `deflate` encodings.
310+
311+
A new `body` object containing the parsed data is populated on the `request`
312+
object after the middleware (i.e. `req.body`). This object will contain
313+
key-value pairs for text fields only. File fields (fields with `filename` in
314+
their `Content-Disposition` header) are automatically dropped.
315+
316+
#### Options
317+
318+
The `multipart` function takes an optional `options` object that may contain
319+
any of the following keys:
320+
321+
##### inflate
322+
323+
When set to `true`, then deflated (compressed) bodies will be inflated; when
324+
`false`, deflated bodies are rejected. Defaults to `true`.
325+
326+
##### limit
327+
328+
Controls the maximum size of individual text fields. If this is a number, then
329+
the value specifies the number of bytes; if it is a string, the value is passed
330+
to the [bytes](https://www.npmjs.com/package/bytes) library for parsing.
331+
Defaults to `'100kb'`. Note: The overall body size limit is automatically set
332+
higher to allow multiple fields.
333+
334+
##### type
335+
336+
The `type` option is used to determine what media type the middleware will
337+
parse. This option can be a string, array of strings, or a function. If not
338+
a function, `type` option is passed directly to the
339+
[type-is](https://www.npmjs.com/package/type-is#readme) library and this can
340+
be an extension name (like `multipart`), a mime type (like
341+
`multipart/form-data`), or a mime type with a wildcard (like `multipart/*`).
342+
If a function, the `type` option is called as `fn(req)` and the request is parsed
343+
if it returns a truthy value. Defaults to `multipart/form-data`.
344+
345+
##### verify
346+
347+
The `verify` option, if supplied, is called as `verify(req, res, buf, encoding)`,
348+
where `buf` is a string containing the field value and `encoding` is the
349+
encoding of the request. The verification is called for each text field
350+
individually. The parsing can be aborted by throwing an error.
351+
303352
## Errors
304353

305354
The middlewares provided by this module create errors using the
@@ -445,12 +494,21 @@ const jsonParser = bodyParser.json()
445494
// create application/x-www-form-urlencoded parser
446495
const urlencodedParser = bodyParser.urlencoded()
447496

497+
// create multipart/form-data parser
498+
const multipartParser = bodyParser.multipart()
499+
448500
// POST /login gets urlencoded bodies
449501
app.post('/login', urlencodedParser, function (req, res) {
450502
if (!req.body || !req.body.username) res.sendStatus(400)
451503
res.send('welcome, ' + req.body.username)
452504
})
453505

506+
// POST /upload gets multipart bodies (text fields only, files are dropped)
507+
app.post('/upload', multipartParser, function (req, res) {
508+
if (!req.body || !req.body.description) res.sendStatus(400)
509+
res.send('uploaded: ' + req.body.description)
510+
})
511+
454512
// POST /api/users gets JSON bodies
455513
app.post('/api/users', jsonParser, function (req, res) {
456514
if (!req.body) res.sendStatus(400)

index.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
/**
1010
* @typedef {Object} Parsers
1111
* @property {Function} json JSON parser
12+
* @property {Function} multipart Multipart/form-data parser
1213
* @property {Function} raw Raw parser
1314
* @property {Function} text Text parser
1415
* @property {Function} urlencoded URL-encoded parser
@@ -60,6 +61,17 @@ Object.defineProperty(exports, 'urlencoded', {
6061
get: () => require('./lib/types/urlencoded')
6162
})
6263

64+
/**
65+
* Multipart/form-data parser.
66+
* Only extracts text fields and drops file fields.
67+
* @public
68+
*/
69+
Object.defineProperty(exports, 'multipart', {
70+
configurable: true,
71+
enumerable: true,
72+
get: () => require('./lib/types/multipart')
73+
})
74+
6375
/**
6476
* Create a middleware to parse json and urlencoded bodies.
6577
*

lib/types/multipart.js

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/*!
2+
* body-parser
3+
* Copyright(c) 2014-2015 Douglas Christopher Wilson
4+
* Copyright(c) 2026
5+
* MIT Licensed
6+
*/
7+
8+
'use strict'
9+
10+
/**
11+
* Module dependencies.
12+
* @private
13+
*/
14+
15+
var createError = require('http-errors')
16+
var debug = require('debug')('body-parser:multipart')
17+
var read = require('../read')
18+
var { normalizeOptions } = require('../utils')
19+
20+
/**
21+
* Module exports.
22+
*/
23+
24+
module.exports = multipart
25+
26+
/**
27+
* Create a middleware to parse multipart/form-data bodies.
28+
* This parser only extracts text fields and drops file fields.
29+
*
30+
* @param {Object} [options]
31+
* @returns {Function}
32+
* @public
33+
*/
34+
function multipart (options) {
35+
const normalizedOptions = normalizeOptions(options, 'multipart/form-data')
36+
37+
var limit = normalizedOptions.limit
38+
var verify = normalizedOptions.verify
39+
40+
function parse (body, encoding) {
41+
var req = this
42+
if (!body || body.length === 0) {
43+
return {}
44+
}
45+
46+
var contentType = req.headers && req.headers['content-type']
47+
if (!contentType) {
48+
throw createError(400, 'missing content-type header', {
49+
type: 'multipart.content-type.missing'
50+
})
51+
}
52+
53+
if (!contentType.toLowerCase().includes('multipart')) {
54+
debug('non-multipart content-type in parse function - should have been skipped')
55+
return undefined
56+
}
57+
58+
var boundary = extractBoundary(contentType)
59+
var bodyStr = typeof body === 'string' ? body : body.toString('utf-8')
60+
var parts = bodyStr.split('--' + boundary)
61+
var result = {}
62+
63+
for (var i = 1; i < parts.length - 1; i++) {
64+
var field = parsePart(parts[i], limit, req, encoding)
65+
if (field) {
66+
addField(result, field.name, field.value)
67+
}
68+
}
69+
70+
return result
71+
}
72+
73+
var readLimit = normalizedOptions.limit
74+
var overallLimit = Math.max(readLimit * 100, 100 * 1024 * 1024)
75+
76+
const readOptions = {
77+
...normalizedOptions,
78+
limit: overallLimit,
79+
skipCharset: true,
80+
verify: false
81+
}
82+
83+
return function multipartParser (req, res, next) {
84+
req._multipartVerify = verify
85+
read(req, res, next, parse.bind(req), debug, readOptions)
86+
}
87+
}
88+
89+
/**
90+
* Extract boundary from content-type header.
91+
*
92+
* @param {string} contentType
93+
* @returns {string}
94+
* @private
95+
*/
96+
function extractBoundary (contentType) {
97+
var boundaryMatch = contentType.match(/boundary=([^;]+)/i)
98+
if (!boundaryMatch) {
99+
throw createError(400, 'missing boundary in content-type', {
100+
type: 'multipart.boundary.missing'
101+
})
102+
}
103+
return boundaryMatch[1].replace(/^["']|["']$/g, '')
104+
}
105+
106+
/**
107+
* Parse a single multipart part.
108+
*
109+
* @param {string} part
110+
* @param {number} limit
111+
* @param {Object} req
112+
* @param {string} encoding
113+
* @returns {Object|null}
114+
* @private
115+
*/
116+
function parsePart (part, limit, req, encoding) {
117+
var trimmed = part.trim()
118+
if (trimmed === '--' || trimmed === '') {
119+
return null
120+
}
121+
122+
var headerEnd = trimmed.indexOf('\r\n\r\n')
123+
if (headerEnd === -1) {
124+
headerEnd = trimmed.indexOf('\n\n')
125+
if (headerEnd === -1) {
126+
debug('invalid part format')
127+
return null
128+
}
129+
headerEnd += 1
130+
} else {
131+
headerEnd += 4
132+
}
133+
134+
var headers = trimmed.substring(0, headerEnd)
135+
var bodyContent = trimmed.substring(headerEnd).replace(/\r\n$/, '')
136+
137+
var contentDisposition = headers.match(/Content-Disposition:\s*([^\r\n]+)/i)
138+
if (!contentDisposition) {
139+
debug('missing Content-Disposition header')
140+
return null
141+
}
142+
143+
var disposition = contentDisposition[1]
144+
145+
if (/filename\s*=/i.test(disposition)) {
146+
debug('dropping file field')
147+
return null
148+
}
149+
150+
var nameMatch = disposition.match(/name\s*=\s*"([^"]+)"|name\s*=\s*([^;,\s]+)/i)
151+
if (!nameMatch) {
152+
debug('missing field name')
153+
return null
154+
}
155+
156+
var fieldName = nameMatch[1] || nameMatch[2]
157+
158+
if (bodyContent.length > limit) {
159+
var err = createError(413, 'field size limit exceeded', {
160+
type: 'entity.too.large',
161+
limit: limit
162+
})
163+
err.expose = true
164+
throw err
165+
}
166+
167+
var fieldVerify = req._multipartVerify
168+
if (fieldVerify) {
169+
try {
170+
fieldVerify(req, null, bodyContent, encoding || 'utf-8')
171+
} catch (err) {
172+
throw createError(403, err, {
173+
type: err.type || 'entity.verify.failed'
174+
})
175+
}
176+
}
177+
178+
return { name: fieldName, value: bodyContent }
179+
}
180+
181+
/**
182+
* Add field to result object, handling multiple values.
183+
*
184+
* @param {Object} result
185+
* @param {string} name
186+
* @param {string} value
187+
* @private
188+
*/
189+
function addField (result, name, value) {
190+
if (result[name]) {
191+
if (Array.isArray(result[name])) {
192+
result[name].push(value)
193+
} else {
194+
result[name] = [result[name], value]
195+
}
196+
} else {
197+
result[name] = value
198+
}
199+
}

0 commit comments

Comments
 (0)