0http-bun 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +505 -9
- package/common.d.ts +16 -0
- package/lib/middleware/README.md +402 -22
- package/lib/middleware/body-parser.js +410 -263
- package/lib/middleware/cors.js +57 -57
- package/lib/middleware/index.d.ts +93 -26
- package/lib/middleware/index.js +13 -0
- package/lib/middleware/jwt-auth.js +146 -36
- package/lib/middleware/logger.js +19 -4
- package/lib/middleware/prometheus.js +491 -0
- package/lib/middleware/rate-limit.js +77 -27
- package/lib/next.js +8 -1
- package/lib/router/sequential.js +41 -10
- package/package.json +9 -8
|
@@ -12,6 +12,87 @@
|
|
|
12
12
|
* - Error message sanitization to prevent information leakage
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Symbol key for storing raw body text to prevent accidental serialization/logging (L-3 fix)
|
|
17
|
+
*/
|
|
18
|
+
const RAW_BODY_SYMBOL = Symbol.for('0http.rawBody')
|
|
19
|
+
|
|
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
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reads request body as text with inline size enforcement.
|
|
37
|
+
* Streams the body and aborts if the accumulated size exceeds the limit,
|
|
38
|
+
* preventing full memory allocation for oversized payloads (H-3 fix).
|
|
39
|
+
* @param {Request} req - Request object
|
|
40
|
+
* @param {number} maxBytes - Maximum allowed body size in bytes
|
|
41
|
+
* @returns {Promise<string>} Body text
|
|
42
|
+
* @throws {Object} Object with `status` and `message` on limit exceeded
|
|
43
|
+
*/
|
|
44
|
+
async function readBodyWithLimit(req, maxBytes) {
|
|
45
|
+
// If the request has no body stream, fall back to req.text()
|
|
46
|
+
if (!req.body || typeof req.body.getReader !== 'function') {
|
|
47
|
+
const text = await req.text()
|
|
48
|
+
const byteLength = new TextEncoder().encode(text).length
|
|
49
|
+
if (byteLength > maxBytes) {
|
|
50
|
+
const err = new Error('Request body size exceeded')
|
|
51
|
+
err.status = 413
|
|
52
|
+
throw err
|
|
53
|
+
}
|
|
54
|
+
return text
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const reader = req.body.getReader()
|
|
58
|
+
const chunks = []
|
|
59
|
+
let totalBytes = 0
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
while (true) {
|
|
63
|
+
const {done, value} = await reader.read()
|
|
64
|
+
if (done) break
|
|
65
|
+
|
|
66
|
+
totalBytes += value.byteLength
|
|
67
|
+
if (totalBytes > maxBytes) {
|
|
68
|
+
reader.cancel()
|
|
69
|
+
const err = new Error('Request body size exceeded')
|
|
70
|
+
err.status = 413
|
|
71
|
+
throw err
|
|
72
|
+
}
|
|
73
|
+
chunks.push(value)
|
|
74
|
+
}
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// Re-throw size limit errors
|
|
77
|
+
if (e.status === 413) throw e
|
|
78
|
+
// Wrap other stream errors
|
|
79
|
+
const err = new Error('Failed to read request body')
|
|
80
|
+
err.status = 400
|
|
81
|
+
throw err
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Concatenate chunks and decode to string
|
|
85
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
|
|
86
|
+
const combined = new Uint8Array(totalLength)
|
|
87
|
+
let offset = 0
|
|
88
|
+
for (const chunk of chunks) {
|
|
89
|
+
combined.set(chunk, offset)
|
|
90
|
+
offset += chunk.byteLength
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return new TextDecoder().decode(combined)
|
|
94
|
+
}
|
|
95
|
+
|
|
15
96
|
/**
|
|
16
97
|
* Parses size limit strings with suffixes (e.g. '500b', '1kb', '2mb')
|
|
17
98
|
* @param {number|string} limit - Size limit
|
|
@@ -64,7 +145,9 @@ function parseLimit(limit) {
|
|
|
64
145
|
return Math.min(bytes, 1024 * 1024 * 1024) // Max 1GB
|
|
65
146
|
}
|
|
66
147
|
|
|
67
|
-
|
|
148
|
+
throw new TypeError(
|
|
149
|
+
`Invalid limit type: expected number or string, got ${typeof limit}`,
|
|
150
|
+
)
|
|
68
151
|
}
|
|
69
152
|
|
|
70
153
|
/**
|
|
@@ -113,19 +196,7 @@ function parseNestedKey(obj, key, value, depth = 0) {
|
|
|
113
196
|
throw new Error('Maximum nesting depth exceeded')
|
|
114
197
|
}
|
|
115
198
|
|
|
116
|
-
|
|
117
|
-
const prototypePollutionKeys = [
|
|
118
|
-
'__proto__',
|
|
119
|
-
'constructor',
|
|
120
|
-
'prototype',
|
|
121
|
-
'hasOwnProperty',
|
|
122
|
-
'isPrototypeOf',
|
|
123
|
-
'propertyIsEnumerable',
|
|
124
|
-
'valueOf',
|
|
125
|
-
'toString',
|
|
126
|
-
]
|
|
127
|
-
|
|
128
|
-
if (prototypePollutionKeys.includes(key)) {
|
|
199
|
+
if (PROTOTYPE_POLLUTION_KEYS.has(key)) {
|
|
129
200
|
return // Silently ignore dangerous keys
|
|
130
201
|
}
|
|
131
202
|
|
|
@@ -138,7 +209,7 @@ function parseNestedKey(obj, key, value, depth = 0) {
|
|
|
138
209
|
const [, baseKey, indexKey, remaining] = match
|
|
139
210
|
|
|
140
211
|
// Protect against prototype pollution on base key
|
|
141
|
-
if (
|
|
212
|
+
if (PROTOTYPE_POLLUTION_KEYS.has(baseKey)) {
|
|
142
213
|
return
|
|
143
214
|
}
|
|
144
215
|
|
|
@@ -161,7 +232,7 @@ function parseNestedKey(obj, key, value, depth = 0) {
|
|
|
161
232
|
}
|
|
162
233
|
} else {
|
|
163
234
|
// Protect against prototype pollution on index key
|
|
164
|
-
if (!
|
|
235
|
+
if (!PROTOTYPE_POLLUTION_KEYS.has(indexKey)) {
|
|
165
236
|
obj[baseKey][indexKey] = value
|
|
166
237
|
}
|
|
167
238
|
}
|
|
@@ -194,90 +265,104 @@ function createJSONParser(options = {}) {
|
|
|
194
265
|
return deferNext ? null : next()
|
|
195
266
|
}
|
|
196
267
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
return new Response('Invalid content-length header', {status: 400})
|
|
203
|
-
}
|
|
204
|
-
if (length > parsedLimit) {
|
|
205
|
-
return new Response('Request body size exceeded', {status: 413})
|
|
206
|
-
}
|
|
268
|
+
const contentLength = req.headers.get('content-length')
|
|
269
|
+
if (contentLength) {
|
|
270
|
+
const length = parseInt(contentLength)
|
|
271
|
+
if (isNaN(length) || length < 0) {
|
|
272
|
+
return new Response('Invalid content-length header', {status: 400})
|
|
207
273
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (req.body === null) {
|
|
211
|
-
Object.defineProperty(req, 'body', {
|
|
212
|
-
value: undefined,
|
|
213
|
-
writable: true,
|
|
214
|
-
enumerable: true,
|
|
215
|
-
configurable: true,
|
|
216
|
-
})
|
|
217
|
-
return deferNext ? null : next()
|
|
274
|
+
if (length > parsedLimit) {
|
|
275
|
+
return new Response('Request body size exceeded', {status: 413})
|
|
218
276
|
}
|
|
277
|
+
}
|
|
219
278
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
req
|
|
279
|
+
// Check if the request has a null body (no body was provided)
|
|
280
|
+
if (req.body === null) {
|
|
281
|
+
Object.defineProperty(req, 'body', {
|
|
282
|
+
value: undefined,
|
|
283
|
+
writable: true,
|
|
284
|
+
enumerable: true,
|
|
285
|
+
configurable: true,
|
|
286
|
+
})
|
|
287
|
+
return deferNext ? null : next()
|
|
288
|
+
}
|
|
223
289
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
290
|
+
let text
|
|
291
|
+
try {
|
|
292
|
+
text = await readBodyWithLimit(req, parsedLimit)
|
|
293
|
+
} catch (e) {
|
|
294
|
+
if (e.status === 413) {
|
|
227
295
|
return new Response('Request body size exceeded', {status: 413})
|
|
228
296
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
297
|
+
return new Response('Failed to read request body', {status: 400})
|
|
298
|
+
}
|
|
299
|
+
// Store raw body text for verification (L-3: use Symbol to prevent accidental serialization)
|
|
300
|
+
req[RAW_BODY_SYMBOL] = text
|
|
301
|
+
|
|
302
|
+
// Additional protection against excessively deep nesting
|
|
303
|
+
if (text.length > 0) {
|
|
304
|
+
// Count nesting levels with string-awareness to prevent bypass via string content
|
|
305
|
+
let nestingLevel = 0
|
|
306
|
+
let maxNesting = 0
|
|
307
|
+
let inString = false
|
|
308
|
+
let escape = false
|
|
309
|
+
for (let i = 0; i < text.length; i++) {
|
|
310
|
+
const ch = text[i]
|
|
311
|
+
if (escape) {
|
|
312
|
+
escape = false
|
|
313
|
+
continue
|
|
242
314
|
}
|
|
243
|
-
if (
|
|
244
|
-
|
|
315
|
+
if (ch === '\\' && inString) {
|
|
316
|
+
escape = true
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
if (ch === '"') {
|
|
320
|
+
inString = !inString
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
if (inString) continue
|
|
324
|
+
if (ch === '{' || ch === '[') {
|
|
325
|
+
nestingLevel++
|
|
326
|
+
maxNesting = Math.max(maxNesting, nestingLevel)
|
|
327
|
+
} else if (ch === '}' || ch === ']') {
|
|
328
|
+
nestingLevel--
|
|
245
329
|
}
|
|
246
330
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (text === '' || text.trim() === '') {
|
|
250
|
-
Object.defineProperty(req, 'body', {
|
|
251
|
-
value: {},
|
|
252
|
-
writable: true,
|
|
253
|
-
enumerable: true,
|
|
254
|
-
configurable: true,
|
|
255
|
-
})
|
|
256
|
-
return deferNext ? null : next()
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
let body
|
|
260
|
-
try {
|
|
261
|
-
body = JSON.parse(text, reviver)
|
|
262
|
-
} catch (parseError) {
|
|
263
|
-
throw new Error(`Invalid JSON: ${parseError.message}`)
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (strict && typeof body !== 'object') {
|
|
267
|
-
throw new Error('JSON body must be an object or array')
|
|
331
|
+
if (maxNesting > 100) {
|
|
332
|
+
return new Response('JSON nesting too deep', {status: 400})
|
|
268
333
|
}
|
|
334
|
+
}
|
|
269
335
|
|
|
336
|
+
// Handle empty string body — return undefined to distinguish from actual data (L-2 fix)
|
|
337
|
+
if (text === '' || text.trim() === '') {
|
|
270
338
|
Object.defineProperty(req, 'body', {
|
|
271
|
-
value:
|
|
339
|
+
value: undefined,
|
|
272
340
|
writable: true,
|
|
273
341
|
enumerable: true,
|
|
274
342
|
configurable: true,
|
|
275
343
|
})
|
|
276
|
-
|
|
277
344
|
return deferNext ? null : next()
|
|
278
|
-
} catch (error) {
|
|
279
|
-
throw error
|
|
280
345
|
}
|
|
346
|
+
|
|
347
|
+
let body
|
|
348
|
+
try {
|
|
349
|
+
body = JSON.parse(text, reviver)
|
|
350
|
+
} catch (parseError) {
|
|
351
|
+
throw new Error(`Invalid JSON: ${parseError.message}`)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (strict && typeof body !== 'object') {
|
|
355
|
+
throw new Error('JSON body must be an object or array')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
Object.defineProperty(req, 'body', {
|
|
359
|
+
value: body,
|
|
360
|
+
writable: true,
|
|
361
|
+
enumerable: true,
|
|
362
|
+
configurable: true,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
return deferNext ? null : next()
|
|
281
366
|
}
|
|
282
367
|
}
|
|
283
368
|
|
|
@@ -299,38 +384,37 @@ function createTextParser(options = {}) {
|
|
|
299
384
|
return deferNext ? null : next()
|
|
300
385
|
}
|
|
301
386
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
return new Response('Invalid content-length header', {status: 400})
|
|
308
|
-
}
|
|
309
|
-
if (length > parsedLimit) {
|
|
310
|
-
return new Response('Request body size exceeded', {status: 413})
|
|
311
|
-
}
|
|
387
|
+
const contentLength = req.headers.get('content-length')
|
|
388
|
+
if (contentLength) {
|
|
389
|
+
const length = parseInt(contentLength)
|
|
390
|
+
if (isNaN(length) || length < 0) {
|
|
391
|
+
return new Response('Invalid content-length header', {status: 400})
|
|
312
392
|
}
|
|
393
|
+
if (length > parsedLimit) {
|
|
394
|
+
return new Response('Request body size exceeded', {status: 413})
|
|
395
|
+
}
|
|
396
|
+
}
|
|
313
397
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (textLength > parsedLimit) {
|
|
398
|
+
let text
|
|
399
|
+
try {
|
|
400
|
+
text = await readBodyWithLimit(req, parsedLimit)
|
|
401
|
+
} catch (e) {
|
|
402
|
+
if (e.status === 413) {
|
|
320
403
|
return new Response('Request body size exceeded', {status: 413})
|
|
321
404
|
}
|
|
405
|
+
return new Response('Failed to read request body', {status: 400})
|
|
406
|
+
}
|
|
407
|
+
// Store raw body text for verification (L-3: use Symbol to prevent accidental serialization)
|
|
408
|
+
req[RAW_BODY_SYMBOL] = text
|
|
322
409
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
410
|
+
Object.defineProperty(req, 'body', {
|
|
411
|
+
value: text,
|
|
412
|
+
writable: true,
|
|
413
|
+
enumerable: true,
|
|
414
|
+
configurable: true,
|
|
415
|
+
})
|
|
329
416
|
|
|
330
|
-
|
|
331
|
-
} catch (error) {
|
|
332
|
-
throw error
|
|
333
|
-
}
|
|
417
|
+
return deferNext ? null : next()
|
|
334
418
|
}
|
|
335
419
|
}
|
|
336
420
|
|
|
@@ -338,8 +422,8 @@ function createTextParser(options = {}) {
|
|
|
338
422
|
* Creates a URL-encoded form parser middleware
|
|
339
423
|
* @param {Object} options - Body parser configuration
|
|
340
424
|
* @param {number|string} options.limit - Maximum body size in bytes
|
|
341
|
-
* @param {boolean} options.extended -
|
|
342
|
-
* @param {boolean} options.parseNestedObjects - Parse nested
|
|
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)
|
|
343
427
|
* @param {boolean} options.deferNext - If true, don't call next() and let caller handle it
|
|
344
428
|
* @returns {Function} Middleware function
|
|
345
429
|
*/
|
|
@@ -361,92 +445,87 @@ function createURLEncodedParser(options = {}) {
|
|
|
361
445
|
return deferNext ? null : next()
|
|
362
446
|
}
|
|
363
447
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
return new Response('Invalid content-length header', {status: 400})
|
|
370
|
-
}
|
|
371
|
-
if (length > parsedLimit) {
|
|
372
|
-
return new Response('Request body size exceeded', {status: 413})
|
|
373
|
-
}
|
|
448
|
+
const contentLength = req.headers.get('content-length')
|
|
449
|
+
if (contentLength) {
|
|
450
|
+
const length = parseInt(contentLength)
|
|
451
|
+
if (isNaN(length) || length < 0) {
|
|
452
|
+
return new Response('Invalid content-length header', {status: 400})
|
|
374
453
|
}
|
|
454
|
+
if (length > parsedLimit) {
|
|
455
|
+
return new Response('Request body size exceeded', {status: 413})
|
|
456
|
+
}
|
|
457
|
+
}
|
|
375
458
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (textLength > parsedLimit) {
|
|
459
|
+
let text
|
|
460
|
+
try {
|
|
461
|
+
text = await readBodyWithLimit(req, parsedLimit)
|
|
462
|
+
} catch (e) {
|
|
463
|
+
if (e.status === 413) {
|
|
382
464
|
return new Response('Request body size exceeded', {status: 413})
|
|
383
465
|
}
|
|
466
|
+
return new Response('Failed to read request body', {status: 400})
|
|
467
|
+
}
|
|
468
|
+
// Store raw body text for verification (L-3: use Symbol to prevent accidental serialization)
|
|
469
|
+
req[RAW_BODY_SYMBOL] = text
|
|
384
470
|
|
|
385
|
-
|
|
386
|
-
|
|
471
|
+
const body = Object.create(null)
|
|
472
|
+
const params = new URLSearchParams(text)
|
|
387
473
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
474
|
+
// Prevent DoS through excessive parameters
|
|
475
|
+
let paramCount = 0
|
|
476
|
+
const maxParams = 1000 // Reasonable limit for URL-encoded parameters
|
|
391
477
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
478
|
+
for (const [key, value] of params.entries()) {
|
|
479
|
+
paramCount++
|
|
480
|
+
if (paramCount > maxParams) {
|
|
481
|
+
return new Response('Too many parameters', {status: 400})
|
|
482
|
+
}
|
|
397
483
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
484
|
+
// Validate key and value lengths to prevent memory exhaustion
|
|
485
|
+
if (key.length > 1000 || value.length > 10000) {
|
|
486
|
+
return new Response('Parameter too long', {status: 400})
|
|
487
|
+
}
|
|
402
488
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
'propertyIsEnumerable',
|
|
421
|
-
'valueOf',
|
|
422
|
-
'toString',
|
|
423
|
-
]
|
|
424
|
-
|
|
425
|
-
if (!prototypePollutionKeys.includes(key)) {
|
|
426
|
-
if (body[key] !== undefined) {
|
|
427
|
-
if (Array.isArray(body[key])) {
|
|
428
|
-
body[key].push(value)
|
|
429
|
-
} else {
|
|
430
|
-
body[key] = [body[key], value]
|
|
431
|
-
}
|
|
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)
|
|
492
|
+
try {
|
|
493
|
+
parseNestedKey(body, key, value)
|
|
494
|
+
} catch (parseError) {
|
|
495
|
+
return new Response(
|
|
496
|
+
`Invalid parameter structure: ${parseError.message}`,
|
|
497
|
+
{status: 400},
|
|
498
|
+
)
|
|
499
|
+
}
|
|
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)) {
|
|
503
|
+
if (body[key] !== undefined) {
|
|
504
|
+
if (Array.isArray(body[key])) {
|
|
505
|
+
body[key].push(value)
|
|
432
506
|
} else {
|
|
433
|
-
body[key] = value
|
|
507
|
+
body[key] = [body[key], value]
|
|
434
508
|
}
|
|
509
|
+
} else {
|
|
510
|
+
body[key] = value
|
|
435
511
|
}
|
|
436
512
|
}
|
|
513
|
+
} else {
|
|
514
|
+
// Simple mode: flat key-value pairs, last value wins
|
|
515
|
+
if (!PROTOTYPE_POLLUTION_KEYS.has(key)) {
|
|
516
|
+
body[key] = value
|
|
517
|
+
}
|
|
437
518
|
}
|
|
519
|
+
}
|
|
438
520
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
521
|
+
Object.defineProperty(req, 'body', {
|
|
522
|
+
value: body,
|
|
523
|
+
writable: true,
|
|
524
|
+
enumerable: true,
|
|
525
|
+
configurable: true,
|
|
526
|
+
})
|
|
445
527
|
|
|
446
|
-
|
|
447
|
-
} catch (error) {
|
|
448
|
-
throw error
|
|
449
|
-
}
|
|
528
|
+
return deferNext ? null : next()
|
|
450
529
|
}
|
|
451
530
|
}
|
|
452
531
|
|
|
@@ -468,109 +547,141 @@ function createMultipartParser(options = {}) {
|
|
|
468
547
|
return deferNext ? null : next()
|
|
469
548
|
}
|
|
470
549
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
return new Response('Request body size exceeded', {status: 413})
|
|
480
|
-
}
|
|
550
|
+
const contentLength = req.headers.get('content-length')
|
|
551
|
+
if (contentLength) {
|
|
552
|
+
const length = parseInt(contentLength)
|
|
553
|
+
if (isNaN(length) || length < 0) {
|
|
554
|
+
return new Response('Invalid content-length header', {status: 400})
|
|
555
|
+
}
|
|
556
|
+
if (length > parsedLimit) {
|
|
557
|
+
return new Response('Request body size exceeded', {status: 413})
|
|
481
558
|
}
|
|
559
|
+
}
|
|
482
560
|
|
|
483
|
-
|
|
561
|
+
const formData = await req.formData()
|
|
484
562
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
563
|
+
// Single-pass: validate and extract simultaneously (H-5 fix)
|
|
564
|
+
// Merges the validation and extraction loops to avoid double iteration
|
|
565
|
+
let totalSize = 0
|
|
566
|
+
let fieldCount = 0
|
|
567
|
+
const maxFields = 100 // Reasonable limit for form fields
|
|
489
568
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
if (fieldCount > maxFields) {
|
|
493
|
-
return new Response('Too many form fields', {status: 400})
|
|
494
|
-
}
|
|
569
|
+
const body = Object.create(null)
|
|
570
|
+
const files = Object.create(null)
|
|
495
571
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
572
|
+
for (const [key, value] of formData.entries()) {
|
|
573
|
+
fieldCount++
|
|
574
|
+
if (fieldCount > maxFields) {
|
|
575
|
+
return new Response('Too many form fields', {status: 400})
|
|
576
|
+
}
|
|
500
577
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
// 100KB per field
|
|
517
|
-
return new Response('Field value too long', {status: 400})
|
|
518
|
-
}
|
|
578
|
+
// Validate field name length
|
|
579
|
+
if (key.length > 1000) {
|
|
580
|
+
return new Response('Field name too long', {status: 400})
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Skip prototype pollution keys
|
|
584
|
+
if (PROTOTYPE_POLLUTION_KEYS.has(key)) {
|
|
585
|
+
continue
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (value instanceof File) {
|
|
589
|
+
totalSize += value.size
|
|
590
|
+
// Validate file name length for security
|
|
591
|
+
if (value.name && value.name.length > 255) {
|
|
592
|
+
return new Response('Filename too long', {status: 400})
|
|
519
593
|
}
|
|
594
|
+
// Validate file size individually
|
|
595
|
+
if (value.size > parsedLimit) {
|
|
596
|
+
return new Response('File too large', {status: 413})
|
|
597
|
+
}
|
|
598
|
+
|
|
520
599
|
totalSize += new TextEncoder().encode(key).length
|
|
521
600
|
|
|
522
|
-
// Check total size
|
|
601
|
+
// Check total size to prevent memory exhaustion
|
|
523
602
|
if (totalSize > parsedLimit) {
|
|
524
603
|
return new Response('Request body size exceeded', {status: 413})
|
|
525
604
|
}
|
|
526
|
-
}
|
|
527
605
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
if (
|
|
533
|
-
const
|
|
534
|
-
|
|
606
|
+
const mimetype = value.type?.split(';')[0] || value.type
|
|
607
|
+
const fileData = new Uint8Array(await value.arrayBuffer())
|
|
608
|
+
// Sanitize filename to prevent path traversal
|
|
609
|
+
let sanitizedFilename = value.name
|
|
610
|
+
if (sanitizedFilename) {
|
|
611
|
+
const originalName = sanitizedFilename
|
|
612
|
+
// Remove null bytes
|
|
613
|
+
sanitizedFilename = sanitizedFilename.replace(/\0/g, '')
|
|
614
|
+
// Remove path separators and directory components
|
|
615
|
+
sanitizedFilename = sanitizedFilename.replace(/\.\./g, '')
|
|
616
|
+
sanitizedFilename = sanitizedFilename.replace(/[/\\]/g, '')
|
|
617
|
+
// Remove leading dots (hidden files)
|
|
618
|
+
sanitizedFilename = sanitizedFilename.replace(/^\.+/, '')
|
|
619
|
+
// If nothing is left after sanitization, use a default name
|
|
620
|
+
if (!sanitizedFilename) {
|
|
621
|
+
sanitizedFilename = 'upload'
|
|
622
|
+
}
|
|
535
623
|
files[key] = {
|
|
536
|
-
filename:
|
|
537
|
-
|
|
624
|
+
filename: sanitizedFilename,
|
|
625
|
+
originalName: originalName,
|
|
626
|
+
name: sanitizedFilename,
|
|
538
627
|
size: value.size,
|
|
539
628
|
type: value.type,
|
|
540
629
|
mimetype: mimetype,
|
|
541
630
|
data: fileData,
|
|
542
631
|
}
|
|
543
632
|
} else {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
body[key] = value
|
|
633
|
+
files[key] = {
|
|
634
|
+
filename: value.name,
|
|
635
|
+
name: value.name,
|
|
636
|
+
size: value.size,
|
|
637
|
+
type: value.type,
|
|
638
|
+
mimetype: mimetype,
|
|
639
|
+
data: fileData,
|
|
552
640
|
}
|
|
553
641
|
}
|
|
554
|
-
}
|
|
642
|
+
} else {
|
|
643
|
+
const valueSize = new TextEncoder().encode(value).length
|
|
644
|
+
totalSize += valueSize
|
|
645
|
+
// Validate field value length
|
|
646
|
+
if (valueSize > 100000) {
|
|
647
|
+
// 100KB per field
|
|
648
|
+
return new Response('Field value too long', {status: 400})
|
|
649
|
+
}
|
|
555
650
|
|
|
556
|
-
|
|
557
|
-
value: body,
|
|
558
|
-
writable: true,
|
|
559
|
-
enumerable: true,
|
|
560
|
-
configurable: true,
|
|
561
|
-
})
|
|
651
|
+
totalSize += new TextEncoder().encode(key).length
|
|
562
652
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
configurable: true,
|
|
568
|
-
})
|
|
653
|
+
// Check total size to prevent memory exhaustion
|
|
654
|
+
if (totalSize > parsedLimit) {
|
|
655
|
+
return new Response('Request body size exceeded', {status: 413})
|
|
656
|
+
}
|
|
569
657
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
658
|
+
if (body[key] !== undefined) {
|
|
659
|
+
if (Array.isArray(body[key])) {
|
|
660
|
+
body[key].push(value)
|
|
661
|
+
} else {
|
|
662
|
+
body[key] = [body[key], value]
|
|
663
|
+
}
|
|
664
|
+
} else {
|
|
665
|
+
body[key] = value
|
|
666
|
+
}
|
|
667
|
+
}
|
|
573
668
|
}
|
|
669
|
+
|
|
670
|
+
Object.defineProperty(req, 'body', {
|
|
671
|
+
value: body,
|
|
672
|
+
writable: true,
|
|
673
|
+
enumerable: true,
|
|
674
|
+
configurable: true,
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
Object.defineProperty(req, 'files', {
|
|
678
|
+
value: files,
|
|
679
|
+
writable: true,
|
|
680
|
+
enumerable: true,
|
|
681
|
+
configurable: true,
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
return deferNext ? null : next()
|
|
574
685
|
}
|
|
575
686
|
}
|
|
576
687
|
|
|
@@ -586,6 +697,7 @@ function createMultipartParser(options = {}) {
|
|
|
586
697
|
* @param {Function} options.onError - Custom error handler
|
|
587
698
|
* @param {Function} options.verify - Body verification function
|
|
588
699
|
* @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)
|
|
589
701
|
* @param {string|number} options.jsonLimit - JSON size limit (backward compatibility)
|
|
590
702
|
* @param {string|number} options.textLimit - Text size limit (backward compatibility)
|
|
591
703
|
* @param {string|number} options.urlencodedLimit - URL-encoded size limit (backward compatibility)
|
|
@@ -603,6 +715,7 @@ function createBodyParser(options = {}) {
|
|
|
603
715
|
onError,
|
|
604
716
|
verify,
|
|
605
717
|
parseNestedObjects = true,
|
|
718
|
+
extended,
|
|
606
719
|
// Backward compatibility for direct limit options
|
|
607
720
|
jsonLimit,
|
|
608
721
|
textLimit,
|
|
@@ -626,6 +739,12 @@ function createBodyParser(options = {}) {
|
|
|
626
739
|
urlencoded.urlencodedLimit ||
|
|
627
740
|
urlencoded.limit ||
|
|
628
741
|
'1mb',
|
|
742
|
+
extended:
|
|
743
|
+
urlencoded.extended !== undefined
|
|
744
|
+
? urlencoded.extended
|
|
745
|
+
: extended !== undefined
|
|
746
|
+
? extended
|
|
747
|
+
: true,
|
|
629
748
|
parseNestedObjects:
|
|
630
749
|
urlencoded.parseNestedObjects !== undefined
|
|
631
750
|
? urlencoded.parseNestedObjects
|
|
@@ -640,7 +759,7 @@ function createBodyParser(options = {}) {
|
|
|
640
759
|
// Create parsers with custom types consideration
|
|
641
760
|
const jsonParserMiddleware = createJSONParser({
|
|
642
761
|
...jsonOptions,
|
|
643
|
-
type: 'application/', //
|
|
762
|
+
type: 'application/json', // Match only JSON content types
|
|
644
763
|
deferNext: !!verify, // Defer next if verification is enabled
|
|
645
764
|
})
|
|
646
765
|
const textParserMiddleware = createTextParser({
|
|
@@ -686,7 +805,26 @@ function createBodyParser(options = {}) {
|
|
|
686
805
|
|
|
687
806
|
// Custom JSON parser handling for custom JSON types (case-insensitive)
|
|
688
807
|
if (jsonParser && isJsonType(contentType, jsonTypes)) {
|
|
689
|
-
const
|
|
808
|
+
const contentLength = req.headers.get('content-length')
|
|
809
|
+
const jsonParsedLimit = parseLimit(jsonOptions.limit)
|
|
810
|
+
if (contentLength) {
|
|
811
|
+
const length = parseInt(contentLength)
|
|
812
|
+
if (isNaN(length) || length < 0) {
|
|
813
|
+
return new Response('Invalid content-length header', {status: 400})
|
|
814
|
+
}
|
|
815
|
+
if (length > jsonParsedLimit) {
|
|
816
|
+
return new Response('Request body size exceeded', {status: 413})
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
let text
|
|
820
|
+
try {
|
|
821
|
+
text = await readBodyWithLimit(req, jsonParsedLimit)
|
|
822
|
+
} catch (e) {
|
|
823
|
+
if (e.status === 413) {
|
|
824
|
+
return new Response('Request body size exceeded', {status: 413})
|
|
825
|
+
}
|
|
826
|
+
return new Response('Failed to read request body', {status: 400})
|
|
827
|
+
}
|
|
690
828
|
const body = jsonParser(text)
|
|
691
829
|
Object.defineProperty(req, 'body', {
|
|
692
830
|
value: body,
|
|
@@ -699,7 +837,15 @@ function createBodyParser(options = {}) {
|
|
|
699
837
|
} else {
|
|
700
838
|
// Check if content type matches any JSON types first (including custom ones)
|
|
701
839
|
if (isJsonType(contentType, jsonTypes)) {
|
|
702
|
-
|
|
840
|
+
// Use a JSON parser that matches the detected custom type
|
|
841
|
+
const customTypeJsonParser = createJSONParser({
|
|
842
|
+
...jsonOptions,
|
|
843
|
+
type: jsonTypes.find((t) =>
|
|
844
|
+
contentType.toLowerCase().includes(t.toLowerCase()),
|
|
845
|
+
),
|
|
846
|
+
deferNext: !!verify,
|
|
847
|
+
})
|
|
848
|
+
result = await customTypeJsonParser(req, next)
|
|
703
849
|
} else {
|
|
704
850
|
// Route to appropriate parser based on content type (case-insensitive)
|
|
705
851
|
const lowerContentType = contentType.toLowerCase()
|
|
@@ -735,10 +881,10 @@ function createBodyParser(options = {}) {
|
|
|
735
881
|
if (verify && req.body !== undefined) {
|
|
736
882
|
try {
|
|
737
883
|
// For verification, we need to pass the raw body text
|
|
738
|
-
// Get the original text/data that was parsed
|
|
884
|
+
// Get the original text/data that was parsed (L-3: read from Symbol)
|
|
739
885
|
let rawBody = ''
|
|
740
|
-
if (req
|
|
741
|
-
rawBody = req
|
|
886
|
+
if (req[RAW_BODY_SYMBOL]) {
|
|
887
|
+
rawBody = req[RAW_BODY_SYMBOL]
|
|
742
888
|
}
|
|
743
889
|
verify(req, rawBody)
|
|
744
890
|
} catch (verifyError) {
|
|
@@ -777,6 +923,7 @@ module.exports = {
|
|
|
777
923
|
hasBody,
|
|
778
924
|
shouldParse,
|
|
779
925
|
parseLimit,
|
|
926
|
+
RAW_BODY_SYMBOL,
|
|
780
927
|
}
|
|
781
928
|
|
|
782
929
|
// Default export is the main body parser
|