@blamejs/core 0.14.26 → 0.14.27

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.
@@ -22,8 +22,12 @@
22
22
  * content gate inspects the reassembled buffer, so it runs on uploads
23
23
  * up to `maxStreamReassemblyBytes` (default 64 MiB); a larger upload
24
24
  * is handed to `onFinalize` as a stream and the byte-content gate is
25
- * skipped (MIME-sniff + filename gates still run, and the skip emits a
26
- * `fileUpload.content_safety_skipped` warning audit). To guarantee
25
+ * skipped (MIME-sniff + filename gates still run). Every skip path
26
+ * the upload streamed past the reassembly cap, no gate is registered
27
+ * for the file's extension, or `contentSafety: null` disabled scanning
28
+ * — emits a `fileUpload.content_safety_skipped` audit whose `reason`
29
+ * names the cause, so a security review of the audit log can tell which
30
+ * uploads reached storage without a content scan and why. To guarantee
27
31
  * content-gating of a type, cap `maxFileBytes` at or below
28
32
  * `maxStreamReassemblyBytes`. Per-chunk hooks
29
33
  * (`onChunk`) are the integration point for virus scanners and
@@ -475,6 +479,32 @@ function create(opts) {
475
479
  if (opts.observability) opts.observability.safeEvent(name, value, labels || {});
476
480
  }
477
481
 
482
+ // Emit an audit row whenever the byte-level content-safety scan is
483
+ // SKIPPED for a finalized upload — so a security review of the audit
484
+ // log can tell that bytes reached storage without passing the
485
+ // content gate, and WHY. Without this, every skip path (operator
486
+ // opt-out, no gate registered for the file's extension, or the upload
487
+ // streamed past maxStreamReassemblyBytes) was silent: the audit log
488
+ // showed a clean `fileUpload.finalize` success indistinguishable from
489
+ // a scanned upload. `reason` names the skip cause so operators can
490
+ // alert / lower maxStreamReassemblyBytes / register the missing gate.
491
+ // Observability-only: `_emitAudit` wraps audit.safeEmit in try/catch
492
+ // (drop-silent — by design) so a throwing sink never breaks the upload.
493
+ function _emitContentSafetySkipped(uploadId, actor, reason, ext, size) {
494
+ _emitObs("fileUpload.content_safety_skipped", 1, { reason: reason, ext: ext || "" });
495
+ // outcome "success" — the upload itself finalized; the audit records
496
+ // that the byte-level scan did NOT run, with `reason` naming why
497
+ // (the only outcomes the audit chain accepts are success / failure /
498
+ // denied, so the skip-cause lives in `reason` + `metadata`).
499
+ _emitAudit("fileUpload.content_safety_skipped", {
500
+ actor: requestHelpers.extractActorContext(actor),
501
+ resource: { kind: "fileUpload", id: uploadId },
502
+ outcome: "success",
503
+ reason: reason,
504
+ metadata: { uploadId: uploadId, ext: ext || null, size: size, reason: reason },
505
+ });
506
+ }
507
+
478
508
  // Staging dir mode 0o700 — only the framework process reads its own
479
509
  // staging files.
480
510
  atomicFile.ensureDir(stagingDir, 0o700);
@@ -1088,12 +1118,27 @@ function create(opts) {
1088
1118
  // upload streamed past maxStreamReassemblyBytes and was never
1089
1119
  // reassembled into a buffer the byte-level gate can inspect. The
1090
1120
  // MIME-sniff and filename gates still ran; the per-extension
1091
- // content gate did NOT. Surface it (rather than skipping silently)
1092
- // via an observability counter so operators can alert, lower
1093
- // maxStreamReassemblyBytes, or cap maxFileBytes to force
1094
- // content-gating of this type.
1095
- _emitObs("fileUpload.content_safety_skipped_streamed", 1, { ext: safetyExt });
1121
+ // content gate did NOT. Audit the skip (with the streamed reason)
1122
+ // so operators can alert, lower maxStreamReassemblyBytes, or cap
1123
+ // maxFileBytes to force content-gating of this type.
1124
+ _emitContentSafetySkipped(uploadId, actor, "streamed-over-reassembly-cap",
1125
+ safetyExt, verified.totalBytes);
1126
+ } else {
1127
+ // contentSafety is wired but no gate is registered for this file's
1128
+ // extension — the byte-level scan does not run. Audit the skip so
1129
+ // a review can tell the upload bypassed content scanning (and
1130
+ // register a gate for the extension if it should be scanned).
1131
+ _emitContentSafetySkipped(uploadId, actor, "no-gate-for-extension",
1132
+ safetyExt, verified.totalBytes);
1096
1133
  }
