@blamejs/core 0.7.4 → 0.7.18

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.
Files changed (180) hide show
  1. package/CHANGELOG.md +423 -395
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -284
  5. package/lib/api-key.js +660 -663
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -256
  27. package/lib/cache.js +1206 -1206
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/file-upload.js +213 -10
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -215
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -936
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -264
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -612
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -488
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -519
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -903
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -167
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -745
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -519
  146. package/lib/request-helpers.js +340 -340
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -618
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +184 -4
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -319
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -595
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. package/sbom.cyclonedx.json +7 -7
@@ -1,1091 +1,1170 @@
1
- "use strict";
2
- /**
3
- * body-parser — request-body buffering + parsing middleware.
4
- *
5
- * Populates `req.body` for body-bearing methods (POST, PUT, PATCH,
6
- * DELETE) based on the request's Content-Type. Five sub-parsers ship,
7
- * each with its own size-cap, encoding handling, and pollution defense:
8
- *
9
- * application/json → req.body = parsed object
10
- * (via safe-json — POISONED_KEYS
11
- * stripped, depth + size caps)
12
- * application/x-www-form-urlencoded → req.body = { field: value }
13
- * text/plain → req.body = string
14
- * application/octet-stream → req.body = Buffer (raw)
15
- * multipart/form-data → req.body = { field: value }
16
- * req.files = [{ field, filename,
17
- * mimeType, path, size, hash }]
18
- * req.filesRejected = [{ field,
19
- * filename, mimeType, code, message }]
20
- *
21
- * Multipart parses incrementally — file parts stream to a tmp dir
22
- * rather than buffering in memory. Per-file + total-request size caps
23
- * enforced. Filename sanitization strips path components + traversal.
24
- * Per-file SHA3-512 hash computed during streaming. Tmp files cleaned
25
- * on response end (whether the handler returned or threw).
26
- *
27
- * var bp = b.middleware.bodyParser({
28
- * json: {
29
- * limit: b.constants.BYTES.mib(1),
30
- * strict: true, // require the body to start with {/[
31
- * parseHook: function (parsed) { ... return validatedShape; },
32
- * },
33
- * urlencoded: {
34
- * limit: b.constants.BYTES.mib(1),
35
- * arrayLimit: 100, // for ?tag=a&tag=b → tag: ["a","b"]
36
- * },
37
- * text: { limit: b.constants.BYTES.mib(1), charset: "utf-8" },
38
- * raw: { limit: b.constants.BYTES.mib(10), contentTypes: ["application/octet-stream"] },
39
- * multipart: {
40
- * tmpDir: os.tmpdir(),
41
- * fileSize: b.constants.BYTES.mib(10),
42
- * totalSize: b.constants.BYTES.mib(50),
43
- * fileCount: 20,
44
- * fieldCount: 100,
45
- * fieldSize: b.constants.BYTES.mib(1),
46
- * mimeAllowlist: ["image/jpeg", "image/png", "application/pdf"], // null = any
47
- *
48
- * // Per-part predicate. Runs after sanitization + MIME checks but
49
- * // BEFORE the tmp file opens. Rejected parts are SKIPPED — the body
50
- * // bytes are consumed (we still must scan past them to find the
51
- * // next boundary) but never written to disk; the part metadata
52
- * // lands in req.filesRejected. Surviving files appear in req.files
53
- * // as usual. Sync only — async filtering goes in the route handler.
54
- * fileFilter: function (part) {
55
- * // part = { field, filename, mimeType, partHeaders }
56
- * // return true / undefined → accept
57
- * // return false → reject silently (entry in req.filesRejected)
58
- * // return { reject: true, code, message } → reject with custom info
59
- * return part.field === "avatar" && part.mimeType.startsWith("image/");
60
- * },
61
- *
62
- * // Per-field overrides. maxBytes overrides global fileSize for file
63
- * // parts and fieldSize for text parts. mimeTypes overrides the
64
- * // global mimeAllowlist for the named field; other fields still
65
- * // use the global list.
66
- * fields: {
67
- * avatar: { maxBytes: b.constants.BYTES.mib(2), mimeTypes: ["image/jpeg", "image/png"] },
68
- * document: { maxBytes: b.constants.BYTES.mib(25) },
69
- * },
70
- *
71
- * // When wired, fileFilter rejections emit body-parser.multipart.file_rejected
72
- * // on the audit chain with the field, filename, mime, and reason.
73
- * audit: b.audit,
74
- * },
75
- * // Stash the raw bytes for webhook-signature paths that need to
76
- * // verify the wire bytes rather than the parsed shape.
77
- * keepRawBody: false,
78
- * });
79
- * router.use(bp);
80
- *
81
- * Set any sub-parser to `false` to disable it entirely (the body is
82
- * left untouched for those Content-Types — operator handles them).
83
- *
84
- * Failure modes — all return responses, do NOT call next(err):
85
- * 413 Payload Too Large size-cap exceeded (incl. multipart
86
- * file/total/field caps)
87
- * 415 Unsupported Media Type Content-Type doesn't match any enabled
88
- * sub-parser AND the request has a body
89
- * 400 Bad Request malformed JSON / multipart / urlencoded;
90
- * filename traversal; MIME not on allowlist
91
- *
92
- * Security guarantees:
93
- * - All parsers enforce size caps BEFORE buffering the full body, so
94
- * a large body can't OOM the process.
95
- * - JSON path goes through safe-json — POISONED_KEYS stripped, depth
96
- * + size caps applied at parse, no prototype pollution downstream.
97
- * - Urlencoded uses URLSearchParams with a key-count cap, refuses
98
- * POISONED_KEYS as field names (returns 400).
99
- * - Multipart filename: path components stripped (basename), traversal
100
- * dots collapsed, control characters stripped, length capped at 255.
101
- * Tmp file path is generated by the framework, never derived from
102
- * the operator-supplied filename — so a malicious filename can't
103
- * collide with a sensitive path.
104
- * - Multipart parser refuses fields whose `name` is in POISONED_KEYS
105
- * (consistent with the JSON path).
106
- * - Tmp files set with mode 0o600, parent dir created with 0o700.
107
- * - Cleanup on response end always fires (response close, finish, or
108
- * error) so a crashing handler doesn't leak files.
109
- */
110
-
111
- var fs = require("fs");
112
- var os = require("os");
113
- var path = require("path");
114
- var nodeCrypto = require("node:crypto");
115
- var atomicFile = require("../atomic-file");
116
- var crypto = require("../crypto");
117
- var requestHelpers = require("../request-helpers");
118
- var safeBuffer = require("../safe-buffer");
119
- var safeJson = require("../safe-json");
120
- var validateOpts = require("../validate-opts");
121
- var C = require("../constants");
122
- var { defineClass } = require("../framework-error");
123
-
124
- var HTTP_STATUS = requestHelpers.HTTP_STATUS;
125
- var BodyParserError = defineClass("BodyParserError", { withStatusCode: true });
126
-
127
- // Mirrors safe-json.js + safe-schema.js. Field names that match these
128
- // are refused at the parse boundary regardless of which sub-parser is
129
- // in play — consistent prototype-pollution defense across the framework.
130
- var POISONED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
131
-
132
- // ---- defaults ----
133
-
134
- var DEFAULTS = Object.freeze({
135
- json: {
136
- limit: C.BYTES.mib(1),
137
- strict: true,
138
- contentTypes: ["application/json", "application/json; charset=utf-8"],
139
- charset: "utf-8",
140
- },
141
- urlencoded: {
142
- limit: C.BYTES.mib(1),
143
- arrayLimit: 100,
144
- contentTypes: ["application/x-www-form-urlencoded"],
145
- charset: "utf-8",
146
- },
147
- text: {
148
- limit: C.BYTES.mib(1),
149
- charset: "utf-8",
150
- contentTypes: ["text/plain"],
151
- },
152
- raw: {
153
- limit: C.BYTES.mib(10),
154
- contentTypes: ["application/octet-stream"],
155
- },
156
- multipart: {
157
- tmpDir: null, // resolved per-instance from os.tmpdir()
158
- fileSize: C.BYTES.mib(10),
159
- totalSize: C.BYTES.mib(50),
160
- fileCount: 20,
161
- fieldCount: 100,
162
- fieldSize: C.BYTES.mib(1),
163
- mimeAllowlist: null,
164
- fileFilter: null, // fn({ field, filename, mimeType, partHeaders }) → bool | { reject, code, message }
165
- fields: null, // per-field overrides: { name: { maxBytes?, mimeTypes? } }
166
- audit: null, // when wired, file-rejection emits an audit event
167
- contentTypes: ["multipart/form-data"],
168
- },
169
- });
170
-
171
- var BODY_BEARING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
172
-
173
- // Headroom on top of arrayLimit before declaring a key-bomb. Sized so a
174
- // reasonable form (a few hundred fields with repeated names) fits without
175
- // tripping the cap.
176
- var URLENCODED_KEY_HEADROOM = C.BYTES.bytes(1000);
177
-
178
- // ---- helpers ----
179
-
180
- function _contentType(req) {
181
- var ct = req.headers && req.headers["content-type"];
182
- if (typeof ct !== "string") return { type: "", params: {} };
183
- var idx = ct.indexOf(";");
184
- var type = (idx === -1 ? ct : ct.slice(0, idx)).trim().toLowerCase();
185
- var params = {};
186
- if (idx !== -1) {
187
- var rest = ct.slice(idx + 1);
188
- var parts = rest.split(";");
189
- for (var i = 0; i < parts.length; i++) {
190
- var p = parts[i].trim();
191
- var eq = p.indexOf("=");
192
- if (eq === -1) continue;
193
- var k = p.slice(0, eq).trim().toLowerCase();
194
- var v = p.slice(eq + 1).trim();
195
- if (v.length >= 2 && v[0] === '"' && v[v.length - 1] === '"') v = v.slice(1, -1);
196
- params[k] = v;
197
- }
198
- }
199
- return { type: type, params: params };
200
- }
201
-
202
- function _typeMatches(actual, allowed) {
203
- for (var i = 0; i < allowed.length; i++) {
204
- var a = allowed[i].toLowerCase();
205
- // Match either exact type or "type/*" prefix
206
- if (a === actual) return true;
207
- var slash = a.indexOf("/");
208
- if (slash !== -1 && a.slice(slash + 1) === "*" &&
209
- actual.indexOf(a.slice(0, slash + 1)) === 0) return true;
210
- }
211
- return false;
212
- }
213
-
214
- // RFC 9112 §6.1: Content-Length MUST be a sequence of decimal digits with
215
- // no whitespace, sign, or trailing garbage. parseInt("123abc") returning
216
- // 123 is the lenient parse that lets malformed headers slip past the
217
- // preflight cap; the strict regex catches them at the boundary.
218
- var STRICT_CONTENT_LENGTH = /^\d+$/;
219
-
220
- function _parseContentLength(cl) {
221
- if (typeof cl !== "string" || !STRICT_CONTENT_LENGTH.test(cl)) return null;
222
- var n = Number(cl);
223
- return isFinite(n) ? n : null;
224
- }
225
-
226
- function _hasBody(req) {
227
- if (!BODY_BEARING_METHODS.has(req.method)) return false;
228
- var cl = req.headers && req.headers["content-length"];
229
- if (typeof cl === "string") {
230
- var clNum = _parseContentLength(cl);
231
- // Spec-shaped zero (the only RFC 9112 §6.1 zero) → no body. Malformed
232
- // values (non-decimal-digits) flow through as "yes, has body" so the
233
- // downstream _bufferBody call rejects with 400 — silently treating
234
- // a malformed header as "no body" would let the request slip past
235
- // the parser entirely.
236
- if (clNum === 0) return false;
237
- return true;
238
- }
239
- var te = req.headers && req.headers["transfer-encoding"];
240
- if (typeof te === "string" && te.length > 0) return true;
241
- return false;
242
- }
243
-
244
- function _writeError(res, status, message, code) {
245
- if (res.headersSent) return;
246
- var body = JSON.stringify({ error: message, code: code });
247
- res.writeHead(status, {
248
- "Content-Type": "application/json; charset=utf-8",
249
- "Content-Length": Buffer.byteLength(body),
250
- });
251
- res.end(body);
252
- }
253
-
254
- // Buffer the raw body up to `limit`. If Content-Length is known and
255
- // exceeds limit, reject IMMEDIATELY without reading any bytes.
256
- function _bufferBody(req, limit) {
257
- return new Promise(function (resolve, reject) {
258
- var cl = req.headers && req.headers["content-length"];
259
- if (typeof cl === "string") {
260
- var clNum = _parseContentLength(cl);
261
- if (clNum === null) {
262
- // RFC 9112 §6.1 — malformed Content-Length is a 400.
263
- reject(new BodyParserError(
264
- "body-parser/bad-content-length",
265
- "Content-Length is not a sequence of decimal digits: " + JSON.stringify(cl),
266
- true, HTTP_STATUS.BAD_REQUEST
267
- ));
268
- return;
269
- }
270
- if (clNum > limit) {
271
- reject(new BodyParserError(
272
- "body-parser/too-large",
273
- "request body exceeds limit (" + clNum + " > " + limit + ")",
274
- true, HTTP_STATUS.PAYLOAD_TOO_LARGE
275
- ));
276
- return;
277
- }
278
- }
279
- var collector = safeBuffer.boundedChunkCollector({
280
- maxBytes: limit,
281
- errorClass: BodyParserError,
282
- sizeCode: "body-parser/too-large",
283
- sizeMessage: "request body exceeds limit",
284
- });
285
- var done = false;
286
- req.on("data", function (chunk) {
287
- if (done) return;
288
- try { collector.push(chunk); }
289
- catch (e) {
290
- done = true;
291
- try { req.destroy(); } catch (_e) { /* socket already closed */ }
292
- if (e && e.isBodyParserError) e.statusCode = HTTP_STATUS.PAYLOAD_TOO_LARGE;
293
- reject(e);
294
- return;
295
- }
296
- });
297
- req.on("end", function () { if (!done) { done = true; resolve(collector.result()); } });
298
- req.on("error", function (e) { if (!done) { done = true; reject(e); } });
299
- });
300
- }
301
-
302
- // ---- JSON parser ----
303
-
304
- async function _parseJson(req, opts) {
305
- var buf = await _bufferBody(req, opts.limit);
306
- if (buf.length === 0) return undefined;
307
- var text = buf.toString(opts.charset);
308
- if (opts.strict) {
309
- var head = text.replace(/^[\s\u00A0\uFEFF]+/, "")[0];
310
- if (head !== "{" && head !== "[") {
311
- throw new BodyParserError(
312
- "body-parser/json-strict",
313
- "JSON body must start with '{' or '[' (strict mode)",
314
- true, HTTP_STATUS.BAD_REQUEST
315
- );
316
- }
317
- }
318
- var parsed;
319
- try {
320
- // safe-json strips POISONED_KEYS and enforces depth + byte caps.
321
- parsed = safeJson.parse(text, { maxBytes: opts.limit });
322
- } catch (e) {
323
- throw new BodyParserError(
324
- "body-parser/json-malformed",
325
- "JSON parse failed: " + ((e && e.message) || String(e)),
326
- true, HTTP_STATUS.BAD_REQUEST
327
- );
328
- }
329
- if (typeof opts.parseHook === "function") {
330
- try { parsed = opts.parseHook(parsed); }
331
- catch (e) {
332
- throw new BodyParserError(
333
- "body-parser/json-hook",
334
- "JSON parseHook failed: " + ((e && e.message) || String(e)),
335
- true, HTTP_STATUS.BAD_REQUEST
336
- );
337
- }
338
- }
339
- return parsed;
340
- }
341
-
342
- // ---- urlencoded parser ----
343
-
344
- async function _parseUrlencoded(req, opts) {
345
- var buf = await _bufferBody(req, opts.limit);
346
- if (buf.length === 0) return {};
347
- var text = buf.toString(opts.charset);
348
- var sp;
349
- try { sp = new URLSearchParams(text); }
350
- catch (e) {
351
- throw new BodyParserError(
352
- "body-parser/urlencoded-malformed",
353
- "urlencoded parse failed: " + ((e && e.message) || String(e)),
354
- true, HTTP_STATUS.BAD_REQUEST
355
- );
356
- }
357
- var out = {};
358
- var keyCount = 0;
359
- // Track repeated keys so [a=1, a=2] becomes a: ["1","2"] rather than overwriting.
360
- var seen = Object.create(null);
361
- var keys = [];
362
- sp.forEach(function (value, key) { keys.push([key, value]); });
363
- for (var i = 0; i < keys.length; i++) {
364
- var k = keys[i][0];
365
- var v = keys[i][1];
366
- if (POISONED_KEYS.has(k)) {
367
- throw new BodyParserError(
368
- "body-parser/urlencoded-poisoned-key",
369
- "urlencoded body contains forbidden key '" + k + "' (prototype-pollution defense)",
370
- true, HTTP_STATUS.BAD_REQUEST
371
- );
372
- }
373
- keyCount++;
374
- if (keyCount > opts.arrayLimit + URLENCODED_KEY_HEADROOM) { // soft cap on total keys
375
- throw new BodyParserError(
376
- "body-parser/urlencoded-too-many-fields",
377
- "urlencoded body has too many fields",
378
- true, 413
379
- );
380
- }
381
- if (Object.prototype.hasOwnProperty.call(seen, k)) {
382
- if (Array.isArray(out[k])) {
383
- if (out[k].length >= opts.arrayLimit) {
384
- throw new BodyParserError(
385
- "body-parser/urlencoded-array-too-large",
386
- "urlencoded array '" + k + "' exceeds arrayLimit (" + opts.arrayLimit + ")",
387
- true, 413
388
- );
389
- }
390
- out[k].push(v);
391
- } else {
392
- out[k] = [out[k], v];
393
- }
394
- } else {
395
- out[k] = v;
396
- seen[k] = true;
397
- }
398
- }
399
- return out;
400
- }
401
-
402
- // ---- text parser ----
403
-
404
- async function _parseText(req, opts) {
405
- var buf = await _bufferBody(req, opts.limit);
406
- return buf.toString(opts.charset);
407
- }
408
-
409
- // ---- raw parser ----
410
-
411
- async function _parseRaw(req, opts) {
412
- return await _bufferBody(req, opts.limit);
413
- }
414
-
415
- // ---- multipart parser ----
416
- //
417
- // Streaming RFC 7578 multipart/form-data parser. Walks an incoming
418
- // request stream byte-by-byte (well, chunk-by-chunk with a sliding
419
- // look-ahead window) and emits parts as they're encountered. File
420
- // parts stream straight to disk; field parts buffer in memory up to
421
- // fieldSize.
422
- //
423
- // State machine:
424
- // INITIAL skip preamble until first boundary
425
- // AFTER_BD just consumed a boundary; check next two bytes for
426
- // "--" (end-of-multipart) or "\r\n" (next part)
427
- // HEADERS reading per-part headers until \r\n\r\n
428
- // BODY streaming part body; watch for \r\n--<boundary>
429
- // DONE all parts read
430
-
431
- var MP_INITIAL = 0;
432
- var MP_AFTER_BD = 1;
433
- var MP_HEADERS = 2;
434
- var MP_BODY = 3;
435
- var MP_DONE = 4;
436
-
437
- function _sanitizeFilename(name) {
438
- if (typeof name !== "string") return null;
439
- // Strip every path component — keep only basename (last segment).
440
- // Both POSIX and Windows separators handled, plus URL-encoded.
441
- var s = name.replace(/\\/g, "/");
442
- var idx = s.lastIndexOf("/");
443
- if (idx !== -1) s = s.slice(idx + 1);
444
- // Drop control characters, NUL, leading/trailing dots.
445
- s = s.replace(/\p{Cc}/gu, "");
446
- s = s.replace(/^\.+/, "").replace(/\.+$/, "");
447
- if (s.length === 0) return null;
448
- if (s.length > 255) s = s.slice(0, 255);
449
- // Refuse any remaining traversal artifact.
450
- if (s === "." || s === "..") return null;
451
- return s;
452
- }
453
-
454
- function _parseMultipartHeaders(rawHeaders) {
455
- // Each line is `Header-Name: value`. Common headers: Content-Disposition,
456
- // Content-Type, Content-Transfer-Encoding. Unknown headers are ignored.
457
- var lines = rawHeaders.split("\r\n");
458
- var out = {};
459
- for (var i = 0; i < lines.length; i++) {
460
- var line = lines[i];
461
- if (!line) continue;
462
- var idx = line.indexOf(":");
463
- if (idx === -1) continue;
464
- var k = line.slice(0, idx).trim().toLowerCase();
465
- var v = line.slice(idx + 1).trim();
466
- out[k] = v;
467
- }
468
- return out;
469
- }
470
-
471
- function _parseHeaderParams(headerValue) {
472
- // Content-Disposition: form-data; name="field"; filename="x.txt"
473
- // Returns { _value: "form-data", name: "field", filename: "x.txt" }
474
- var out = { _value: "" };
475
- if (!headerValue) return out;
476
- var parts = headerValue.split(";");
477
- out._value = parts[0].trim().toLowerCase();
478
- for (var i = 1; i < parts.length; i++) {
479
- var p = parts[i].trim();
480
- var eq = p.indexOf("=");
481
- if (eq === -1) continue;
482
- var k = p.slice(0, eq).trim().toLowerCase();
483
- var v = p.slice(eq + 1).trim();
484
- if (v.length >= 2 && v[0] === '"' && v[v.length - 1] === '"') v = v.slice(1, -1);
485
- out[k] = v;
486
- }
487
- return out;
488
- }
489
-
490
- async function _parseMultipart(req, opts, ctParams) {
491
- var boundary = ctParams.boundary;
492
- if (typeof boundary !== "string" || boundary.length === 0) {
493
- throw new BodyParserError(
494
- "body-parser/multipart-no-boundary",
495
- "multipart Content-Type missing boundary parameter",
496
- true, HTTP_STATUS.BAD_REQUEST
497
- );
498
- }
499
- // Resolve tmpDir per-request so directory-creation failure surfaces as a
500
- // structured error rather than a deferred fs throw.
501
- var tmpDir = opts.tmpDir || path.join(os.tmpdir(), "blamejs-uploads");
502
- try { atomicFile.ensureDir(tmpDir, 0o700); }
503
- catch (e) {
504
- throw new BodyParserError(
505
- "body-parser/multipart-tmpdir",
506
- "could not create multipart tmp dir '" + tmpDir + "': " + ((e && e.message) || String(e)),
507
- true, 500
508
- );
509
- }
510
-
511
- var boundaryBuf = Buffer.from("--" + boundary);
512
- var boundaryDelimBuf = Buffer.from("\r\n--" + boundary);
513
-
514
- var fields = {};
515
- var files = [];
516
- var filesRejected = [];
517
- var totalRead = 0;
518
- var fileCount = 0;
519
- var fieldCount = 0;
520
- var fileSize = opts.fileSize;
521
- var totalSize = opts.totalSize;
522
- var fileLimit = opts.fileCount;
523
- var fieldLimit = opts.fieldCount;
524
- var fieldSize = opts.fieldSize;
525
- var mimeAllowlist = Array.isArray(opts.mimeAllowlist) ? opts.mimeAllowlist : null;
526
- var fileFilter = typeof opts.fileFilter === "function" ? opts.fileFilter : null;
527
- var perField = (opts.fields && typeof opts.fields === "object") ? opts.fields : null;
528
- var auditInst = (opts.audit && typeof opts.audit.safeEmit === "function") ? opts.audit : null;
529
-
530
- var state = MP_INITIAL;
531
- var pending = Buffer.alloc(0);
532
- var currentHeaders = null;
533
- var currentField = null;
534
- var currentFilename = null;
535
- var currentMime = null;
536
- var currentTmpPath = null;
537
- var currentFd = null;
538
- var currentSize = 0;
539
- var currentHash = null;
540
- var currentBuf = null; // for fields (in-memory accumulator)
541
- var currentDiscarded = false; // true when fileFilter rejected the part — body bytes are
542
- // still consumed (we have to read past them to find the next
543
- // boundary) but never written to disk.
544
- var currentEffectiveLimit = 0; // per-field-or-global cap; recomputed at part start.
545
-
546
- function _resetCurrent() {
547
- currentHeaders = null;
548
- currentField = null;
549
- currentFilename = null;
550
- currentMime = null;
551
- currentTmpPath = null;
552
- if (currentFd !== null) { try { fs.closeSync(currentFd); } catch (_e) { /* fd already closed */ } currentFd = null; }
553
- currentSize = 0;
554
- currentHash = null;
555
- currentBuf = null;
556
- currentDiscarded = false;
557
- currentEffectiveLimit = 0;
558
- }
559
-
560
- function _emitRejection(field, filename, mimeType, code, message) {
561
- filesRejected.push({
562
- field: field,
563
- filename: filename,
564
- mimeType: mimeType,
565
- code: code,
566
- message: message || null,
567
- });
568
- if (auditInst) {
569
- try {
570
- auditInst.safeEmit({
571
- action: "body-parser.multipart.file_rejected",
572
- outcome: "denied",
573
- resource: { kind: "multipart.file", id: field + (filename ? ":" + filename : "") },
574
- metadata: { field: field, filename: filename, mimeType: mimeType, code: code, message: message || null },
575
- });
576
- } catch (_e) { /* audit best-effort */ }
577
- }
578
- }
579
-
580
- function _cleanup() {
581
- if (currentFd !== null) { try { fs.closeSync(currentFd); } catch (_e) { /* fd already closed */ } currentFd = null; }
582
- if (currentTmpPath) { try { fs.unlinkSync(currentTmpPath); } catch (_e) { /* tmp file already removed */ } }
583
- for (var i = 0; i < files.length; i++) {
584
- try { fs.unlinkSync(files[i].path); } catch (_e) { /* tmp file already removed */ }
585
- }
586
- }
587
-
588
- try {
589
- return await new Promise(function (resolve, reject) {
590
- function done(err, value) {
591
- // De-dup completion — req error + req end can both fire.
592
- if (resolved) return;
593
- resolved = true;
594
- if (err) {
595
- _cleanup();
596
- reject(err);
597
- } else {
598
- resolve(value);
599
- }
600
- }
601
- var resolved = false;
602
-
603
- function processBuffer() {
604
- // Re-enter the state machine until we can't make progress.
605
- while (true) {
606
- if (state === MP_INITIAL) {
607
- // Find the first boundary marker (without the leading \r\n
608
- // since the preamble may begin with the boundary directly).
609
- var firstIdx = pending.indexOf(boundaryBuf);
610
- if (firstIdx === -1) {
611
- // Need more data.
612
- if (pending.length > boundary.length + 100) {
613
- // Drop preamble bytes to bound memory; keep the last
614
- // boundary.length+4 bytes as look-ahead.
615
- pending = pending.slice(pending.length - boundary.length - 4);
616
- }
617
- return;
618
- }
619
- pending = pending.slice(firstIdx + boundaryBuf.length);
620
- state = MP_AFTER_BD;
621
- continue;
622
- }
623
- if (state === MP_AFTER_BD) {
624
- if (pending.length < 2) return;
625
- if (pending[0] === 0x2d && pending[1] === 0x2d) { // "--"
626
- state = MP_DONE;
627
- done(null, { fields: fields, files: files, filesRejected: filesRejected });
628
- return;
629
- }
630
- if (pending[0] === 0x0d && pending[1] === 0x0a) { // "\r\n"
631
- pending = pending.slice(2);
632
- state = MP_HEADERS;
633
- continue;
634
- }
635
- // Tolerate transport-added \n only.
636
- if (pending[0] === 0x0a) {
637
- pending = pending.slice(1);
638
- state = MP_HEADERS;
639
- continue;
640
- }
641
- done(new BodyParserError("body-parser/multipart-malformed",
642
- "expected --, \\r\\n, or \\n after boundary", true, HTTP_STATUS.BAD_REQUEST));
643
- return;
644
- }
645
- if (state === MP_HEADERS) {
646
- // Read until \r\n\r\n.
647
- var headEnd = pending.indexOf("\r\n\r\n");
648
- if (headEnd === -1) {
649
- if (pending.length > C.BYTES.kib(16)) {
650
- done(new BodyParserError("body-parser/multipart-headers-too-large",
651
- "multipart part headers exceed 16KB", true, 413));
652
- return;
653
- }
654
- return;
655
- }
656
- currentHeaders = _parseMultipartHeaders(pending.slice(0, headEnd).toString("utf8"));
657
- pending = pending.slice(headEnd + 4);
658
- // Decode Content-Disposition.
659
- var cd = _parseHeaderParams(currentHeaders["content-disposition"]);
660
- if (cd._value !== "form-data" || typeof cd.name !== "string" || cd.name.length === 0) {
661
- done(new BodyParserError("body-parser/multipart-bad-disposition",
662
- "multipart part missing form-data Content-Disposition", true, HTTP_STATUS.BAD_REQUEST));
663
- return;
664
- }
665
- if (POISONED_KEYS.has(cd.name)) {
666
- done(new BodyParserError("body-parser/multipart-poisoned-field",
667
- "multipart field '" + cd.name + "' is forbidden (prototype-pollution defense)",
668
- true, HTTP_STATUS.BAD_REQUEST));
669
- return;
670
- }
671
- currentField = cd.name;
672
- if (typeof cd.filename === "string") {
673
- currentFilename = _sanitizeFilename(cd.filename);
674
- if (!currentFilename) {
675
- done(new BodyParserError("body-parser/multipart-bad-filename",
676
- "multipart part filename did not survive sanitization (path traversal or empty)",
677
- true, HTTP_STATUS.BAD_REQUEST));
678
- return;
679
- }
680
- currentMime = currentHeaders["content-type"] || "application/octet-stream";
681
- // Per-field MIME allowlist takes precedence over the global one
682
- // for this field; global applies to fields without an entry.
683
- var fieldRule = perField ? perField[currentField] : null;
684
- var perFieldMime = (fieldRule && Array.isArray(fieldRule.mimeTypes))
685
- ? fieldRule.mimeTypes : null;
686
- if (perFieldMime) {
687
- if (perFieldMime.indexOf(currentMime) === -1) {
688
- done(new BodyParserError("body-parser/multipart-mime-not-allowed",
689
- "multipart file '" + currentField + "' MIME '" + currentMime +
690
- "' is not on the per-field allowlist",
691
- true, 415));
692
- return;
693
- }
694
- } else if (mimeAllowlist && mimeAllowlist.indexOf(currentMime) === -1) {
695
- done(new BodyParserError("body-parser/multipart-mime-not-allowed",
696
- "multipart file MIME '" + currentMime + "' is not on the allowlist",
697
- true, 415));
698
- return;
699
- }
700
- fileCount++;
701
- if (fileCount > fileLimit) {
702
- done(new BodyParserError("body-parser/multipart-too-many-files",
703
- "multipart fileCount exceeds limit (" + fileLimit + ")",
704
- true, 413));
705
- return;
706
- }
707
- // Per-field cap overrides global fileSize for this field.
708
- currentEffectiveLimit = (fieldRule && typeof fieldRule.maxBytes === "number")
709
- ? fieldRule.maxBytes : fileSize;
710
-
711
- // fileFilter runs AFTER sanitize + MIME checks but BEFORE the
712
- // tmp file opens. Synchronous so the parser can decide between
713
- // disk-write and discard-bytes without buffering the part.
714
- if (fileFilter) {
715
- var filterVerdict;
716
- try {
717
- filterVerdict = fileFilter({
718
- field: currentField,
719
- filename: currentFilename,
720
- mimeType: currentMime,
721
- partHeaders: currentHeaders,
722
- });
723
- } catch (e) {
724
- done(new BodyParserError("body-parser/multipart-file-filter-throw",
725
- "fileFilter threw: " + ((e && e.message) || String(e)),
726
- true, 500));
727
- return;
728
- }
729
- if (filterVerdict === false ||
730
- (filterVerdict && typeof filterVerdict === "object" && filterVerdict.reject)) {
731
- var rejCode = (filterVerdict && filterVerdict.code) || "fileFilter";
732
- var rejMessage = (filterVerdict && filterVerdict.message) || null;
733
- _emitRejection(currentField, currentFilename, currentMime, rejCode, rejMessage);
734
- // Read past the body bytes (we still must find the next
735
- // boundary) but never open a tmp file or push to req.files.
736
- currentDiscarded = true;
737
- fileCount--; // doesn't count toward the limit since it didn't land
738
- currentSize = 0;
739
- state = MP_BODY;
740
- continue;
741
- }
742
- }
743
-
744
- // Generate the tmp path never derived from the
745
- // operator-supplied filename.
746
- var unique = crypto.generateToken(C.BYTES.bytes(16));
747
- currentTmpPath = path.join(tmpDir, "blamejs-up-" + unique);
748
- try {
749
- currentFd = fs.openSync(currentTmpPath, "wx", 0o600);
750
- } catch (e) {
751
- done(new BodyParserError("body-parser/multipart-tmp-open",
752
- "could not open multipart tmp file: " + ((e && e.message) || String(e)),
753
- true, 500));
754
- return;
755
- }
756
- currentHash = nodeCrypto.createHash("sha3-512");
757
- currentSize = 0;
758
- } else {
759
- fieldCount++;
760
- if (fieldCount > fieldLimit) {
761
- done(new BodyParserError("body-parser/multipart-too-many-fields",
762
- "multipart fieldCount exceeds limit (" + fieldLimit + ")",
763
- true, 413));
764
- return;
765
- }
766
- // Per-field cap overrides global fieldSize for text parts too.
767
- var textFieldRule = perField ? perField[currentField] : null;
768
- currentEffectiveLimit = (textFieldRule && typeof textFieldRule.maxBytes === "number")
769
- ? textFieldRule.maxBytes : fieldSize;
770
- currentBuf = [];
771
- currentSize = 0;
772
- }
773
- state = MP_BODY;
774
- continue;
775
- }
776
- if (state === MP_BODY) {
777
- // Look for the next boundary. The marker we want is
778
- // \r\n--<boundary>
779
- // Anything before it is part body. We need to keep at least
780
- // boundary.length + 4 bytes in `pending` so we don't emit a
781
- // partial match as body bytes.
782
- var bdIdx = pending.indexOf(boundaryDelimBuf);
783
- var emitLen;
784
- if (bdIdx === -1) {
785
- // No marker yet — emit everything except the trailing
786
- // boundary-length+4 bytes (might be the start of the marker).
787
- if (pending.length <= boundaryDelimBuf.length) return;
788
- emitLen = pending.length - boundaryDelimBuf.length;
789
- } else {
790
- emitLen = bdIdx;
791
- }
792
- if (emitLen > 0) {
793
- var bodyChunk = pending.slice(0, emitLen);
794
- if (currentDiscarded) {
795
- // fileFilter rejected this part — read past the bytes to find
796
- // the next boundary but never write to disk. totalSize still
797
- // applies as a per-request DoS guard.
798
- totalRead += bodyChunk.length;
799
- if (totalRead > totalSize) {
800
- done(new BodyParserError("body-parser/multipart-total-too-large",
801
- "multipart total request size exceeds totalSize (" + totalSize + ")",
802
- true, 413));
803
- return;
804
- }
805
- } else if (currentFd !== null) {
806
- // File partwrite to disk.
807
- currentSize += bodyChunk.length;
808
- if (currentSize > currentEffectiveLimit) {
809
- var perFieldFile = (perField && perField[currentField] &&
810
- typeof perField[currentField].maxBytes === "number");
811
- done(new BodyParserError("body-parser/multipart-file-too-large",
812
- "multipart file '" + currentField + "' exceeds " +
813
- (perFieldFile ? "per-field maxBytes" : "fileSize") +
814
- " (" + currentEffectiveLimit + ")",
815
- true, 413));
816
- return;
817
- }
818
- totalRead += bodyChunk.length;
819
- if (totalRead > totalSize) {
820
- done(new BodyParserError("body-parser/multipart-total-too-large",
821
- "multipart total request size exceeds totalSize (" + totalSize + ")",
822
- true, 413));
823
- return;
824
- }
825
- try {
826
- var written = 0;
827
- while (written < bodyChunk.length) {
828
- written += fs.writeSync(currentFd, bodyChunk, written, bodyChunk.length - written);
829
- }
830
- } catch (e) {
831
- done(new BodyParserError("body-parser/multipart-tmp-write",
832
- "multipart tmp write failed: " + ((e && e.message) || String(e)),
833
- true, 500));
834
- return;
835
- }
836
- currentHash.update(bodyChunk);
837
- } else {
838
- // Field part buffer in memory up to per-field-or-global cap.
839
- currentSize += bodyChunk.length;
840
- if (currentSize > currentEffectiveLimit) {
841
- var perFieldText = (perField && perField[currentField] &&
842
- typeof perField[currentField].maxBytes === "number");
843
- done(new BodyParserError("body-parser/multipart-field-too-large",
844
- "multipart field '" + currentField + "' exceeds " +
845
- (perFieldText ? "per-field maxBytes" : "fieldSize") +
846
- " (" + currentEffectiveLimit + ")",
847
- true, 413));
848
- return;
849
- }
850
- totalRead += bodyChunk.length;
851
- if (totalRead > totalSize) {
852
- done(new BodyParserError("body-parser/multipart-total-too-large",
853
- "multipart total request size exceeds totalSize (" + totalSize + ")",
854
- true, 413));
855
- return;
856
- }
857
- currentBuf.push(bodyChunk);
858
- }
859
- pending = pending.slice(emitLen);
860
- }
861
- if (bdIdx === -1) return; // need more data
862
- // Consume the boundary delimiter; transition to AFTER_BD.
863
- pending = pending.slice(boundaryDelimBuf.length);
864
- // Finalize the current part.
865
- if (currentDiscarded) {
866
- // fileFilter rejected — already recorded in filesRejected; no
867
- // tmp file was opened, nothing to clean up here.
868
- } else if (currentFd !== null) {
869
- try { fs.closeSync(currentFd); } catch (_e) { /* fd already closed */ }
870
- currentFd = null;
871
- files.push({
872
- field: currentField,
873
- filename: currentFilename,
874
- mimeType: currentMime,
875
- path: currentTmpPath,
876
- size: currentSize,
877
- hash: currentHash.digest("hex"),
878
- });
879
- } else {
880
- // Field part — flatten + decode UTF-8.
881
- var fbuf = Buffer.concat(currentBuf);
882
- var text = fbuf.toString("utf8");
883
- // Repeated field name array, matching urlencoded parser.
884
- if (Object.prototype.hasOwnProperty.call(fields, currentField)) {
885
- if (Array.isArray(fields[currentField])) fields[currentField].push(text);
886
- else fields[currentField] = [fields[currentField], text];
887
- } else {
888
- fields[currentField] = text;
889
- }
890
- }
891
- currentHeaders = null;
892
- currentField = null;
893
- currentFilename = null;
894
- currentMime = null;
895
- currentTmpPath = null;
896
- currentSize = 0;
897
- currentHash = null;
898
- currentBuf = null;
899
- currentDiscarded = false;
900
- currentEffectiveLimit = 0;
901
- state = MP_AFTER_BD;
902
- continue;
903
- }
904
- if (state === MP_DONE) return;
905
- }
906
- }
907
-
908
- req.on("data", function (chunk) {
909
- if (resolved) return;
910
- pending = Buffer.concat([pending, chunk]);
911
- try { processBuffer(); }
912
- catch (e) {
913
- done(new BodyParserError("body-parser/multipart-internal",
914
- "multipart internal parse error: " + ((e && e.message) || String(e)),
915
- true, 500));
916
- }
917
- });
918
- req.on("end", function () {
919
- if (resolved) return;
920
- if (state !== MP_DONE) {
921
- done(new BodyParserError("body-parser/multipart-truncated",
922
- "multipart stream ended before final boundary", true, HTTP_STATUS.BAD_REQUEST));
923
- return;
924
- }
925
- });
926
- req.on("error", function (e) {
927
- if (resolved) return;
928
- done(new BodyParserError("body-parser/multipart-stream",
929
- "multipart stream error: " + ((e && e.message) || String(e)), true, HTTP_STATUS.BAD_REQUEST));
930
- });
931
- });
932
- } catch (e) {
933
- // Already cleaned up via done(err).
934
- throw e;
935
- }
936
- }
937
-
938
- // ---- main middleware factory ----
939
-
940
- function create(opts) {
941
- opts = opts || {};
942
- validateOpts(opts, [
943
- "json", "urlencoded", "text", "raw", "multipart", "keepRawBody",
944
- ], "middleware.bodyParser");
945
-
946
- function _resolve(name) {
947
- if (opts[name] === false) return null; // disabled
948
- return Object.assign({}, DEFAULTS[name], opts[name] || {});
949
- }
950
- var jsonOpts = _resolve("json");
951
- var urlencodedOpts = _resolve("urlencoded");
952
- var textOpts = _resolve("text");
953
- var rawOpts = _resolve("raw");
954
- var multipartOpts = _resolve("multipart");
955
- var keepRawBody = !!opts.keepRawBody;
956
-
957
- return async function bodyParser(req, res, next) {
958
- if (!_hasBody(req)) return next();
959
- if (req.body !== undefined) return next(); // already parsed by an earlier middleware
960
-
961
- var ct = _contentType(req);
962
-
963
- try {
964
- if (jsonOpts && _typeMatches(ct.type, jsonOpts.contentTypes)) {
965
- if (keepRawBody) {
966
- var rawBuf = await _bufferBody(req, jsonOpts.limit);
967
- req.bodyRaw = rawBuf;
968
- req.body = rawBuf.length === 0 ? undefined : await _parseJsonFromBuf(rawBuf, jsonOpts);
969
- } else {
970
- req.body = await _parseJson(req, jsonOpts);
971
- }
972
- return next();
973
- }
974
- if (urlencodedOpts && _typeMatches(ct.type, urlencodedOpts.contentTypes)) {
975
- req.body = await _parseUrlencoded(req, urlencodedOpts);
976
- return next();
977
- }
978
- if (multipartOpts && _typeMatches(ct.type, multipartOpts.contentTypes)) {
979
- var mpResult = await _parseMultipart(req, multipartOpts, ct.params);
980
- req.body = mpResult.fields;
981
- req.files = mpResult.files;
982
- req.filesRejected = mpResult.filesRejected || [];
983
- // Cleanup tmp files when the response finishes / closes / errors,
984
- // regardless of whether the handler returned cleanly. Operators
985
- // who want to KEEP a file move it elsewhere inside the handler.
986
- var cleanedUp = false;
987
- function cleanup() {
988
- if (cleanedUp) return;
989
- cleanedUp = true;
990
- for (var i = 0; i < mpResult.files.length; i++) {
991
- try { fs.unlinkSync(mpResult.files[i].path); } catch (_e) { /* tmp file already removed */ }
992
- }
993
- }
994
- res.on("finish", cleanup);
995
- res.on("close", cleanup);
996
- return next();
997
- }
998
- if (textOpts && _typeMatches(ct.type, textOpts.contentTypes)) {
999
- req.body = await _parseText(req, textOpts);
1000
- return next();
1001
- }
1002
- if (rawOpts && _typeMatches(ct.type, rawOpts.contentTypes)) {
1003
- req.body = await _parseRaw(req, rawOpts);
1004
- return next();
1005
- }
1006
- // Body present but no enabled sub-parser matches the Content-Type.
1007
- // Don't reject silently — operators with a custom parser opted in
1008
- // by NOT enabling any sub-parser for this Content-Type would skip
1009
- // bodyParser entirely. Reaching here means: enabled parsers exist
1010
- // but none match this body. 415 is honest about the mismatch.
1011
- _writeError(res, 415,
1012
- "Unsupported Content-Type '" + ct.type + "'. Enable a matching sub-parser or send a different type.",
1013
- "body-parser/unsupported-content-type"
1014
- );
1015
- } catch (e) {
1016
- var status = (e && typeof e.statusCode === "number") ? e.statusCode : HTTP_STATUS.BAD_REQUEST;
1017
- var code = (e && typeof e.code === "string") ? e.code : "body-parser/error";
1018
- var message = (e && e.message) ? e.message : String(e);
1019
- _writeError(res, status, message, code);
1020
- }
1021
- };
1022
- }
1023
-
1024
- // Helper used by keepRawBody path — re-uses the JSON pipeline but
1025
- // works from an already-buffered Buffer (so we don't read req twice).
1026
- async function _parseJsonFromBuf(buf, opts) {
1027
- var text = buf.toString(opts.charset);
1028
- if (opts.strict) {
1029
- var head = text.replace(/^[\s\u00A0\uFEFF]+/, "")[0];
1030
- if (head !== "{" && head !== "[") {
1031
- throw new BodyParserError("body-parser/json-strict",
1032
- "JSON body must start with '{' or '[' (strict mode)", true, HTTP_STATUS.BAD_REQUEST);
1033
- }
1034
- }
1035
- var parsed;
1036
- try { parsed = safeJson.parse(text, { maxBytes: opts.limit }); }
1037
- catch (e) {
1038
- throw new BodyParserError("body-parser/json-malformed",
1039
- "JSON parse failed: " + ((e && e.message) || String(e)), true, HTTP_STATUS.BAD_REQUEST);
1040
- }
1041
- if (typeof opts.parseHook === "function") {
1042
- try { parsed = opts.parseHook(parsed); }
1043
- catch (e) {
1044
- throw new BodyParserError("body-parser/json-hook",
1045
- "JSON parseHook failed: " + ((e && e.message) || String(e)), true, HTTP_STATUS.BAD_REQUEST);
1046
- }
1047
- }
1048
- return parsed;
1049
- }
1050
-
1051
- // raw — convenience wrapper that returns a middleware which buffers
1052
- // the request body as a Buffer regardless of Content-Type. Webhook
1053
- // signature-verification routes use this — the HMAC is computed over
1054
- // the literal body bytes, so JSON-parsing first would change them.
1055
- //
1056
- // router.post("/hooks/in", b.middleware.bodyParser.raw(), function (req, res) {
1057
- // verifier.verify(req.headers["x-signature"], req.body); // req.body is a Buffer
1058
- // });
1059
- //
1060
- // Accepts the same `raw`-section opts as create() (limit, contentTypes).
1061
- // contentTypes default expands to `["*/*"]` so any Content-Type lands
1062
- // as raw bytes.
1063
- function raw(opts) {
1064
- opts = opts || {};
1065
- return create({
1066
- json: false,
1067
- urlencoded: false,
1068
- text: false,
1069
- multipart: false,
1070
- raw: {
1071
- limit: opts.limit != null ? opts.limit : DEFAULTS.raw.limit,
1072
- contentTypes: opts.contentTypes || ["*/*"],
1073
- },
1074
- });
1075
- }
1076
-
1077
- // Attach raw onto create so b.middleware.bodyParser.raw() works
1078
- // (middleware/index.js exports the create function as the namespace
1079
- // itself, so static helpers hang off it).
1080
- create.raw = raw;
1081
-
1082
- module.exports = {
1083
- create: create,
1084
- raw: raw,
1085
- BodyParserError: BodyParserError,
1086
- // Internal helpers exposed for tests + the csrf-protect refactor.
1087
- _contentType: _contentType,
1088
- _hasBody: _hasBody,
1089
- _sanitizeFilename: _sanitizeFilename,
1090
- POISONED_KEYS: POISONED_KEYS,
1091
- };
1
+ "use strict";
2
+ /**
3
+ * body-parser — request-body buffering + parsing middleware.
4
+ *
5
+ * Populates `req.body` for body-bearing methods (POST, PUT, PATCH,
6
+ * DELETE) based on the request's Content-Type. Five sub-parsers ship,
7
+ * each with its own size-cap, encoding handling, and pollution defense:
8
+ *
9
+ * application/json → req.body = parsed object
10
+ * (via safe-json — POISONED_KEYS
11
+ * stripped, depth + size caps)
12
+ * application/x-www-form-urlencoded → req.body = { field: value }
13
+ * text/plain → req.body = string
14
+ * application/octet-stream → req.body = Buffer (raw)
15
+ * multipart/form-data → req.body = { field: value }
16
+ * req.files = [{ field, filename,
17
+ * mimeType, path, size, hash }]
18
+ * req.filesRejected = [{ field,
19
+ * filename, mimeType, code, message }]
20
+ *
21
+ * Multipart parses incrementally — file parts stream to a tmp dir
22
+ * rather than buffering in memory. Per-file + total-request size caps
23
+ * enforced. Filename sanitization strips path components + traversal.
24
+ * Per-file SHA3-512 hash computed during streaming. Tmp files cleaned
25
+ * on response end (whether the handler returned or threw).
26
+ *
27
+ * var bp = b.middleware.bodyParser({
28
+ * json: {
29
+ * limit: b.constants.BYTES.mib(1),
30
+ * strict: true, // require the body to start with {/[
31
+ * parseHook: function (parsed) { ... return validatedShape; },
32
+ * },
33
+ * urlencoded: {
34
+ * limit: b.constants.BYTES.mib(1),
35
+ * arrayLimit: 100, // for ?tag=a&tag=b → tag: ["a","b"]
36
+ * },
37
+ * text: { limit: b.constants.BYTES.mib(1), charset: "utf-8" },
38
+ * raw: { limit: b.constants.BYTES.mib(10), contentTypes: ["application/octet-stream"] },
39
+ * multipart: {
40
+ * tmpDir: os.tmpdir(),
41
+ * fileSize: b.constants.BYTES.mib(10),
42
+ * totalSize: b.constants.BYTES.mib(50),
43
+ * fileCount: 20,
44
+ * fieldCount: 100,
45
+ * fieldSize: b.constants.BYTES.mib(1),
46
+ * mimeAllowlist: ["image/jpeg", "image/png", "application/pdf"], // null = any
47
+ *
48
+ * // Per-part predicate. Runs after sanitization + MIME checks but
49
+ * // BEFORE the tmp file opens. Rejected parts are SKIPPED — the body
50
+ * // bytes are consumed (we still must scan past them to find the
51
+ * // next boundary) but never written to disk; the part metadata
52
+ * // lands in req.filesRejected. Surviving files appear in req.files
53
+ * // as usual. Sync only — async filtering goes in the route handler.
54
+ * fileFilter: function (part) {
55
+ * // part = { field, filename, mimeType, partHeaders }
56
+ * // return true / undefined → accept
57
+ * // return false → reject silently (entry in req.filesRejected)
58
+ * // return { reject: true, code, message } → reject with custom info
59
+ * return part.field === "avatar" && part.mimeType.startsWith("image/");
60
+ * },
61
+ *
62
+ * // Per-field overrides. maxBytes overrides global fileSize for file
63
+ * // parts and fieldSize for text parts. mimeTypes overrides the
64
+ * // global mimeAllowlist for the named field; other fields still
65
+ * // use the global list.
66
+ * fields: {
67
+ * avatar: { maxBytes: b.constants.BYTES.mib(2), mimeTypes: ["image/jpeg", "image/png"] },
68
+ * document: { maxBytes: b.constants.BYTES.mib(25) },
69
+ * },
70
+ *
71
+ * // When wired, fileFilter rejections emit body-parser.multipart.file_rejected
72
+ * // on the audit chain with the field, filename, mime, and reason.
73
+ * audit: b.audit,
74
+ * },
75
+ * // Stash the raw bytes for webhook-signature paths that need to
76
+ * // verify the wire bytes rather than the parsed shape.
77
+ * keepRawBody: false,
78
+ * });
79
+ * router.use(bp);
80
+ *
81
+ * Set any sub-parser to `false` to disable it entirely (the body is
82
+ * left untouched for those Content-Types — operator handles them).
83
+ *
84
+ * Failure modes — all return responses, do NOT call next(err):
85
+ * 413 Payload Too Large size-cap exceeded (incl. multipart
86
+ * file/total/field caps)
87
+ * 415 Unsupported Media Type Content-Type doesn't match any enabled
88
+ * sub-parser AND the request has a body
89
+ * 400 Bad Request malformed JSON / multipart / urlencoded;
90
+ * filename traversal; MIME not on allowlist
91
+ *
92
+ * Security guarantees:
93
+ * - All parsers enforce size caps BEFORE buffering the full body, so
94
+ * a large body can't OOM the process.
95
+ * - JSON path goes through safe-json — POISONED_KEYS stripped, depth
96
+ * + size caps applied at parse, no prototype pollution downstream.
97
+ * - Urlencoded uses URLSearchParams with a key-count cap, refuses
98
+ * POISONED_KEYS as field names (returns 400).
99
+ * - Multipart filename: path components stripped (basename), traversal
100
+ * dots collapsed, control characters stripped, length capped at 255.
101
+ * Tmp file path is generated by the framework, never derived from
102
+ * the operator-supplied filename — so a malicious filename can't
103
+ * collide with a sensitive path.
104
+ * - Multipart parser refuses fields whose `name` is in POISONED_KEYS
105
+ * (consistent with the JSON path).
106
+ * - Tmp files set with mode 0o600, parent dir created with 0o700.
107
+ * - Cleanup on response end always fires (response close, finish, or
108
+ * error) so a crashing handler doesn't leak files.
109
+ */
110
+
111
+ var fs = require("fs");
112
+ var os = require("os");
113
+ var path = require("path");
114
+ var nodeCrypto = require("node:crypto");
115
+ var atomicFile = require("../atomic-file");
116
+ var crypto = require("../crypto");
117
+ var requestHelpers = require("../request-helpers");
118
+ var safeBuffer = require("../safe-buffer");
119
+ var safeJson = require("../safe-json");
120
+ var validateOpts = require("../validate-opts");
121
+ var C = require("../constants");
122
+ var { defineClass } = require("../framework-error");
123
+
124
+ var HTTP_STATUS = requestHelpers.HTTP_STATUS;
125
+ var BodyParserError = defineClass("BodyParserError", { withStatusCode: true });
126
+
127
+ // Mirrors safe-json.js + safe-schema.js. Field names that match these
128
+ // are refused at the parse boundary regardless of which sub-parser is
129
+ // in play — consistent prototype-pollution defense across the framework.
130
+ var POISONED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
131
+
132
+ // ---- defaults ----
133
+
134
+ var DEFAULTS = Object.freeze({
135
+ json: {
136
+ limit: C.BYTES.mib(1),
137
+ strict: true,
138
+ contentTypes: ["application/json", "application/json; charset=utf-8"],
139
+ charset: "utf-8",
140
+ },
141
+ urlencoded: {
142
+ limit: C.BYTES.mib(1),
143
+ arrayLimit: 100,
144
+ contentTypes: ["application/x-www-form-urlencoded"],
145
+ charset: "utf-8",
146
+ },
147
+ text: {
148
+ limit: C.BYTES.mib(1),
149
+ charset: "utf-8",
150
+ contentTypes: ["text/plain"],
151
+ },
152
+ raw: {
153
+ limit: C.BYTES.mib(10),
154
+ contentTypes: ["application/octet-stream"],
155
+ },
156
+ multipart: {
157
+ tmpDir: null, // resolved per-instance from os.tmpdir()
158
+ fileSize: C.BYTES.mib(10),
159
+ totalSize: C.BYTES.mib(50),
160
+ fileCount: 20,
161
+ fieldCount: 100,
162
+ fieldSize: C.BYTES.mib(1),
163
+ mimeAllowlist: null,
164
+ fileFilter: null, // fn({ field, filename, mimeType, partHeaders }) → bool | { reject, code, message }
165
+ fields: null, // per-field overrides: { name: { maxBytes?, mimeTypes? } }
166
+ audit: null, // when wired, file-rejection emits an audit event
167
+ contentTypes: ["multipart/form-data"],
168
+ },
169
+ });
170
+
171
+ var BODY_BEARING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
172
+
173
+ // Headroom on top of arrayLimit before declaring a key-bomb. Sized so a
174
+ // reasonable form (a few hundred fields with repeated names) fits without
175
+ // tripping the cap.
176
+ var URLENCODED_KEY_HEADROOM = C.BYTES.bytes(1000);
177
+
178
+ // ---- helpers ----
179
+
180
+ function _contentType(req) {
181
+ var ct = req.headers && req.headers["content-type"];
182
+ if (typeof ct !== "string") return { type: "", params: {} };
183
+ var idx = ct.indexOf(";");
184
+ var type = (idx === -1 ? ct : ct.slice(0, idx)).trim().toLowerCase();
185
+ var params = {};
186
+ if (idx !== -1) {
187
+ var rest = ct.slice(idx + 1);
188
+ var parts = rest.split(";");
189
+ for (var i = 0; i < parts.length; i++) {
190
+ var p = parts[i].trim();
191
+ var eq = p.indexOf("=");
192
+ if (eq === -1) continue;
193
+ var k = p.slice(0, eq).trim().toLowerCase();
194
+ var v = p.slice(eq + 1).trim();
195
+ if (v.length >= 2 && v[0] === '"' && v[v.length - 1] === '"') v = v.slice(1, -1);
196
+ params[k] = v;
197
+ }
198
+ }
199
+ return { type: type, params: params };
200
+ }
201
+
202
+ function _typeMatches(actual, allowed) {
203
+ for (var i = 0; i < allowed.length; i++) {
204
+ var a = allowed[i].toLowerCase();
205
+ // Match either exact type or "type/*" prefix
206
+ if (a === actual) return true;
207
+ var slash = a.indexOf("/");
208
+ if (slash !== -1 && a.slice(slash + 1) === "*" &&
209
+ actual.indexOf(a.slice(0, slash + 1)) === 0) return true;
210
+ }
211
+ return false;
212
+ }
213
+
214
+ // RFC 9112 §6.1: Content-Length MUST be a sequence of decimal digits with
215
+ // no whitespace, sign, or trailing garbage. parseInt("123abc") returning
216
+ // 123 is the lenient parse that lets malformed headers slip past the
217
+ // preflight cap; the strict regex catches them at the boundary.
218
+ var STRICT_CONTENT_LENGTH = /^\d+$/;
219
+
220
+ function _parseContentLength(cl) {
221
+ if (typeof cl !== "string" || !STRICT_CONTENT_LENGTH.test(cl)) return null;
222
+ var n = Number(cl);
223
+ return isFinite(n) ? n : null;
224
+ }
225
+
226
+ function _hasBody(req) {
227
+ if (!BODY_BEARING_METHODS.has(req.method)) return false;
228
+ var cl = req.headers && req.headers["content-length"];
229
+ if (typeof cl === "string") {
230
+ var clNum = _parseContentLength(cl);
231
+ // Spec-shaped zero (the only RFC 9112 §6.1 zero) → no body. Malformed
232
+ // values (non-decimal-digits) flow through as "yes, has body" so the
233
+ // downstream _bufferBody call rejects with 400 — silently treating
234
+ // a malformed header as "no body" would let the request slip past
235
+ // the parser entirely.
236
+ if (clNum === 0) return false;
237
+ return true;
238
+ }
239
+ var te = req.headers && req.headers["transfer-encoding"];
240
+ if (typeof te === "string" && te.length > 0) return true;
241
+ return false;
242
+ }
243
+
244
+ // HTTP request-smuggling defense per RFC 9112 §6.1 — covers the
245
+ // CVE-2022-31394 / CVE-2024-27316 / CL.TE / TE.CL / TE.TE class.
246
+ // Returns null on clean; { status, code, message } on smuggling-shaped
247
+ // request that the caller MUST reject with 400 + Connection: close.
248
+ function _detectSmuggling(req) {
249
+ var headers = req.headers || {};
250
+ var cl = headers["content-length"];
251
+ var te = headers["transfer-encoding"];
252
+
253
+ // 1. Both Content-Length AND Transfer-Encoding present — RFC 9112
254
+ // §6.1 says receiver MUST reject; the dual presence is the canonical
255
+ // CL.TE / TE.CL smuggling shape.
256
+ if (typeof cl === "string" && cl.length > 0 &&
257
+ typeof te === "string" && te.length > 0) {
258
+ return {
259
+ status: HTTP_STATUS.BAD_REQUEST, code: "smuggling/te-cl-conflict",
260
+ message: "request has both Content-Length and Transfer-Encoding " +
261
+ "headers (RFC 9112 §6.1 — request-smuggling vector)",
262
+ };
263
+ }
264
+
265
+ // 2. Multiple Content-Length values. Node's http parser collapses
266
+ // duplicate headers into a comma-separated string — `cl.indexOf(",")`
267
+ // catches it.
268
+ if (typeof cl === "string" && cl.indexOf(",") !== -1) {
269
+ return {
270
+ status: HTTP_STATUS.BAD_REQUEST, code: "smuggling/multiple-content-length",
271
+ message: "request has multiple Content-Length values (RFC 9112 §6.1)",
272
+ };
273
+ }
274
+
275
+ // 3. Transfer-Encoding present — final coding MUST be `chunked`
276
+ // (RFC 9112 §6.1). Anything else is a smuggling vector or
277
+ // server-side decode error.
278
+ if (typeof te === "string" && te.length > 0) {
279
+ var tokens = te.toLowerCase().split(",").map(function (t) { return t.trim(); });
280
+ var last = tokens[tokens.length - 1];
281
+ if (last !== "chunked") {
282
+ return {
283
+ status: HTTP_STATUS.BAD_REQUEST, code: "smuggling/te-not-chunked",
284
+ message: "request has Transfer-Encoding but final coding is not " +
285
+ "`chunked` (RFC 9112 §6.1 requires chunked be last)",
286
+ };
287
+ }
288
+ // 4. Duplicate `chunked` token (TE: chunked, chunked) — explicitly
289
+ // forbidden by RFC 9112 §6.1.
290
+ var chunkedCount = 0;
291
+ for (var i = 0; i < tokens.length; i += 1) {
292
+ if (tokens[i] === "chunked") chunkedCount += 1;
293
+ }
294
+ if (chunkedCount > 1) {
295
+ return {
296
+ status: HTTP_STATUS.BAD_REQUEST, code: "smuggling/duplicate-chunked",
297
+ message: "Transfer-Encoding lists `chunked` more than once " +
298
+ "(RFC 9112 §6.1 TE.TE smuggling vector)",
299
+ };
300
+ }
301
+ }
302
+
303
+ return null;
304
+ }
305
+
306
+ function _writeError(res, status, message, code) {
307
+ if (res.headersSent) return;
308
+ var body = JSON.stringify({ error: message, code: code });
309
+ res.writeHead(status, {
310
+ "Content-Type": "application/json; charset=utf-8",
311
+ "Content-Length": Buffer.byteLength(body),
312
+ });
313
+ res.end(body);
314
+ }
315
+
316
+ // Buffer the raw body up to `limit`. If Content-Length is known and
317
+ // exceeds limit, reject IMMEDIATELY without reading any bytes.
318
+ function _bufferBody(req, limit) {
319
+ return new Promise(function (resolve, reject) {
320
+ var cl = req.headers && req.headers["content-length"];
321
+ if (typeof cl === "string") {
322
+ var clNum = _parseContentLength(cl);
323
+ if (clNum === null) {
324
+ // RFC 9112 §6.1 — malformed Content-Length is a 400.
325
+ reject(new BodyParserError(
326
+ "body-parser/bad-content-length",
327
+ "Content-Length is not a sequence of decimal digits: " + JSON.stringify(cl),
328
+ true, HTTP_STATUS.BAD_REQUEST
329
+ ));
330
+ return;
331
+ }
332
+ if (clNum > limit) {
333
+ reject(new BodyParserError(
334
+ "body-parser/too-large",
335
+ "request body exceeds limit (" + clNum + " > " + limit + ")",
336
+ true, HTTP_STATUS.PAYLOAD_TOO_LARGE
337
+ ));
338
+ return;
339
+ }
340
+ }
341
+ var collector = safeBuffer.boundedChunkCollector({
342
+ maxBytes: limit,
343
+ errorClass: BodyParserError,
344
+ sizeCode: "body-parser/too-large",
345
+ sizeMessage: "request body exceeds limit",
346
+ });
347
+ var done = false;
348
+ req.on("data", function (chunk) {
349
+ if (done) return;
350
+ try { collector.push(chunk); }
351
+ catch (e) {
352
+ done = true;
353
+ try { req.destroy(); } catch (_e) { /* socket already closed */ }
354
+ if (e && e.isBodyParserError) e.statusCode = HTTP_STATUS.PAYLOAD_TOO_LARGE;
355
+ reject(e);
356
+ return;
357
+ }
358
+ });
359
+ req.on("end", function () { if (!done) { done = true; resolve(collector.result()); } });
360
+ req.on("error", function (e) { if (!done) { done = true; reject(e); } });
361
+ });
362
+ }
363
+
364
+ // ---- JSON parser ----
365
+
366
+ async function _parseJson(req, opts) {
367
+ var buf = await _bufferBody(req, opts.limit);
368
+ if (buf.length === 0) return undefined;
369
+ var text = buf.toString(opts.charset);
370
+ if (opts.strict) {
371
+ var head = text.replace(/^[\s\u00A0\uFEFF]+/, "")[0];
372
+ if (head !== "{" && head !== "[") {
373
+ throw new BodyParserError(
374
+ "body-parser/json-strict",
375
+ "JSON body must start with '{' or '[' (strict mode)",
376
+ true, HTTP_STATUS.BAD_REQUEST
377
+ );
378
+ }
379
+ }
380
+ var parsed;
381
+ try {
382
+ // safe-json strips POISONED_KEYS and enforces depth + byte caps.
383
+ parsed = safeJson.parse(text, { maxBytes: opts.limit });
384
+ } catch (e) {
385
+ throw new BodyParserError(
386
+ "body-parser/json-malformed",
387
+ "JSON parse failed: " + ((e && e.message) || String(e)),
388
+ true, HTTP_STATUS.BAD_REQUEST
389
+ );
390
+ }
391
+ if (typeof opts.parseHook === "function") {
392
+ try { parsed = opts.parseHook(parsed); }
393
+ catch (e) {
394
+ throw new BodyParserError(
395
+ "body-parser/json-hook",
396
+ "JSON parseHook failed: " + ((e && e.message) || String(e)),
397
+ true, HTTP_STATUS.BAD_REQUEST
398
+ );
399
+ }
400
+ }
401
+ return parsed;
402
+ }
403
+
404
+ // ---- urlencoded parser ----
405
+
406
+ async function _parseUrlencoded(req, opts) {
407
+ var buf = await _bufferBody(req, opts.limit);
408
+ if (buf.length === 0) return {};
409
+ var text = buf.toString(opts.charset);
410
+ var sp;
411
+ try { sp = new URLSearchParams(text); }
412
+ catch (e) {
413
+ throw new BodyParserError(
414
+ "body-parser/urlencoded-malformed",
415
+ "urlencoded parse failed: " + ((e && e.message) || String(e)),
416
+ true, HTTP_STATUS.BAD_REQUEST
417
+ );
418
+ }
419
+ var out = {};
420
+ var keyCount = 0;
421
+ // Track repeated keys so [a=1, a=2] becomes a: ["1","2"] rather than overwriting.
422
+ var seen = Object.create(null);
423
+ var keys = [];
424
+ sp.forEach(function (value, key) { keys.push([key, value]); });
425
+ for (var i = 0; i < keys.length; i++) {
426
+ var k = keys[i][0];
427
+ var v = keys[i][1];
428
+ if (POISONED_KEYS.has(k)) {
429
+ throw new BodyParserError(
430
+ "body-parser/urlencoded-poisoned-key",
431
+ "urlencoded body contains forbidden key '" + k + "' (prototype-pollution defense)",
432
+ true, HTTP_STATUS.BAD_REQUEST
433
+ );
434
+ }
435
+ keyCount++;
436
+ if (keyCount > opts.arrayLimit + URLENCODED_KEY_HEADROOM) { // soft cap on total keys
437
+ throw new BodyParserError(
438
+ "body-parser/urlencoded-too-many-fields",
439
+ "urlencoded body has too many fields",
440
+ true, 413
441
+ );
442
+ }
443
+ if (Object.prototype.hasOwnProperty.call(seen, k)) {
444
+ if (Array.isArray(out[k])) {
445
+ if (out[k].length >= opts.arrayLimit) {
446
+ throw new BodyParserError(
447
+ "body-parser/urlencoded-array-too-large",
448
+ "urlencoded array '" + k + "' exceeds arrayLimit (" + opts.arrayLimit + ")",
449
+ true, 413
450
+ );
451
+ }
452
+ out[k].push(v);
453
+ } else {
454
+ out[k] = [out[k], v];
455
+ }
456
+ } else {
457
+ out[k] = v;
458
+ seen[k] = true;
459
+ }
460
+ }
461
+ return out;
462
+ }
463
+
464
+ // ---- text parser ----
465
+
466
+ async function _parseText(req, opts) {
467
+ var buf = await _bufferBody(req, opts.limit);
468
+ return buf.toString(opts.charset);
469
+ }
470
+
471
+ // ---- raw parser ----
472
+
473
+ async function _parseRaw(req, opts) {
474
+ return await _bufferBody(req, opts.limit);
475
+ }
476
+
477
+ // ---- multipart parser ----
478
+ //
479
+ // Streaming RFC 7578 multipart/form-data parser. Walks an incoming
480
+ // request stream byte-by-byte (well, chunk-by-chunk with a sliding
481
+ // look-ahead window) and emits parts as they're encountered. File
482
+ // parts stream straight to disk; field parts buffer in memory up to
483
+ // fieldSize.
484
+ //
485
+ // State machine:
486
+ // INITIAL skip preamble until first boundary
487
+ // AFTER_BD just consumed a boundary; check next two bytes for
488
+ // "--" (end-of-multipart) or "\r\n" (next part)
489
+ // HEADERS reading per-part headers until \r\n\r\n
490
+ // BODY streaming part body; watch for \r\n--<boundary>
491
+ // DONE all parts read
492
+
493
+ var MP_INITIAL = 0;
494
+ var MP_AFTER_BD = 1;
495
+ var MP_HEADERS = 2;
496
+ var MP_BODY = 3;
497
+ var MP_DONE = 4;
498
+
499
+ function _sanitizeFilename(name) {
500
+ if (typeof name !== "string") return null;
501
+ // Strip every path component — keep only basename (last segment).
502
+ // Both POSIX and Windows separators handled, plus URL-encoded.
503
+ var s = name.replace(/\\/g, "/");
504
+ var idx = s.lastIndexOf("/");
505
+ if (idx !== -1) s = s.slice(idx + 1);
506
+ // Drop control characters, NUL, leading/trailing dots.
507
+ s = s.replace(/\p{Cc}/gu, "");
508
+ s = s.replace(/^\.+/, "").replace(/\.+$/, "");
509
+ if (s.length === 0) return null;
510
+ if (s.length > 255) s = s.slice(0, 255);
511
+ // Refuse any remaining traversal artifact.
512
+ if (s === "." || s === "..") return null;
513
+ return s;
514
+ }
515
+
516
+ function _parseMultipartHeaders(rawHeaders) {
517
+ // Each line is `Header-Name: value`. Common headers: Content-Disposition,
518
+ // Content-Type, Content-Transfer-Encoding. Unknown headers are ignored.
519
+ var lines = rawHeaders.split("\r\n");
520
+ var out = {};
521
+ for (var i = 0; i < lines.length; i++) {
522
+ var line = lines[i];
523
+ if (!line) continue;
524
+ var idx = line.indexOf(":");
525
+ if (idx === -1) continue;
526
+ var k = line.slice(0, idx).trim().toLowerCase();
527
+ var v = line.slice(idx + 1).trim();
528
+ out[k] = v;
529
+ }
530
+ return out;
531
+ }
532
+
533
+ function _parseHeaderParams(headerValue) {
534
+ // Content-Disposition: form-data; name="field"; filename="x.txt"
535
+ // Returns { _value: "form-data", name: "field", filename: "x.txt" }
536
+ var out = { _value: "" };
537
+ if (!headerValue) return out;
538
+ var parts = headerValue.split(";");
539
+ out._value = parts[0].trim().toLowerCase();
540
+ for (var i = 1; i < parts.length; i++) {
541
+ var p = parts[i].trim();
542
+ var eq = p.indexOf("=");
543
+ if (eq === -1) continue;
544
+ var k = p.slice(0, eq).trim().toLowerCase();
545
+ var v = p.slice(eq + 1).trim();
546
+ if (v.length >= 2 && v[0] === '"' && v[v.length - 1] === '"') v = v.slice(1, -1);
547
+ out[k] = v;
548
+ }
549
+ return out;
550
+ }
551
+
552
+ async function _parseMultipart(req, opts, ctParams) {
553
+ var boundary = ctParams.boundary;
554
+ if (typeof boundary !== "string" || boundary.length === 0) {
555
+ throw new BodyParserError(
556
+ "body-parser/multipart-no-boundary",
557
+ "multipart Content-Type missing boundary parameter",
558
+ true, HTTP_STATUS.BAD_REQUEST
559
+ );
560
+ }
561
+ // Resolve tmpDir per-request so directory-creation failure surfaces as a
562
+ // structured error rather than a deferred fs throw.
563
+ var tmpDir = opts.tmpDir || path.join(os.tmpdir(), "blamejs-uploads");
564
+ try { atomicFile.ensureDir(tmpDir, 0o700); }
565
+ catch (e) {
566
+ throw new BodyParserError(
567
+ "body-parser/multipart-tmpdir",
568
+ "could not create multipart tmp dir '" + tmpDir + "': " + ((e && e.message) || String(e)),
569
+ true, 500
570
+ );
571
+ }
572
+
573
+ var boundaryBuf = Buffer.from("--" + boundary);
574
+ var boundaryDelimBuf = Buffer.from("\r\n--" + boundary);
575
+
576
+ var fields = {};
577
+ var files = [];
578
+ var filesRejected = [];
579
+ var totalRead = 0;
580
+ var fileCount = 0;
581
+ var fieldCount = 0;
582
+ var fileSize = opts.fileSize;
583
+ var totalSize = opts.totalSize;
584
+ var fileLimit = opts.fileCount;
585
+ var fieldLimit = opts.fieldCount;
586
+ var fieldSize = opts.fieldSize;
587
+ var mimeAllowlist = Array.isArray(opts.mimeAllowlist) ? opts.mimeAllowlist : null;
588
+ var fileFilter = typeof opts.fileFilter === "function" ? opts.fileFilter : null;
589
+ var perField = (opts.fields && typeof opts.fields === "object") ? opts.fields : null;
590
+ var auditInst = (opts.audit && typeof opts.audit.safeEmit === "function") ? opts.audit : null;
591
+
592
+ var state = MP_INITIAL;
593
+ var pending = Buffer.alloc(0);
594
+ var currentHeaders = null;
595
+ var currentField = null;
596
+ var currentFilename = null;
597
+ var currentMime = null;
598
+ var currentTmpPath = null;
599
+ var currentFd = null;
600
+ var currentSize = 0;
601
+ var currentHash = null;
602
+ var currentBuf = null; // for fields (in-memory accumulator)
603
+ var currentDiscarded = false; // true when fileFilter rejected the part — body bytes are
604
+ // still consumed (we have to read past them to find the next
605
+ // boundary) but never written to disk.
606
+ var currentEffectiveLimit = 0; // per-field-or-global cap; recomputed at part start.
607
+
608
+ function _resetCurrent() {
609
+ currentHeaders = null;
610
+ currentField = null;
611
+ currentFilename = null;
612
+ currentMime = null;
613
+ currentTmpPath = null;
614
+ if (currentFd !== null) { try { fs.closeSync(currentFd); } catch (_e) { /* fd already closed */ } currentFd = null; }
615
+ currentSize = 0;
616
+ currentHash = null;
617
+ currentBuf = null;
618
+ currentDiscarded = false;
619
+ currentEffectiveLimit = 0;
620
+ }
621
+
622
+ function _emitRejection(field, filename, mimeType, code, message) {
623
+ filesRejected.push({
624
+ field: field,
625
+ filename: filename,
626
+ mimeType: mimeType,
627
+ code: code,
628
+ message: message || null,
629
+ });
630
+ if (auditInst) {
631
+ try {
632
+ auditInst.safeEmit({
633
+ action: "body-parser.multipart.file_rejected",
634
+ outcome: "denied",
635
+ resource: { kind: "multipart.file", id: field + (filename ? ":" + filename : "") },
636
+ metadata: { field: field, filename: filename, mimeType: mimeType, code: code, message: message || null },
637
+ });
638
+ } catch (_e) { /* audit best-effort */ }
639
+ }
640
+ }
641
+
642
+ function _cleanup() {
643
+ if (currentFd !== null) { try { fs.closeSync(currentFd); } catch (_e) { /* fd already closed */ } currentFd = null; }
644
+ if (currentTmpPath) { try { fs.unlinkSync(currentTmpPath); } catch (_e) { /* tmp file already removed */ } }
645
+ for (var i = 0; i < files.length; i++) {
646
+ try { fs.unlinkSync(files[i].path); } catch (_e) { /* tmp file already removed */ }
647
+ }
648
+ }
649
+
650
+ try {
651
+ return await new Promise(function (resolve, reject) {
652
+ function done(err, value) {
653
+ // De-dup completion — req error + req end can both fire.
654
+ if (resolved) return;
655
+ resolved = true;
656
+ if (err) {
657
+ _cleanup();
658
+ reject(err);
659
+ } else {
660
+ resolve(value);
661
+ }
662
+ }
663
+ var resolved = false;
664
+
665
+ function processBuffer() {
666
+ // Re-enter the state machine until we can't make progress.
667
+ while (true) {
668
+ if (state === MP_INITIAL) {
669
+ // Find the first boundary marker (without the leading \r\n
670
+ // since the preamble may begin with the boundary directly).
671
+ var firstIdx = pending.indexOf(boundaryBuf);
672
+ if (firstIdx === -1) {
673
+ // Need more data.
674
+ if (pending.length > boundary.length + 100) {
675
+ // Drop preamble bytes to bound memory; keep the last
676
+ // boundary.length+4 bytes as look-ahead.
677
+ pending = pending.slice(pending.length - boundary.length - 4);
678
+ }
679
+ return;
680
+ }
681
+ pending = pending.slice(firstIdx + boundaryBuf.length);
682
+ state = MP_AFTER_BD;
683
+ continue;
684
+ }
685
+ if (state === MP_AFTER_BD) {
686
+ if (pending.length < 2) return;
687
+ if (pending[0] === 0x2d && pending[1] === 0x2d) { // "--"
688
+ state = MP_DONE;
689
+ done(null, { fields: fields, files: files, filesRejected: filesRejected });
690
+ return;
691
+ }
692
+ if (pending[0] === 0x0d && pending[1] === 0x0a) { // "\r\n"
693
+ pending = pending.slice(2);
694
+ state = MP_HEADERS;
695
+ continue;
696
+ }
697
+ // Tolerate transport-added \n only.
698
+ if (pending[0] === 0x0a) {
699
+ pending = pending.slice(1);
700
+ state = MP_HEADERS;
701
+ continue;
702
+ }
703
+ done(new BodyParserError("body-parser/multipart-malformed",
704
+ "expected --, \\r\\n, or \\n after boundary", true, HTTP_STATUS.BAD_REQUEST));
705
+ return;
706
+ }
707
+ if (state === MP_HEADERS) {
708
+ // Read until \r\n\r\n.
709
+ var headEnd = pending.indexOf("\r\n\r\n");
710
+ if (headEnd === -1) {
711
+ if (pending.length > C.BYTES.kib(16)) {
712
+ done(new BodyParserError("body-parser/multipart-headers-too-large",
713
+ "multipart part headers exceed 16KB", true, 413));
714
+ return;
715
+ }
716
+ return;
717
+ }
718
+ currentHeaders = _parseMultipartHeaders(pending.slice(0, headEnd).toString("utf8"));
719
+ pending = pending.slice(headEnd + 4);
720
+ // Decode Content-Disposition.
721
+ var cd = _parseHeaderParams(currentHeaders["content-disposition"]);
722
+ if (cd._value !== "form-data" || typeof cd.name !== "string" || cd.name.length === 0) {
723
+ done(new BodyParserError("body-parser/multipart-bad-disposition",
724
+ "multipart part missing form-data Content-Disposition", true, HTTP_STATUS.BAD_REQUEST));
725
+ return;
726
+ }
727
+ if (POISONED_KEYS.has(cd.name)) {
728
+ done(new BodyParserError("body-parser/multipart-poisoned-field",
729
+ "multipart field '" + cd.name + "' is forbidden (prototype-pollution defense)",
730
+ true, HTTP_STATUS.BAD_REQUEST));
731
+ return;
732
+ }
733
+ currentField = cd.name;
734
+ if (typeof cd.filename === "string") {
735
+ currentFilename = _sanitizeFilename(cd.filename);
736
+ if (!currentFilename) {
737
+ done(new BodyParserError("body-parser/multipart-bad-filename",
738
+ "multipart part filename did not survive sanitization (path traversal or empty)",
739
+ true, HTTP_STATUS.BAD_REQUEST));
740
+ return;
741
+ }
742
+ currentMime = currentHeaders["content-type"] || "application/octet-stream";
743
+ // Per-field MIME allowlist takes precedence over the global one
744
+ // for this field; global applies to fields without an entry.
745
+ var fieldRule = perField ? perField[currentField] : null;
746
+ var perFieldMime = (fieldRule && Array.isArray(fieldRule.mimeTypes))
747
+ ? fieldRule.mimeTypes : null;
748
+ if (perFieldMime) {
749
+ if (perFieldMime.indexOf(currentMime) === -1) {
750
+ done(new BodyParserError("body-parser/multipart-mime-not-allowed",
751
+ "multipart file '" + currentField + "' MIME '" + currentMime +
752
+ "' is not on the per-field allowlist",
753
+ true, 415));
754
+ return;
755
+ }
756
+ } else if (mimeAllowlist && mimeAllowlist.indexOf(currentMime) === -1) {
757
+ done(new BodyParserError("body-parser/multipart-mime-not-allowed",
758
+ "multipart file MIME '" + currentMime + "' is not on the allowlist",
759
+ true, 415));
760
+ return;
761
+ }
762
+ fileCount++;
763
+ if (fileCount > fileLimit) {
764
+ done(new BodyParserError("body-parser/multipart-too-many-files",
765
+ "multipart fileCount exceeds limit (" + fileLimit + ")",
766
+ true, 413));
767
+ return;
768
+ }
769
+ // Per-field cap overrides global fileSize for this field.
770
+ currentEffectiveLimit = (fieldRule && typeof fieldRule.maxBytes === "number")
771
+ ? fieldRule.maxBytes : fileSize;
772
+
773
+ // fileFilter runs AFTER sanitize + MIME checks but BEFORE the
774
+ // tmp file opens. Synchronous so the parser can decide between
775
+ // disk-write and discard-bytes without buffering the part.
776
+ if (fileFilter) {
777
+ var filterVerdict;
778
+ try {
779
+ filterVerdict = fileFilter({
780
+ field: currentField,
781
+ filename: currentFilename,
782
+ mimeType: currentMime,
783
+ partHeaders: currentHeaders,
784
+ });
785
+ } catch (e) {
786
+ done(new BodyParserError("body-parser/multipart-file-filter-throw",
787
+ "fileFilter threw: " + ((e && e.message) || String(e)),
788
+ true, 500));
789
+ return;
790
+ }
791
+ if (filterVerdict === false ||
792
+ (filterVerdict && typeof filterVerdict === "object" && filterVerdict.reject)) {
793
+ var rejCode = (filterVerdict && filterVerdict.code) || "fileFilter";
794
+ var rejMessage = (filterVerdict && filterVerdict.message) || null;
795
+ _emitRejection(currentField, currentFilename, currentMime, rejCode, rejMessage);
796
+ // Read past the body bytes (we still must find the next
797
+ // boundary) but never open a tmp file or push to req.files.
798
+ currentDiscarded = true;
799
+ fileCount--; // doesn't count toward the limit since it didn't land
800
+ currentSize = 0;
801
+ state = MP_BODY;
802
+ continue;
803
+ }
804
+ }
805
+
806
+ // Generate the tmp path never derived from the
807
+ // operator-supplied filename.
808
+ var unique = crypto.generateToken(C.BYTES.bytes(16));
809
+ currentTmpPath = path.join(tmpDir, "blamejs-up-" + unique);
810
+ try {
811
+ currentFd = fs.openSync(currentTmpPath, "wx", 0o600);
812
+ } catch (e) {
813
+ done(new BodyParserError("body-parser/multipart-tmp-open",
814
+ "could not open multipart tmp file: " + ((e && e.message) || String(e)),
815
+ true, 500));
816
+ return;
817
+ }
818
+ currentHash = nodeCrypto.createHash("sha3-512");
819
+ currentSize = 0;
820
+ } else {
821
+ fieldCount++;
822
+ if (fieldCount > fieldLimit) {
823
+ done(new BodyParserError("body-parser/multipart-too-many-fields",
824
+ "multipart fieldCount exceeds limit (" + fieldLimit + ")",
825
+ true, 413));
826
+ return;
827
+ }
828
+ // Per-field cap overrides global fieldSize for text parts too.
829
+ var textFieldRule = perField ? perField[currentField] : null;
830
+ currentEffectiveLimit = (textFieldRule && typeof textFieldRule.maxBytes === "number")
831
+ ? textFieldRule.maxBytes : fieldSize;
832
+ currentBuf = [];
833
+ currentSize = 0;
834
+ }
835
+ state = MP_BODY;
836
+ continue;
837
+ }
838
+ if (state === MP_BODY) {
839
+ // Look for the next boundary. The marker we want is
840
+ // \r\n--<boundary>
841
+ // Anything before it is part body. We need to keep at least
842
+ // boundary.length + 4 bytes in `pending` so we don't emit a
843
+ // partial match as body bytes.
844
+ var bdIdx = pending.indexOf(boundaryDelimBuf);
845
+ var emitLen;
846
+ if (bdIdx === -1) {
847
+ // No marker yet — emit everything except the trailing
848
+ // boundary-length+4 bytes (might be the start of the marker).
849
+ if (pending.length <= boundaryDelimBuf.length) return;
850
+ emitLen = pending.length - boundaryDelimBuf.length;
851
+ } else {
852
+ emitLen = bdIdx;
853
+ }
854
+ if (emitLen > 0) {
855
+ var bodyChunk = pending.slice(0, emitLen);
856
+ if (currentDiscarded) {
857
+ // fileFilter rejected this part — read past the bytes to find
858
+ // the next boundary but never write to disk. totalSize still
859
+ // applies as a per-request DoS guard.
860
+ totalRead += bodyChunk.length;
861
+ if (totalRead > totalSize) {
862
+ done(new BodyParserError("body-parser/multipart-total-too-large",
863
+ "multipart total request size exceeds totalSize (" + totalSize + ")",
864
+ true, 413));
865
+ return;
866
+ }
867
+ } else if (currentFd !== null) {
868
+ // File part write to disk.
869
+ currentSize += bodyChunk.length;
870
+ if (currentSize > currentEffectiveLimit) {
871
+ var perFieldFile = (perField && perField[currentField] &&
872
+ typeof perField[currentField].maxBytes === "number");
873
+ done(new BodyParserError("body-parser/multipart-file-too-large",
874
+ "multipart file '" + currentField + "' exceeds " +
875
+ (perFieldFile ? "per-field maxBytes" : "fileSize") +
876
+ " (" + currentEffectiveLimit + ")",
877
+ true, 413));
878
+ return;
879
+ }
880
+ totalRead += bodyChunk.length;
881
+ if (totalRead > totalSize) {
882
+ done(new BodyParserError("body-parser/multipart-total-too-large",
883
+ "multipart total request size exceeds totalSize (" + totalSize + ")",
884
+ true, 413));
885
+ return;
886
+ }
887
+ try {
888
+ var written = 0;
889
+ while (written < bodyChunk.length) {
890
+ written += fs.writeSync(currentFd, bodyChunk, written, bodyChunk.length - written);
891
+ }
892
+ } catch (e) {
893
+ done(new BodyParserError("body-parser/multipart-tmp-write",
894
+ "multipart tmp write failed: " + ((e && e.message) || String(e)),
895
+ true, 500));
896
+ return;
897
+ }
898
+ currentHash.update(bodyChunk);
899
+ } else {
900
+ // Field part — buffer in memory up to per-field-or-global cap.
901
+ currentSize += bodyChunk.length;
902
+ if (currentSize > currentEffectiveLimit) {
903
+ var perFieldText = (perField && perField[currentField] &&
904
+ typeof perField[currentField].maxBytes === "number");
905
+ done(new BodyParserError("body-parser/multipart-field-too-large",
906
+ "multipart field '" + currentField + "' exceeds " +
907
+ (perFieldText ? "per-field maxBytes" : "fieldSize") +
908
+ " (" + currentEffectiveLimit + ")",
909
+ true, 413));
910
+ return;
911
+ }
912
+ totalRead += bodyChunk.length;
913
+ if (totalRead > totalSize) {
914
+ done(new BodyParserError("body-parser/multipart-total-too-large",
915
+ "multipart total request size exceeds totalSize (" + totalSize + ")",
916
+ true, 413));
917
+ return;
918
+ }
919
+ currentBuf.push(bodyChunk);
920
+ }
921
+ pending = pending.slice(emitLen);
922
+ }
923
+ if (bdIdx === -1) return; // need more data
924
+ // Consume the boundary delimiter; transition to AFTER_BD.
925
+ pending = pending.slice(boundaryDelimBuf.length);
926
+ // Finalize the current part.
927
+ if (currentDiscarded) {
928
+ // fileFilter rejected — already recorded in filesRejected; no
929
+ // tmp file was opened, nothing to clean up here.
930
+ } else if (currentFd !== null) {
931
+ try { fs.closeSync(currentFd); } catch (_e) { /* fd already closed */ }
932
+ currentFd = null;
933
+ files.push({
934
+ field: currentField,
935
+ filename: currentFilename,
936
+ mimeType: currentMime,
937
+ path: currentTmpPath,
938
+ size: currentSize,
939
+ hash: currentHash.digest("hex"),
940
+ });
941
+ } else {
942
+ // Field part — flatten + decode UTF-8.
943
+ var fbuf = Buffer.concat(currentBuf);
944
+ var text = fbuf.toString("utf8");
945
+ // Repeated field name → array, matching urlencoded parser.
946
+ if (Object.prototype.hasOwnProperty.call(fields, currentField)) {
947
+ if (Array.isArray(fields[currentField])) fields[currentField].push(text);
948
+ else fields[currentField] = [fields[currentField], text];
949
+ } else {
950
+ fields[currentField] = text;
951
+ }
952
+ }
953
+ currentHeaders = null;
954
+ currentField = null;
955
+ currentFilename = null;
956
+ currentMime = null;
957
+ currentTmpPath = null;
958
+ currentSize = 0;
959
+ currentHash = null;
960
+ currentBuf = null;
961
+ currentDiscarded = false;
962
+ currentEffectiveLimit = 0;
963
+ state = MP_AFTER_BD;
964
+ continue;
965
+ }
966
+ if (state === MP_DONE) return;
967
+ }
968
+ }
969
+
970
+ req.on("data", function (chunk) {
971
+ if (resolved) return;
972
+ pending = Buffer.concat([pending, chunk]);
973
+ try { processBuffer(); }
974
+ catch (e) {
975
+ done(new BodyParserError("body-parser/multipart-internal",
976
+ "multipart internal parse error: " + ((e && e.message) || String(e)),
977
+ true, 500));
978
+ }
979
+ });
980
+ req.on("end", function () {
981
+ if (resolved) return;
982
+ if (state !== MP_DONE) {
983
+ done(new BodyParserError("body-parser/multipart-truncated",
984
+ "multipart stream ended before final boundary", true, HTTP_STATUS.BAD_REQUEST));
985
+ return;
986
+ }
987
+ });
988
+ req.on("error", function (e) {
989
+ if (resolved) return;
990
+ done(new BodyParserError("body-parser/multipart-stream",
991
+ "multipart stream error: " + ((e && e.message) || String(e)), true, HTTP_STATUS.BAD_REQUEST));
992
+ });
993
+ });
994
+ } catch (e) {
995
+ // Already cleaned up via done(err).
996
+ throw e;
997
+ }
998
+ }
999
+
1000
+ // ---- main middleware factory ----
1001
+
1002
+ function create(opts) {
1003
+ opts = opts || {};
1004
+ validateOpts(opts, [
1005
+ "json", "urlencoded", "text", "raw", "multipart", "keepRawBody",
1006
+ ], "middleware.bodyParser");
1007
+
1008
+ function _resolve(name) {
1009
+ if (opts[name] === false) return null; // disabled
1010
+ return Object.assign({}, DEFAULTS[name], opts[name] || {});
1011
+ }
1012
+ var jsonOpts = _resolve("json");
1013
+ var urlencodedOpts = _resolve("urlencoded");
1014
+ var textOpts = _resolve("text");
1015
+ var rawOpts = _resolve("raw");
1016
+ var multipartOpts = _resolve("multipart");
1017
+ var keepRawBody = !!opts.keepRawBody;
1018
+
1019
+ return async function bodyParser(req, res, next) {
1020
+ // RFC 9112 §6.1 request-smuggling defense — runs BEFORE _hasBody
1021
+ // so the smuggling shape is rejected even if the request would
1022
+ // otherwise short-circuit as no-body. Reject with 400 +
1023
+ // Connection: close so the upstream proxy doesn't reuse the socket.
1024
+ var smug = _detectSmuggling(req);
1025
+ if (smug) {
1026
+ if (!res.headersSent) {
1027
+ var smugBody = JSON.stringify({ error: smug.message, code: smug.code });
1028
+ res.writeHead(smug.status, {
1029
+ "Content-Type": "application/json; charset=utf-8",
1030
+ "Content-Length": Buffer.byteLength(smugBody),
1031
+ "Connection": "close",
1032
+ });
1033
+ res.end(smugBody);
1034
+ }
1035
+ return;
1036
+ }
1037
+ if (!_hasBody(req)) return next();
1038
+ if (req.body !== undefined) return next(); // already parsed by an earlier middleware
1039
+
1040
+ var ct = _contentType(req);
1041
+
1042
+ try {
1043
+ if (jsonOpts && _typeMatches(ct.type, jsonOpts.contentTypes)) {
1044
+ if (keepRawBody) {
1045
+ var rawBuf = await _bufferBody(req, jsonOpts.limit);
1046
+ req.bodyRaw = rawBuf;
1047
+ req.body = rawBuf.length === 0 ? undefined : await _parseJsonFromBuf(rawBuf, jsonOpts);
1048
+ } else {
1049
+ req.body = await _parseJson(req, jsonOpts);
1050
+ }
1051
+ return next();
1052
+ }
1053
+ if (urlencodedOpts && _typeMatches(ct.type, urlencodedOpts.contentTypes)) {
1054
+ req.body = await _parseUrlencoded(req, urlencodedOpts);
1055
+ return next();
1056
+ }
1057
+ if (multipartOpts && _typeMatches(ct.type, multipartOpts.contentTypes)) {
1058
+ var mpResult = await _parseMultipart(req, multipartOpts, ct.params);
1059
+ req.body = mpResult.fields;
1060
+ req.files = mpResult.files;
1061
+ req.filesRejected = mpResult.filesRejected || [];
1062
+ // Cleanup tmp files when the response finishes / closes / errors,
1063
+ // regardless of whether the handler returned cleanly. Operators
1064
+ // who want to KEEP a file move it elsewhere inside the handler.
1065
+ var cleanedUp = false;
1066
+ function cleanup() {
1067
+ if (cleanedUp) return;
1068
+ cleanedUp = true;
1069
+ for (var i = 0; i < mpResult.files.length; i++) {
1070
+ try { fs.unlinkSync(mpResult.files[i].path); } catch (_e) { /* tmp file already removed */ }
1071
+ }
1072
+ }
1073
+ res.on("finish", cleanup);
1074
+ res.on("close", cleanup);
1075
+ return next();
1076
+ }
1077
+ if (textOpts && _typeMatches(ct.type, textOpts.contentTypes)) {
1078
+ req.body = await _parseText(req, textOpts);
1079
+ return next();
1080
+ }
1081
+ if (rawOpts && _typeMatches(ct.type, rawOpts.contentTypes)) {
1082
+ req.body = await _parseRaw(req, rawOpts);
1083
+ return next();
1084
+ }
1085
+ // Body present but no enabled sub-parser matches the Content-Type.
1086
+ // Don't reject silently operators with a custom parser opted in
1087
+ // by NOT enabling any sub-parser for this Content-Type would skip
1088
+ // bodyParser entirely. Reaching here means: enabled parsers exist
1089
+ // but none match this body. 415 is honest about the mismatch.
1090
+ _writeError(res, 415,
1091
+ "Unsupported Content-Type '" + ct.type + "'. Enable a matching sub-parser or send a different type.",
1092
+ "body-parser/unsupported-content-type"
1093
+ );
1094
+ } catch (e) {
1095
+ var status = (e && typeof e.statusCode === "number") ? e.statusCode : HTTP_STATUS.BAD_REQUEST;
1096
+ var code = (e && typeof e.code === "string") ? e.code : "body-parser/error";
1097
+ var message = (e && e.message) ? e.message : String(e);
1098
+ _writeError(res, status, message, code);
1099
+ }
1100
+ };
1101
+ }
1102
+
1103
+ // Helper used by keepRawBody path — re-uses the JSON pipeline but
1104
+ // works from an already-buffered Buffer (so we don't read req twice).
1105
+ async function _parseJsonFromBuf(buf, opts) {
1106
+ var text = buf.toString(opts.charset);
1107
+ if (opts.strict) {
1108
+ var head = text.replace(/^[\s\u00A0\uFEFF]+/, "")[0];
1109
+ if (head !== "{" && head !== "[") {
1110
+ throw new BodyParserError("body-parser/json-strict",
1111
+ "JSON body must start with '{' or '[' (strict mode)", true, HTTP_STATUS.BAD_REQUEST);
1112
+ }
1113
+ }
1114
+ var parsed;
1115
+ try { parsed = safeJson.parse(text, { maxBytes: opts.limit }); }
1116
+ catch (e) {
1117
+ throw new BodyParserError("body-parser/json-malformed",
1118
+ "JSON parse failed: " + ((e && e.message) || String(e)), true, HTTP_STATUS.BAD_REQUEST);
1119
+ }
1120
+ if (typeof opts.parseHook === "function") {
1121
+ try { parsed = opts.parseHook(parsed); }
1122
+ catch (e) {
1123
+ throw new BodyParserError("body-parser/json-hook",
1124
+ "JSON parseHook failed: " + ((e && e.message) || String(e)), true, HTTP_STATUS.BAD_REQUEST);
1125
+ }
1126
+ }
1127
+ return parsed;
1128
+ }
1129
+
1130
+ // raw — convenience wrapper that returns a middleware which buffers
1131
+ // the request body as a Buffer regardless of Content-Type. Webhook
1132
+ // signature-verification routes use this — the HMAC is computed over
1133
+ // the literal body bytes, so JSON-parsing first would change them.
1134
+ //
1135
+ // router.post("/hooks/in", b.middleware.bodyParser.raw(), function (req, res) {
1136
+ // verifier.verify(req.headers["x-signature"], req.body); // req.body is a Buffer
1137
+ // });
1138
+ //
1139
+ // Accepts the same `raw`-section opts as create() (limit, contentTypes).
1140
+ // contentTypes default expands to `["*/*"]` so any Content-Type lands
1141
+ // as raw bytes.
1142
+ function raw(opts) {
1143
+ opts = opts || {};
1144
+ return create({
1145
+ json: false,
1146
+ urlencoded: false,
1147
+ text: false,
1148
+ multipart: false,
1149
+ raw: {
1150
+ limit: opts.limit != null ? opts.limit : DEFAULTS.raw.limit,
1151
+ contentTypes: opts.contentTypes || ["*/*"],
1152
+ },
1153
+ });
1154
+ }
1155
+
1156
+ // Attach raw onto create so b.middleware.bodyParser.raw() works
1157
+ // (middleware/index.js exports the create function as the namespace
1158
+ // itself, so static helpers hang off it).
1159
+ create.raw = raw;
1160
+
1161
+ module.exports = {
1162
+ create: create,
1163
+ raw: raw,
1164
+ BodyParserError: BodyParserError,
1165
+ // Internal helpers exposed for tests + the csrf-protect refactor.
1166
+ _contentType: _contentType,
1167
+ _hasBody: _hasBody,
1168
+ _sanitizeFilename: _sanitizeFilename,
1169
+ POISONED_KEYS: POISONED_KEYS,
1170
+ };