Skip to content

Commit c8651f7

Browse files
committed
fix: refactor body-parser extended option semantics and harden prototype pollution protection
- Consolidate prototype pollution blocklist into a shared Set constant (PROTOTYPE_POLLUTION_KEYS) for DRY and O(1) lookups - Use Object.create(null) for URL-encoded body to prevent prototype chain access - Implement 3 distinct URL-encoded parsing modes: simple (extended=false), extended flat, and extended+nested - Forward top-level 'extended' option to urlencoded parser for backward compatibility - Add 8 new tests covering all extended/parseNestedObjects combinations and prototype pollution guards - Add TypeScript types and async/await to benchmark suite - Bump devDependencies to latest versions
1 parent ba36cbf commit c8651f7

File tree

4 files changed

+196
-70
lines changed

4 files changed

+196
-70
lines changed

bench.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,55 @@
11
import {run, bench, group} from 'mitata'
2-
import httpNext from './index'
2+
import httpNext, {IRouter} from './index'
33
import httpPrevious from '0http-bun'
44

5-
function setupRouter(router) {
6-
router.use((req, next) => {
5+
const silentErrorHandler = () =>
6+
new Response('Internal Server Error', {status: 500})
7+
8+
function setupRouter(router: IRouter) {
9+
router.use((req: any, next: () => any) => {
710
return next()
811
})
912

1013
router.get('/', () => {
1114
return new Response()
1215
})
13-
router.get('/:id', async (req) => {
16+
router.get('/:id', async (req: {params: Record<string, string>}) => {
1417
return new Response(req.params.id)
1518
})
1619
router.get('/:id/error', () => {
1720
throw new Error('Error')
1821
})
1922
}
2023

21-
const {router} = httpNext()
24+
const {router} = httpNext({errorHandler: silentErrorHandler})
2225
setupRouter(router)
2326

24-
const {router: routerPrevious} = httpPrevious()
27+
const {router: routerPrevious} = httpPrevious({
28+
errorHandler: silentErrorHandler,
29+
})
2530
setupRouter(routerPrevious)
2631

2732
group('Next Router', () => {
28-
bench('Parameter URL', () => {
29-
router.fetch(new Request(new URL('http://localhost/0')))
33+
bench('Parameter URL', async () => {
34+
await router.fetch(new Request(new URL('http://localhost/0')))
3035
}).gc('inner')
31-
bench('Not Found URL', () => {
32-
router.fetch(new Request(new URL('http://localhost/0/404')))
36+
bench('Not Found URL', async () => {
37+
await router.fetch(new Request(new URL('http://localhost/0/404')))
3338
}).gc('inner')
34-
bench('Error URL', () => {
35-
router.fetch(new Request(new URL('http://localhost/0/error')))
39+
bench('Error URL', async () => {
40+
await router.fetch(new Request(new URL('http://localhost/0/error')))
3641
}).gc('inner')
3742
})
3843

3944
group('Previous Router', () => {
40-
bench('Parameter URL', () => {
41-
routerPrevious.fetch(new Request(new URL('http://localhost/0')))
45+
bench('Parameter URL', async () => {
46+
await routerPrevious.fetch(new Request(new URL('http://localhost/0')))
4247
}).gc('inner')
43-
bench('Not Found URL', () => {
44-
routerPrevious.fetch(new Request(new URL('http://localhost/0/404')))
48+
bench('Not Found URL', async () => {
49+
await routerPrevious.fetch(new Request(new URL('http://localhost/0/404')))
4550
}).gc('inner')
46-
bench('Error URL', () => {
47-
routerPrevious.fetch(new Request(new URL('http://localhost/0/error')))
51+
bench('Error URL', async () => {
52+
await routerPrevious.fetch(new Request(new URL('http://localhost/0/error')))
4853
}).gc('inner')
4954
})
5055

lib/middleware/body-parser.js

Lines changed: 41 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,21 @@
1717
*/
1818
const RAW_BODY_SYMBOL = Symbol.for('0http.rawBody')
1919

20+
/**
21+
* Keys that must be blocked to prevent prototype pollution attacks.
22+
* Shared across all parsers (URL-encoded, multipart, nested key parsing).
23+
*/
24+
const PROTOTYPE_POLLUTION_KEYS = new Set([
25+
'__proto__',
26+
'constructor',
27+
'prototype',
28+
'hasOwnProperty',
29+
'isPrototypeOf',
30+
'propertyIsEnumerable',
31+
'valueOf',
32+
'toString',
33+
])
34+
2035
/**
2136
* Reads request body as text with inline size enforcement.
2237
* Streams the body and aborts if the accumulated size exceeds the limit,
@@ -181,19 +196,7 @@ function parseNestedKey(obj, key, value, depth = 0) {
181196
throw new Error('Maximum nesting depth exceeded')
182197
}
183198

184-
// Protect against prototype pollution
185-
const prototypePollutionKeys = [
186-
'__proto__',
187-
'constructor',
188-
'prototype',
189-
'hasOwnProperty',
190-
'isPrototypeOf',
191-
'propertyIsEnumerable',
192-
'valueOf',
193-
'toString',
194-
]
195-
196-
if (prototypePollutionKeys.includes(key)) {
199+
if (PROTOTYPE_POLLUTION_KEYS.has(key)) {
197200
return // Silently ignore dangerous keys
198201
}
199202

@@ -206,7 +209,7 @@ function parseNestedKey(obj, key, value, depth = 0) {
206209
const [, baseKey, indexKey, remaining] = match
207210

208211
// Protect against prototype pollution on base key
209-
if (prototypePollutionKeys.includes(baseKey)) {
212+
if (PROTOTYPE_POLLUTION_KEYS.has(baseKey)) {
210213
return
211214
}
212215

@@ -229,7 +232,7 @@ function parseNestedKey(obj, key, value, depth = 0) {
229232
}
230233
} else {
231234
// Protect against prototype pollution on index key
232-
if (!prototypePollutionKeys.includes(indexKey)) {
235+
if (!PROTOTYPE_POLLUTION_KEYS.has(indexKey)) {
233236
obj[baseKey][indexKey] = value
234237
}
235238
}
@@ -419,8 +422,8 @@ function createTextParser(options = {}) {
419422
* Creates a URL-encoded form parser middleware
420423
* @param {Object} options - Body parser configuration
421424
* @param {number|string} options.limit - Maximum body size in bytes
422-
* @param {boolean} options.extended - Use extended query string parsing
423-
* @param {boolean} options.parseNestedObjects - Parse nested object notation
425+
* @param {boolean} options.extended - Enable rich parsing: nested objects, arrays, and duplicate key merging (default: true). When false, only flat key-value pairs are returned.
426+
* @param {boolean} options.parseNestedObjects - Parse bracket notation (e.g. a[b]=1) into nested objects. Only applies when extended=true (default: true)
424427
* @param {boolean} options.deferNext - If true, don't call next() and let caller handle it
425428
* @returns {Function} Middleware function
426429
*/
@@ -465,7 +468,7 @@ function createURLEncodedParser(options = {}) {
465468
// Store raw body text for verification (L-3: use Symbol to prevent accidental serialization)
466469
req[RAW_BODY_SYMBOL] = text
467470

468-
const body = {}
471+
const body = Object.create(null)
469472
const params = new URLSearchParams(text)
470473

471474
// Prevent DoS through excessive parameters
@@ -483,7 +486,9 @@ function createURLEncodedParser(options = {}) {
483486
return new Response('Parameter too long', {status: 400})
484487
}
485488

486-
if (parseNestedObjects) {
489+
if (extended && parseNestedObjects) {
490+
// Extended + nested: parse bracket notation into nested objects/arrays
491+
// (parseNestedKey has its own prototype pollution guards via PROTOTYPE_POLLUTION_KEYS)
487492
try {
488493
parseNestedKey(body, key, value)
489494
} catch (parseError) {
@@ -492,20 +497,9 @@ function createURLEncodedParser(options = {}) {
492497
{status: 400},
493498
)
494499
}
495-
} else {
496-
// Protect against prototype pollution even when parseNestedObjects is false
497-
const prototypePollutionKeys = [
498-
'__proto__',
499-
'constructor',
500-
'prototype',
501-
'hasOwnProperty',
502-
'isPrototypeOf',
503-
'propertyIsEnumerable',
504-
'valueOf',
505-
'toString',
506-
]
507-
508-
if (!prototypePollutionKeys.includes(key)) {
500+
} else if (extended) {
501+
// Extended but no nested parsing: flat keys with duplicate key merging into arrays
502+
if (!PROTOTYPE_POLLUTION_KEYS.has(key)) {
509503
if (body[key] !== undefined) {
510504
if (Array.isArray(body[key])) {
511505
body[key].push(value)
@@ -516,6 +510,11 @@ function createURLEncodedParser(options = {}) {
516510
body[key] = value
517511
}
518512
}
513+
} else {
514+
// Simple mode: flat key-value pairs, last value wins
515+
if (!PROTOTYPE_POLLUTION_KEYS.has(key)) {
516+
body[key] = value
517+
}
519518
}
520519
}
521520

@@ -570,18 +569,6 @@ function createMultipartParser(options = {}) {
570569
const body = Object.create(null)
571570
const files = Object.create(null)
572571

573-
// Prototype pollution blocklist for field names
574-
const prototypePollutionKeys = [
575-
'__proto__',
576-
'constructor',
577-
'prototype',
578-
'hasOwnProperty',
579-
'isPrototypeOf',
580-
'propertyIsEnumerable',
581-
'valueOf',
582-
'toString',
583-
]
584-
585572
for (const [key, value] of formData.entries()) {
586573
fieldCount++
587574
if (fieldCount > maxFields) {
@@ -594,7 +581,7 @@ function createMultipartParser(options = {}) {
594581
}
595582

596583
// Skip prototype pollution keys
597-
if (prototypePollutionKeys.includes(key)) {
584+
if (PROTOTYPE_POLLUTION_KEYS.has(key)) {
598585
continue
599586
}
600587

@@ -710,6 +697,7 @@ function createMultipartParser(options = {}) {
710697
* @param {Function} options.onError - Custom error handler
711698
* @param {Function} options.verify - Body verification function
712699
* @param {boolean} options.parseNestedObjects - Parse nested object notation (for compatibility)
700+
* @param {boolean} options.extended - Enable rich URL-encoded parsing (for compatibility, forwarded to urlencoded.extended)
713701
* @param {string|number} options.jsonLimit - JSON size limit (backward compatibility)
714702
* @param {string|number} options.textLimit - Text size limit (backward compatibility)
715703
* @param {string|number} options.urlencodedLimit - URL-encoded size limit (backward compatibility)
@@ -727,6 +715,7 @@ function createBodyParser(options = {}) {
727715
onError,
728716
verify,
729717
parseNestedObjects = true,
718+
extended,
730719
// Backward compatibility for direct limit options
731720
jsonLimit,
732721
textLimit,
@@ -750,6 +739,12 @@ function createBodyParser(options = {}) {
750739
urlencoded.urlencodedLimit ||
751740
urlencoded.limit ||
752741
'1mb',
742+
extended:
743+
urlencoded.extended !== undefined
744+
? urlencoded.extended
745+
: extended !== undefined
746+
? extended
747+
: true,
753748
parseNestedObjects:
754749
urlencoded.parseNestedObjects !== undefined
755750
? urlencoded.parseNestedObjects

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@
2626
"lib/"
2727
],
2828
"devDependencies": {
29-
"0http-bun": "^1.2.1",
30-
"bun-types": "^1.2.16",
29+
"0http-bun": "^1.2.2",
30+
"bun-types": "^1.3.8",
3131
"mitata": "^1.0.34",
32-
"prettier": "^3.5.3",
33-
"typescript": "^5.8.3",
34-
"jose": "^6.0.11",
35-
"pino": "^9.7.0",
32+
"prettier": "^3.8.1",
33+
"typescript": "^5.9.3",
34+
"jose": "^6.1.3",
35+
"pino": "^9.14.0",
3636
"prom-client": "^15.1.3"
3737
},
3838
"keywords": [

0 commit comments

Comments
 (0)