1134
+ } else {
1135
+ // Content-safety scanning is disabled for this upload manager
1136
+ // (contentSafety: null opt-out at create()). The create-time audit
1137
+ // recorded the disable; this per-upload audit makes the bypass
1138
+ // visible at the point bytes reached storage.
1139
+ _emitContentSafetySkipped(uploadId, actor, "content-safety-disabled",
1140
+ nodePath.extname(filename).toLowerCase(),
1141
+ verified.totalBytes);
1097
1142
  }
1098
1143
 
1099
1144
  // Hand to operator's onFinalize.
@@ -557,7 +557,9 @@ var WatcherError = defineClass("WatcherError", { alwaysPermane
557
557
  // caller-shape misuse or an irrecoverable on-disk condition.
558
558
  var LocalDbThinError = defineClass("LocalDbThinError", { alwaysPermanent: true });
559
559
  // RouterError covers operator-shape violations on the router primitive:
560
- // invalid `allowedRedirectOrigins` opt at create time, and cross-origin
560
+ // invalid `allowedRedirectOrigins` opt at create time, a malformed
561
+ // `use()` mount (non-string / non-array prefix, a prefix not beginning
562
+ // with "/", a missing or non-function middleware), and cross-origin
561
563
  // `res.redirect()` targets that are not on the allowlist. alwaysPermanent
562
564
  // — every case is config-time programming bug or an outbound-redirect
563
565
  // shape error that retry will not recover.
@@ -54,6 +54,12 @@ var { GateContractError } = require("./framework-error");
54
54
 
55
55
  var observability = lazyRequire(function () { return require("./observability"); });
56
56
  var compliance = lazyRequire(function () { return require("./compliance"); });
57
+ var audit = lazyRequire(function () { return require("./audit"); });
58
+
59
+ // One-time dedupe for the "global posture pinned but this guard maps no
60
+ // overlay" warning. Keyed `<posture>::<errCodePrefix>` so each guard
61
+ // family surfaces the gap once instead of on every gate construction.
62
+ var _unmappedPostureWarned = Object.create(null);
57
63
 
58
64
  // Forensic-id token width (bytes); 64 bits is enough for cross-gate
59
65
  // correlation in a single request scope.
@@ -1415,6 +1421,16 @@ function resolveProfileAndPosture(opts, cfg) {
1415
1421
  if (typeof globalPosture === "string" &&
1416
1422
  cfg.compliancePostures && cfg.compliancePostures[globalPosture]) {
1417
1423
  posture = globalPosture;
1424
+ } else if (typeof globalPosture === "string" && globalPosture.length > 0) {
1425
+ // A global posture IS pinned, but this guard family ships no
1426
+ // COMPLIANCE_POSTURES overlay for it (e.g. fedramp-rev5-moderate
1427
+ // against a guard whose table only covers hipaa/pci-dss/gdpr/soc2).
1428
+ // Falling through to the unposture-d default is the SAFE behavior,
1429
+ // but operators must know the posture is a no-op for THIS guard —
1430
+ // silently no-oping reads as "enforced" (compliance theater).
1431
+ // Emit a one-time, grep-able audit warning per (posture, guard)
1432
+ // and keep the safe default.
1433
+ _warnUnmappedPosture(globalPosture, prefix);
1418
1434
  }
1419
1435
  }
1420
1436
  if (typeof posture === "string") {
@@ -1427,6 +1443,42 @@ function resolveProfileAndPosture(opts, cfg) {
1427
1443
  return Object.assign({}, cfg.defaults || {}, overlay, opts);
1428
1444
  }
1429
1445
 
1446
+ // _warnUnmappedPosture — emit a one-time, grep-able audit warning that a
1447
+ // globally-pinned posture has no overlay in THIS guard family's
1448
+ // COMPLIANCE_POSTURES table, so the operator doesn't read the
1449
+ // safe-default fall-through as "the posture is enforced here." Drop-
1450
+ // silent (hot-path observability sink): a warning emit must never throw
1451
+ // past the guard-gate construction that triggered it.
1452
+ function _warnUnmappedPosture(posture, prefix) {
1453
+ var dedupeKey = posture + "::" + (prefix || "guard");
1454
+ if (_unmappedPostureWarned[dedupeKey]) return;
1455
+ _unmappedPostureWarned[dedupeKey] = true;
1456
+ try {
1457
+ // Canonical audit outcome triple is success/failure/denied; a
1458
+ // posture that maps no overlay is an advisory NOTICE, not a failure
1459
+ // of this construction — the severity rides in metadata.severity so
1460
+ // the audit row carries the warning intent without abusing outcome.
1461
+ audit().safeEmit({
1462
+ action: "gateContract.posture.unmapped",
1463
+ outcome: "success",
1464
+ metadata: {
1465
+ severity: "warning",
1466
+ posture: posture,
1467
+ guard: prefix || "guard",
1468
+ recommendation: "The pinned compliance posture '" + posture +
1469
+ "' has no overlay in this guard's COMPLIANCE_POSTURES table, so " +
1470
+ "its gate runs the unposture-d default. Pass an explicit " +
1471
+ "compliancePosture this guard maps, or add the overlay, if the " +
1472
+ "posture is meant to tighten this surface.",
1473
+ },
1474
+ });
1475
+ } catch (_e) { /* drop-silent — warning must not break gate construction */ }
1476
+ }
1477
+
1478
+ function _resetForTest() {
1479
+ for (var k in _unmappedPostureWarned) delete _unmappedPostureWarned[k];
1480
+ }
1481
+
1430
1482
  /**
1431
1483
  * @primitive b.gateContract.buildProfile
1432
1484
  * @signature b.gateContract.buildProfile(opts)
@@ -1658,4 +1710,5 @@ module.exports = {
1658
1710
  MODES: MODES,
1659
1711
  ISSUE_SEVERITIES: ISSUE_SEVERITIES,
1660
1712
  GateContractError: GateContractError,
1713
+ _resetForTest: _resetForTest,
1661
1714
  };
@@ -1925,7 +1925,21 @@ async function downloadStream(opts) {
1925
1925
  });
1926
1926
  counter.bytesWritten = 0;
1927
1927
 
1928
- var fileStream = nodeFs.createWriteStream(tmpPath, { mode: DEFAULT_DOWNLOAD_FILE_MODE, flags: "w" });
1928
+ // CWE-377 (insecure temporary file) / CWE-59 (symlink follow): stage
1929
+ // the download into the sibling tmp file with an EXCLUSIVE, no-follow
1930
+ // create. The legacy "w" flag is O_WRONLY|O_CREAT|O_TRUNC — it would
1931
+ // open (and truncate, or write through) a file an attacker pre-planted
1932
+ // at tmpPath, including a symlink aimed at a victim path this process
1933
+ // can write. O_EXCL fails with EEXIST if anything already exists at
1934
+ // tmpPath; O_NOFOLLOW rejects a symlink in the final path component
1935
+ // where the platform defines it (Windows leaves it undefined → `|| 0`).
1936
+ // tmpPath already carries a 64-bit CSPRNG suffix (line above), so an
1937
+ // EEXIST here is a hostile-collision signal, not a benign retry.
1938
+ var fileStream = nodeFs.createWriteStream(tmpPath, {
1939
+ mode: DEFAULT_DOWNLOAD_FILE_MODE,
1940
+ flags: nodeFs.constants.O_WRONLY | nodeFs.constants.O_CREAT |
1941
+ nodeFs.constants.O_EXCL | (nodeFs.constants.O_NOFOLLOW || 0),
1942
+ });
1929
1943
 
1930
1944
  try {
1931
1945
  await streamPromises.pipeline(res.body, counter, fileStream);
@@ -1944,14 +1958,14 @@ async function downloadStream(opts) {
1944
1958
  // across platforms but matches the discipline of the rest of the
1945
1959
  // framework's atomic-write paths.
1946
1960
  //
1947
- // CodeQL js/insecure-temporary-file: tmpPath = dest + ".tmp-" +
1948
- // bCrypto.generateToken(C.BYTES.bytes(8)) (line 1802), where
1949
- // bCrypto.generateToken produces 16 hex chars of CSPRNG-derived
1950
- // randomness. The path lives next to operator-supplied `dest`
1951
- // (downloadStream contract — never under os.tmpdir()), and the
1952
- // 64-bit unpredictable suffix defeats the symlink-pre-creation
1953
- // attack the rule flags. The fd is used solely for fsync; the
1954
- // file's bytes were already written by the upstream pipeline.
1961
+ // CodeQL js/insecure-temporary-file (CWE-377 / CWE-59): the tmp file
1962
+ // was already created above with O_EXCL | O_NOFOLLOW, so this reopen
1963
+ // binds to an inode this process exclusively created at a path
1964
+ // carrying a 64-bit CSPRNG suffix (line above) next to operator-
1965
+ // supplied `dest` (downloadStream contract — never under os.tmpdir()).
1966
+ // The earlier exclusive create — not the reopen — is the symlink-
1967
+ // pre-creation defense; this fd is used solely for fsync, after the
1968
+ // upstream pipeline already wrote the bytes.
1955
1969
  try {
1956
1970
  var fd = nodeFs.openSync(tmpPath, "r+");
1957
1971
  try { atomicFile.fsync(fd); } finally { try { nodeFs.closeSync(fd); } catch (_c) { /* best-effort fd close */ } }
@@ -316,6 +316,33 @@ function create(opts) {
316
316
  return cur;
317
317
  }
318
318
 
319
+ // ---- Account authorization (RFC 8620 §3.6.1 accountNotFound) ------------
320
+ //
321
+ // The set of accountIds an actor may touch is whatever
322
+ // `opts.accountsFor(actor)` enumerates in its `accounts` map — the same
323
+ // source the session resource advertises. Resolving it ONCE per request
324
+ // and rejecting any client-supplied accountId outside that set is the
325
+ // cross-tenant authorization control: without it, a tenant's request can
326
+ // name another tenant's accountId and reach the operator's method/blob
327
+ // handler, which must then independently re-check or leak. The listener
328
+ // owns this gate so every account-scoped op (method dispatch + blob
329
+ // upload/download) is covered uniformly.
330
+ async function _permittedAccountIds(actor) {
331
+ var info = await opts.accountsFor(actor);
332
+ info = info || {};
333
+ var accounts = info.accounts || {};
334
+ // A Set of the accountIds the actor is enumerated for. An empty/garbage
335
+ // accounts map yields an empty set → every account-scoped reference is
336
+ // rejected (fail-closed), which is the correct posture for an actor the
337
+ // operator declined to grant any account.
338
+ var set = Object.create(null);
339
+ if (accounts && typeof accounts === "object") {
340
+ var ids = Object.keys(accounts);
341
+ for (var i = 0; i < ids.length; i += 1) set[ids[i]] = true;
342
+ }
343
+ return set;
344
+ }
345
+
319
346
  // ---- Dispatch ------------------------------------------------------------
320
347
  //
321
348
  // `dispatch(actor, body)` is the operator-callable form — accepts a
@@ -340,6 +367,20 @@ function create(opts) {
340
367
  return _refusalResponse(errType, (e && e.message) || "request refused");
341
368
  }
342
369
 
370
+ // Resolve the actor's permitted accountId set ONCE for the whole
371
+ // request (RFC 8620 §3.6.1). Every account-scoped method call is gated
372
+ // against it below, BEFORE the operator handler runs, so a client can't
373
+ // name another tenant's accountId and reach the backend.
374
+ var permittedAccounts;
375
+ try {
376
+ permittedAccounts = await _permittedAccountIds(actor);
377
+ } catch (e) {
378
+ _emit("mail.server.jmap.accounts_for_threw",
379
+ { error: (e && e.message) || String(e) }, "failure");
380
+ return _refusalResponse("urn:ietf:params:jmap:error:serverFail",
381
+ "account authorization unavailable");
382
+ }
383
+
343
384
  var methodResponses = [];
344
385
  var byClientId = Object.create(null);
345
386
  for (var i = 0; i < parsed.methodCalls.length; i += 1) {
@@ -361,6 +402,24 @@ function create(opts) {
361
402
  description: "Method '" + methodName + "' not implemented on this server" }, clientId]);
