@blamejs/core 0.7.18 → 0.7.20

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 (169) hide show
  1. package/CHANGELOG.md +427 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  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-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  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 +350 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  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/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +399 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/fetch-metadata.js +129 -0
  86. package/lib/middleware/health.js +392 -392
  87. package/lib/middleware/index.js +85 -79
  88. package/lib/middleware/rate-limit.js +358 -358
  89. package/lib/middleware/request-id.js +61 -61
  90. package/lib/middleware/request-log.js +168 -168
  91. package/lib/middleware/require-auth.js +104 -104
  92. package/lib/middleware/security-headers.js +121 -116
  93. package/lib/middleware/sse.js +166 -166
  94. package/lib/migrations.js +383 -383
  95. package/lib/mtls-ca.js +518 -518
  96. package/lib/mtls-engine-default.js +481 -481
  97. package/lib/network-dns.js +632 -632
  98. package/lib/network-heartbeat.js +290 -290
  99. package/lib/network-nts.js +574 -574
  100. package/lib/network-proxy.js +265 -265
  101. package/lib/network-tls.js +328 -328
  102. package/lib/network.js +233 -233
  103. package/lib/notify.js +612 -612
  104. package/lib/ntp-check.js +229 -229
  105. package/lib/numeric-bounds.js +111 -111
  106. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  107. package/lib/object-store/azure-blob.js +488 -488
  108. package/lib/object-store/gcs-bucket-ops.js +351 -351
  109. package/lib/object-store/gcs.js +519 -519
  110. package/lib/object-store/http-put.js +153 -153
  111. package/lib/object-store/index.js +197 -197
  112. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  113. package/lib/object-store/sigv4.js +903 -903
  114. package/lib/observability.js +151 -151
  115. package/lib/otel-export.js +269 -269
  116. package/lib/pagination.js +464 -464
  117. package/lib/parsers/index.js +80 -80
  118. package/lib/parsers/safe-env.js +642 -642
  119. package/lib/parsers/safe-ini.js +292 -292
  120. package/lib/parsers/safe-toml.js +784 -784
  121. package/lib/parsers/safe-xml.js +390 -390
  122. package/lib/parsers/safe-yaml.js +1015 -1015
  123. package/lib/permissions.js +708 -708
  124. package/lib/pqc-agent.js +87 -87
  125. package/lib/pqc-gate.js +279 -279
  126. package/lib/protobuf-encoder.js +190 -190
  127. package/lib/protocol-dispatcher.js +161 -161
  128. package/lib/pubsub-redis.js +167 -167
  129. package/lib/pubsub.js +429 -429
  130. package/lib/queue-local.js +476 -476
  131. package/lib/queue-redis.js +745 -745
  132. package/lib/queue-sqs.js +319 -319
  133. package/lib/queue.js +695 -695
  134. package/lib/redis-client.js +519 -519
  135. package/lib/request-helpers.js +340 -340
  136. package/lib/restore-bundle.js +237 -237
  137. package/lib/restore-rollback.js +259 -259
  138. package/lib/restore.js +409 -409
  139. package/lib/retry.js +376 -376
  140. package/lib/router.js +748 -748
  141. package/lib/safe-async.js +735 -735
  142. package/lib/safe-buffer.js +237 -237
  143. package/lib/safe-json.js +541 -541
  144. package/lib/safe-schema.js +1266 -1266
  145. package/lib/safe-url.js +159 -159
  146. package/lib/scheduler.js +706 -706
  147. package/lib/security-assert.js +373 -373
  148. package/lib/seeders.js +618 -618
  149. package/lib/session.js +535 -478
  150. package/lib/slug.js +269 -269
  151. package/lib/ssrf-guard.js +401 -401
  152. package/lib/static.js +7 -5
  153. package/lib/storage.js +471 -471
  154. package/lib/subject.js +281 -281
  155. package/lib/template.js +791 -791
  156. package/lib/testing.js +798 -798
  157. package/lib/time.js +310 -310
  158. package/lib/totp.js +302 -302
  159. package/lib/tracing.js +494 -494
  160. package/lib/uuid.js +132 -132
  161. package/lib/validate-opts.js +340 -340
  162. package/lib/vault/index.js +308 -308
  163. package/lib/vault/rotate.js +784 -784
  164. package/lib/vault/wrap.js +296 -296
  165. package/lib/vendor/noble-ciphers.cjs +9 -9
  166. package/lib/webhook.js +595 -595
  167. package/lib/websocket.js +1048 -1048
  168. package/package.json +77 -77
  169. package/sbom.cyclonedx.json +7 -7
@@ -1,1170 +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
- // 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
- };
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
+ };