@blamejs/core 0.14.25 → 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.
- package/CHANGELOG.md +4 -0
- package/lib/agent-envelope-mac.js +104 -0
- package/lib/agent-event-bus.js +105 -4
- package/lib/agent-posture-chain.js +8 -42
- package/lib/atomic-file.js +33 -3
- package/lib/audit.js +31 -23
- package/lib/auth/oauth.js +25 -5
- package/lib/auth/openid-federation.js +108 -47
- package/lib/auth/sd-jwt-vc.js +16 -3
- package/lib/break-glass.js +153 -3
- package/lib/compliance.js +147 -4
- package/lib/crypto-field.js +87 -1
- package/lib/dsr.js +378 -52
- package/lib/error-page.js +14 -1
- package/lib/file-upload.js +52 -7
- package/lib/framework-error.js +3 -1
- package/lib/gate-contract.js +53 -0
- package/lib/http-client.js +23 -9
- package/lib/mail-server-jmap.js +117 -12
- package/lib/middleware/body-parser.js +71 -25
- package/lib/middleware/csrf-protect.js +19 -8
- package/lib/object-store/azure-blob.js +28 -2
- package/lib/observability.js +87 -0
- package/lib/otel-export.js +25 -1
- package/lib/parsers/safe-xml.js +47 -7
- package/lib/queue-local.js +23 -1
- package/lib/queue.js +7 -0
- package/lib/redact.js +68 -11
- package/lib/redis-client.js +160 -31
- package/lib/request-helpers.js +7 -0
- package/lib/router.js +212 -5
- package/lib/ssrf-guard.js +51 -4
- package/lib/static.js +132 -27
- package/lib/vault/rotate.js +64 -44
- package/lib/websocket.js +19 -5
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/framework-error.js
CHANGED
|
@@ -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,
|
|
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.
|
package/lib/gate-contract.js
CHANGED
|
@@ -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
|
};
|
package/lib/http-client.js
CHANGED
|
@@ -1925,7 +1925,21 @@ async function downloadStream(opts) {
|
|
|
1925
1925
|
});
|
|
1926
1926
|
counter.bytesWritten = 0;
|
|
1927
1927
|
|
|
1928
|
-
|
|
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
|
|
1948
|
-
//
|
|
1949
|
-
//
|
|
1950
|
-
//
|
|
1951
|
-
// (downloadStream contract — never under os.tmpdir())
|
|
1952
|
-
//
|
|
1953
|
-
//
|
|
1954
|
-
//
|
|
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 */ } }
|
package/lib/mail-server-jmap.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
+
paramPairs.push([k, v]);
|
|
242
269
|
}
|
|
243
270
|
}
|
|
244
|
-
return { type: type, 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
|
-
|
|
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
|
-
|
|
645
|
+
headerPairs.push([k, v]);
|
|
613
646
|
}
|
|
614
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
736
|
+
paramPairs.push([bareKey, decoded]);
|
|
698
737
|
}
|
|
699
738
|
continue;
|
|
700
739
|
}
|
|
701
|
-
|
|
740
|
+
paramPairs.push([k, v]);
|
|
702
741
|
}
|
|
703
|
-
if (extName !== null)
|
|
704
|
-
return
|
|
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
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
121
|
+
pairs.push([k, v]);
|
|
111
122
|
}
|
|
112
|
-
return
|
|
123
|
+
return Object.assign(Object.create(null), Object.fromEntries(pairs));
|
|
113
124
|
}
|
|
114
125
|
|
|
115
126
|
// `_isHttps` defers to `requestHelpers.requestProtocol` so the
|
|
@@ -35,6 +35,7 @@ var { URL } = require("node:url");
|
|
|
35
35
|
var { Readable } = require("node:stream");
|
|
36
36
|
var safeXml = require("../parsers/safe-xml");
|
|
37
37
|
var sharedRequest = require("./http-request");
|
|
38
|
+
var sigv4 = require("./sigv4");
|
|
38
39
|
var C = require("../constants");
|
|
39
40
|
var requestHelpers = require("../request-helpers");
|
|
40
41
|
var { ObjectStoreError } = require("../framework-error");
|
|
@@ -65,6 +66,26 @@ function _arrayify(value) {
|
|
|
65
66
|
return Array.isArray(value) ? value : [value];
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
// Percent-encode a hierarchical blob name for use in a URL path. Azure
|
|
70
|
+
// blob names are `/`-delimited virtual directories, so each segment is
|
|
71
|
+
// RFC 3986 percent-encoded (via the family-shared encoder used by the
|
|
72
|
+
// S3 / GCS backends) while the `/` separators are preserved. Without
|
|
73
|
+
// this, a key containing `?`, `#`, a space, or other reserved chars is
|
|
74
|
+
// interpolated raw into the request URL — `?`/`#` start the query /
|
|
75
|
+
// fragment (so the blob path is truncated, hitting the wrong object or
|
|
76
|
+
// the container root), and spaces / control bytes corrupt the request
|
|
77
|
+
// line (CWE-20 improper input → request-smuggling-adjacent). A null
|
|
78
|
+
// byte is refused outright (it can't appear in a valid blob name and
|
|
79
|
+
// indicates a malformed / hostile key), matching the S3 / GCS guards.
|
|
80
|
+
function _encodeBlobKey(key) {
|
|
81
|
+
if (key.indexOf("\0") !== -1) {
|
|
82
|
+
throw _err("INVALID_KEY", "null byte in blob key", true);
|
|
83
|
+
}
|
|
84
|
+
return key.split("/").map(function (s) {
|
|
85
|
+
return sigv4.awsUriEncode(s, true);
|
|
86
|
+
}).join("/");
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
var DEFAULT_API_VERSION = "2024-08-04";
|
|
69
90
|
|
|
70
91
|
// Service SAS expiry bounds. Azure doesn't enforce a hard max, but
|
|
@@ -208,7 +229,8 @@ function create(config) {
|
|
|
208
229
|
if (allowInternal !== null) reqOpts.allowInternal = allowInternal;
|
|
209
230
|
|
|
210
231
|
function _blobUrl(key, params) {
|
|
211
|
-
var u = _internalUrl(endpoint + "/" + config.container + "/" + key,
|
|
232
|
+
var u = _internalUrl(endpoint + "/" + config.container + "/" + _encodeBlobKey(key),
|
|
233
|
+
allowedProtocols);
|
|
212
234
|
if (params) {
|
|
213
235
|
Object.keys(params).forEach(function (k) { u.searchParams.set(k, params[k]); });
|
|
214
236
|
}
|
|
@@ -425,8 +447,12 @@ function create(config) {
|
|
|
425
447
|
throw _err("INVALID_KEY", "null byte in key", true);
|
|
426
448
|
}
|
|
427
449
|
|
|
450
|
+
// _buildSasToken signs the canonicalized resource with the RAW
|
|
451
|
+
// (decoded) blob name per the Azure SAS spec; the URL PATH carries the
|
|
452
|
+
// percent-encoded key so a key with reserved chars (`?` / `#` / space)
|
|
453
|
+
// doesn't truncate the path or corrupt the request line.
|
|
428
454
|
var token = _buildSasToken(permissions, opts);
|
|
429
|
-
var url = _internalUrl(endpoint + "/" + config.container + "/" + opts.key + "?" + token.sas, allowedProtocols);
|
|
455
|
+
var url = _internalUrl(endpoint + "/" + config.container + "/" + _encodeBlobKey(opts.key) + "?" + token.sas, allowedProtocols);
|
|
430
456
|
|
|
431
457
|
var clientHeaders = {};
|
|
432
458
|
if (opts.contentType) clientHeaders["Content-Type"] = opts.contentType;
|