362
403
  continue;
363
404
  }
405
+ // Cross-tenant gate (RFC 8620 §3.6.1): if the call names an accountId,
406
+ // it MUST be one the actor is enumerated for. Rejected BEFORE the
407
+ // operator handler runs so a forged/foreign accountId never reaches
408
+ // the backend. Calls without an accountId (account-agnostic methods)
409
+ // pass through unchanged.
410
+ if (resolvedArgs && typeof resolvedArgs === "object" &&
411
+ resolvedArgs.accountId !== undefined && resolvedArgs.accountId !== null) {
412
+ var callAccountId = resolvedArgs.accountId;
413
+ if (typeof callAccountId !== "string" || !permittedAccounts[callAccountId]) {
414
+ _emit("mail.server.jmap.account_not_found",
415
+ { method: methodName, accountId: typeof callAccountId === "string" ? callAccountId : null,
416
+ clientId: clientId }, "denied");
417
+ methodResponses.push(["error",
418
+ { type: "urn:ietf:params:jmap:error:accountNotFound",
419
+ description: "accountId is not accessible to this actor" }, clientId]);
420
+ continue;
421
+ }
422
+ }
364
423
  if (!_legacyDeprecationEmitted && registry.source(methodName) === "builtin") {
365
424
  _legacyDeprecationEmitted = true;
366
425
  _emit("mail.server.jmap.methods_opt_deprecated",
@@ -813,6 +872,40 @@ function create(opts) {
813
872
  if (refused) return;
814
873
  var bytes = collector.result();
815
874
  Promise.resolve()
875
+ // Cross-tenant gate (RFC 8620 §3.6.1): the accountId in the upload
876
+ // URL must be one the actor is enumerated for, else accountNotFound
877
+ // — the foreign accountId never reaches uploadBlob.
878
+ .then(function () { return _permittedAccountIds(actor); })
879
+ .then(function (permitted) {
880
+ if (!permitted[accountId]) {
881
+ _emit("mail.server.jmap.account_not_found",
882
+ { op: "upload", accountId: accountId }, "denied");
883
+ res.statusCode = 404;
884
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
885
+ res.end(JSON.stringify({
886
+ type: "urn:ietf:params:jmap:error:accountNotFound",
887
+ description: "accountId is not accessible to this actor",
888
+ }));
889
+ return;
890
+ }
891
+ return _completeUpload(bytes);
892
+ })
893
+ .catch(function (err) {
894
+ _emit("mail.server.jmap.upload_threw",
895
+ { accountId: accountId, error: (err && err.message) || String(err) }, "failure");
896
+ if (!res.headersSent) {
897
+ res.statusCode = 500;
898
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
899
+ res.end(JSON.stringify({
900
+ type: "urn:ietf:params:jmap:error:serverFail",
901
+ description: "Upload failed",
902
+ }));
903
+ }
904
+ });
905
+ });
906
+
907
+ function _completeUpload(bytes) {
908
+ return Promise.resolve()
816
909
  .then(function () { return opts.mailStore.uploadBlob(actor, accountId, contentType, bytes); })
817
910
  .then(function (meta) {
818
911
  if (!meta || typeof meta !== "object" || typeof meta.blobId !== "string") {
@@ -827,18 +920,10 @@ function create(opts) {
827
920
  type: meta.type || contentType,
828
921
  size: typeof meta.size === "number" ? meta.size : bytes.length,
829
922
  }));
830
- })
831
- .catch(function (err) {
832
- _emit("mail.server.jmap.upload_threw",
833
- { accountId: accountId, error: (err && err.message) || String(err) }, "failure");
834
- res.statusCode = 500;
835
- res.setHeader("Content-Type", "application/json; charset=utf-8");
836
- res.end(JSON.stringify({
837
- type: "urn:ietf:params:jmap:error:serverFail",
838
- description: "Upload failed",
839
- }));
840
923
  });
841
- });
924
+ // Errors from uploadBlob propagate to the req.on("end") chain's
925
+ // .catch (single serverFail responder, headersSent-guarded).
926
+ }
842
927
  req.on("error", function () {
843
928
  if (!refused) {
844
929
  refused = true;
@@ -944,9 +1029,29 @@ function create(opts) {
944
1029
  }));
945
1030
  return;
946
1031
  }
1032
+ var downloadDenied = false;
947
1033
  Promise.resolve()
948
- .then(function () { return opts.mailStore.downloadBlob(actor, accountId, blobId); })
1034
+ // Cross-tenant gate (RFC 8620 §3.6.1): the accountId in the download
1035
+ // URL must be one the actor is enumerated for, else accountNotFound —
1036
+ // the foreign accountId never reaches downloadBlob.
1037
+ .then(function () { return _permittedAccountIds(actor); })
1038
+ .then(function (permitted) {
1039
+ if (!permitted[accountId]) {
1040
+ downloadDenied = true;
1041
+ _emit("mail.server.jmap.account_not_found",
1042
+ { op: "download", accountId: accountId, blobId: blobId }, "denied");
1043
+ res.statusCode = 404;
1044
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
1045
+ res.end(JSON.stringify({
1046
+ type: "urn:ietf:params:jmap:error:accountNotFound",
1047
+ description: "accountId is not accessible to this actor",
1048
+ }));
1049
+ return undefined;
1050
+ }
1051
+ return opts.mailStore.downloadBlob(actor, accountId, blobId);
1052
+ })
949
1053
  .then(function (result) {
1054
+ if (downloadDenied) return;
950
1055
  if (!result || (typeof result !== "object" && !Buffer.isBuffer(result))) {
951
1056
  res.statusCode = 404;
952
1057
  res.setHeader("Content-Type", "application/json; charset=utf-8");
@@ -166,6 +166,29 @@ var BodyParserError = defineClass("BodyParserError", { withStatusCode: true });
166
166
  // in play — consistent prototype-pollution defense across the framework.
167
167
  var POISONED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
168
168
 
169
+ // Materialize a header/parameter map from request-derived [key, value]
170
+ // pairs WITHOUT a computed member write (`target[key] = value`). A
171
+ // request-keyed computed write is the CWE-915 unsafe-reflection /
172
+ // CWE-1321 prototype-pollution sink: an attacker who controls the key
173
+ // (Content-Type parameter name, multipart part-header name,
174
+ // Content-Disposition parameter name) can target `__proto__` /
175
+ // `constructor` / `prototype` and corrupt the prototype chain. Poisoned
176
+ // keys are dropped, the remaining pairs are funneled through
177
+ // `Object.fromEntries`, and the result carries no prototype chain
178
+ // (`Object.create(null)`) so even a key that slipped a future POISONED_KEYS
179
+ // gap cannot reach Object.prototype. The returned map has plain-object
180
+ // shape (string keys → values) so existing named-property reads
181
+ // (`.boundary`, `.charset`, `["content-disposition"]`, `.name`,
182
+ // `.filename`) are unchanged.
183
+ function _mapFromPairs(pairs) {
184
+ var safe = [];
185
+ for (var i = 0; i < pairs.length; i++) {
186
+ if (POISONED_KEYS.has(pairs[i][0])) continue;
187
+ safe.push(pairs[i]);
188
+ }
189
+ return Object.assign(Object.create(null), Object.fromEntries(safe));
190
+ }
191
+
169
192
  // ---- defaults ----
170
193
 
171
194
  var DEFAULTS = Object.freeze({
@@ -221,7 +244,11 @@ function _contentType(req) {
221
244
  if (typeof ct !== "string") return { type: "", params: {} };
222
245
  var idx = ct.indexOf(";");
223
246
  var type = (idx === -1 ? ct : ct.slice(0, idx)).trim().toLowerCase();
224
- var params = {};
247
+ // Collect [name, value] pairs, then materialize via _mapFromPairs so a
248
+ // request-controlled parameter name (e.g. `boundary` / `charset` / an
249
+ // attacker-supplied `__proto__`) is never used as a computed-write key
250
+ // (CWE-915 / CWE-1321). Poisoned names are dropped at materialization.
251
+ var paramPairs = [];
225
252
  if (idx !== -1) {
226
253
  var rest = ct.slice(idx + 1);
227
254
  // RFC 9110 §8.3 + §5.6.6 — parameter values may be quoted-string
@@ -238,10 +265,10 @@ function _contentType(req) {
238
265
  var v = p.slice(eq + 1).trim();
239
266
  var _unq = structuredFields.unquoteSfString(v);
240
267
  if (_unq !== null) v = _unq;
241
- params[k] = v;
268
+ paramPairs.push([k, v]);
242
269
  }
243
270
  }
244
- return { type: type, params: params };
271
+ return { type: type, params: _mapFromPairs(paramPairs) };
245
272
  }
246
273
 
247
274
  function _typeMatches(actual, allowed) {
@@ -583,7 +610,13 @@ function _parseMultipartHeaders(rawHeaders) {
583
610
  // §5.5 — header field values MUST NOT contain CR, LF, or NUL bytes.
584
611
  // We refuse the part outright (caller surfaces the throw as 400 + drop).
585
612
  var lines = rawHeaders.split("\r\n");
586
- var out = {};
613
+ // Collect [name, value] pairs; materialize via _mapFromPairs so the
614
+ // request-controlled header name is never a computed-write key
615
+ // (CWE-915 / CWE-1321 — a part header literally named `__proto__` would
616
+ // otherwise pollute the prototype chain). Later headers of the same
617
+ // name keep last-wins (the prior `out[k] = v` overwrite semantics:
618
+ // Object.fromEntries takes the last pair for a duplicate key).
619
+ var headerPairs = [];
587
620
  for (var i = 0; i < lines.length; i++) {
588
621
  var line = lines[i];
589
622
  if (!line) continue;
@@ -609,9 +642,9 @@ function _parseMultipartHeaders(rawHeaders) {
609
642
  );
610
643
  }
611
644
  }
612
- out[k] = v;
645
+ headerPairs.push([k, v]);
613
646
  }
614
- return out;
647
+ return _mapFromPairs(headerPairs);
615
648
  }
616
649
 
617
650
  // Percent-decode an RFC 5987 ext-value's value segment under iso-8859-1.
@@ -672,14 +705,20 @@ function _parseHeaderParams(headerValue, filenameCharsets) {
672
705
  // is present, it takes precedence over the legacy `filename=`
673
706
  // companion (RFC 6266 §4.3). We surface the decoded value at
674
707
  // `filename` so downstream consumers don't need parser-aware code.
675
- var out = { _value: "" };
676
- if (!headerValue) return out;
708
+ if (!headerValue) return _mapFromPairs([["_value", ""]]);
677
709
  // RFC 6266 §4.1 + RFC 9110 §5.6.6 — parameter values may be
678
710
  // quoted-string (e.g. `filename="weird;name.txt"`). Bare
679
711
  // `.split(";")` would slice through the quoted semicolon and
680
712
  // corrupt the filename. Quote-aware shared splitter.
681
713
  var parts = structuredFields.splitTopLevel(headerValue, ";");
682
- out._value = parts[0].trim().toLowerCase();
714
+ // Collect [name, value] pairs, then materialize via _mapFromPairs so a
715
+ // request-controlled Content-Disposition parameter name (or its
716
+ // ext-value `name*` bare form) is never a computed-write key
717
+ // (CWE-915 / CWE-1321). `_value` carries the disposition type;
718
+ // `filename` (when an ext-value decoded) takes precedence over the
719
+ // legacy `filename=` companion (RFC 6266 §4.3), preserved by appending
720
+ // it last so Object.fromEntries' last-wins resolves it.
721
+ var paramPairs = [["_value", parts[0].trim().toLowerCase()]];
683
722
  var extName = null;
684
723
  for (var i = 1; i < parts.length; i++) {
685
724
  var p = parts[i].trim();
@@ -694,14 +733,14 @@ function _parseHeaderParams(headerValue, filenameCharsets) {
694
733
  if (decoded !== null) {
695
734
  var bareKey = k.slice(0, -1);
696
735
  if (bareKey === "filename") extName = decoded;
697
- out[bareKey] = decoded;
736
+ paramPairs.push([bareKey, decoded]);
698
737
  }
699
738
  continue;
700
739
  }
701
- out[k] = v;
740
+ paramPairs.push([k, v]);
702
741
  }
703
- if (extName !== null) out.filename = extName;
704
- return out;
742
+ if (extName !== null) paramPairs.push(["filename", extName]);
743
+ return _mapFromPairs(paramPairs);
705
744
  }
706
745
 
707
746
  async function _parseMultipart(req, opts, ctParams) {
@@ -1173,20 +1212,27 @@ async function _parseMultipart(req, opts, ctParams) {
1173
1212
  var fbuf = Buffer.concat(currentBuf);
1174
1213
  var text = fbuf.toString("utf8");
1175
1214
  // Repeated field name → array, matching urlencoded parser.
1176
- if (Object.prototype.hasOwnProperty.call(fields, currentField)) {
1177
- // lgtm[js/remote-property-injection] `currentField` is gated
1178
- // upstream at lib/middleware/body-parser.js:867 by
1179
- // POISONED_KEYS (__proto__ / constructor / prototype) which
1180
- // refuses the multipart part with a 400 BodyParserError before
1181
- // `currentField` is ever assigned. Reachable values cannot
1182
- // pollute the prototype chain.
1183
- if (Array.isArray(fields[currentField])) fields[currentField].push(text);
1184
- else fields[currentField] = [fields[currentField], text];
1215
+ // `currentField` is request-controlled, so the accumulation
1216
+ // never uses it as a computed-write key (`fields[key] = v`),
1217
+ // which is the CWE-915 / CWE-1321 sink: it is merged through
1218
+ // Object.fromEntries + Object.assign instead. The upstream
1219
+ // POISONED_KEYS gate (the multipart-poisoned-field check above)
1220
+ // already rejects __proto__ / constructor / prototype field
1221
+ // names with a 400 before reaching here; the entries-merge is
1222
+ // the structural backstop.
1223
+ var fieldName = currentField;
1224
+ var prior = Object.prototype.hasOwnProperty.call(fields, fieldName)
1225
+ ? fields[fieldName] : undefined;
1226
+ var nextValue;
1227
+ if (prior === undefined) {
1228
+ nextValue = text;
1229
+ } else if (Array.isArray(prior)) {
1230
+ prior.push(text);
1231
+ nextValue = prior;
1185
1232
  } else {
1186
- // lgtm[js/remote-property-injection] — see upstream POISONED_KEYS
1187
- // gate at lib/middleware/body-parser.js:867.
1188
- fields[currentField] = text;
1233
+ nextValue = [prior, text];
1189
1234
  }
1235
+ Object.assign(fields, Object.fromEntries([[fieldName, nextValue]]));
1190
1236
  }
1191
1237
  currentHeaders = null;
1192
1238
  currentField = null;
@@ -91,25 +91,36 @@ function _parseCookieHeader(header) {
91
91
  // just splits the name=value pairs. Keys that appear multiple times
92
92
  // resolve to the FIRST occurrence (browsers send pairs left-to-right
93
93
  // by registration order; the first is the most-specific path).
94
- // Output object has no prototype chain `Object.create(null)` defends
95
- // against `__proto__` / `constructor` / `prototype` cookie-name keys
96
- // polluting the prototype before the hasOwnProperty gate runs.
97
- var out = Object.create(null);
98
- if (typeof header !== "string" || header.length === 0) return out;
94
+ // Collect [name, value] pairs, then materialize the cookie map via
95
+ // Object.fromEntries onto a null-prototype object. The cookie name is
96
+ // attacker-controlled (Cookie request header), so it is never used as a
97
+ // computed-write key (`out[name] = value` / `seen[name] = true`) — that
98
+ // is the CWE-915 unsafe-reflection / CWE-1321 prototype-pollution sink.
99
+ // First-occurrence-wins de-duplication tracks names in a Set (add/has
100
+ // are method calls, not tainted-key property writes); POISONED names
101
+ // (`__proto__` / `constructor` / `prototype`) are dropped; and the
102
+ // null-prototype accumulator means even a slipped name cannot reach
103
+ // Object.prototype.
104
+ if (typeof header !== "string" || header.length === 0) return Object.create(null);
99
105
  var parts = header.split(/;\s*/);
106
+ var seen = new Set();
107
+ var pairs = [];
100
108
  for (var i = 0; i < parts.length; i++) {
101
109
  var p = parts[i];
102
110
  var eq = p.indexOf("=");
103
111
  if (eq === -1) continue;
104
112
  var k = p.slice(0, eq).trim();
105
- if (k.length === 0 || Object.prototype.hasOwnProperty.call(out, k)) continue;
113
+ if (k.length === 0) continue;
114
+ if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
115
+ if (seen.has(k)) continue; // first-occurrence wins
116
+ seen.add(k);
106
117
  var v = p.slice(eq + 1).trim();
107
118
  if (v.length >= 2 && v.charCodeAt(0) === 0x22 && v.charCodeAt(v.length - 1) === 0x22) {
108
119
  v = v.slice(1, -1);
109
120
  }
110
- out[k] = v;
121
+ pairs.push([k, v]);
111
122
  }
112
- return out;
123
+ return Object.assign(Object.create(null), Object.fromEntries(pairs));
113
124
  }
114
125
 
115
126
  // `_isHttps` defers to `requestHelpers.requestProtocol` so the