@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/ssrf-guard.js
CHANGED
|
@@ -148,12 +148,27 @@ var IPV6_6TO4_PREFIX = _ipv6ToBytes("2002::");
|
|
|
148
148
|
// or attempted exfil to a sinkhole.
|
|
149
149
|
var IPV6_DISCARD_PREFIX = _ipv6ToBytes("100::");
|
|
150
150
|
|
|
151
|
-
// ---- Cloud metadata addresses (
|
|
151
|
+
// ---- Cloud metadata addresses (matched on CANONICAL bytes, not string) ----
|
|
152
|
+
// The documentation strings below are the human-readable canonical forms.
|
|
153
|
+
// Matching is byte-canonical (see _isCloudMetadataAddr): an IPv6 address has
|
|
154
|
+
// many textual representations (compressed `::`, fully-expanded
|
|
155
|
+
// `fd00:ec2:0:0:0:0:0:254`, mixed-case) that all decode to the same 16 bytes.
|
|
156
|
+
// A string-equality membership test matched only ONE spelling, so a hostile
|
|
157
|
+
// (or merely DoH-decoded — network-dns.js emits the expanded form) answer of
|
|
158
|
+
// `fd00:ec2:0:0:0:0:0:254` slipped past as "private" and rode the documented
|
|
159
|
+
// `allowInternal:true` waiver straight into the IMDS credential endpoint.
|
|
152
160
|
var CLOUD_METADATA_IPS = [
|
|
153
161
|
"169.254.169.254", // AWS, GCP, Azure, OpenStack, DO
|
|
154
162
|
"169.254.170.2", // AWS ECS task role
|
|
155
163
|
"fd00:ec2::254", // AWS IMDS over IPv6
|
|
156
164
|
];
|
|
165
|
+
// Canonical byte forms of the metadata IPs — v4 as a 4-byte Buffer, v6 as a
|
|
166
|
+
// 16-byte Buffer. Built once at load via the same parsers classify() uses,
|
|
167
|
+
// so every textual representation that decodes to these bytes is caught.
|
|
168
|
+
var CLOUD_METADATA_BYTES = CLOUD_METADATA_IPS.map(function (ip) {
|
|
169
|
+
var fam = net.isIP(ip);
|
|
170
|
+
return fam === 4 ? _ipv4ToBytes(ip) : _ipv6ToBytes(ip);
|
|
171
|
+
});
|
|
157
172
|
|
|
158
173
|
// ---- Helpers ----
|
|
159
174
|
|
|
@@ -180,6 +195,14 @@ function _ipv4ToInt(ip) {
|
|
|
180
195
|
nums[3];
|
|
181
196
|
}
|
|
182
197
|
|
|
198
|
+
function _ipv4ToBytes(ip) {
|
|
199
|
+
// Canonical 4-byte form of an IPv4 address. Returns null on malformed
|
|
200
|
+
// input so a metadata-membership test never matches garbage.
|
|
201
|
+
var n = _ipv4ToInt(ip);
|
|
202
|
+
if (!Number.isFinite(n)) return null;
|
|
203
|
+
return Buffer.from([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
|
204
|
+
}
|
|
205
|
+
|
|
183
206
|
function _ipv6ToBytes(ip) {
|
|
184
207
|
// Node's net.isIPv6 returns 6 for valid IPv6; we then expand
|
|
185
208
|
// shorthand via manual parsing. node:net doesn't export an
|
|
@@ -309,7 +332,10 @@ function classify(ip) {
|
|
|
309
332
|
var family = net.isIP(ip);
|
|
310
333
|
if (family === 0) return null;
|
|
311
334
|
|
|
312
|
-
|
|
335
|
+
// Cloud-metadata IPs are matched on their canonical byte form so every
|
|
336
|
+
// textual spelling (compressed `::`, fully-expanded zero-runs, mixed
|
|
337
|
+
// case) is caught — a string-equality test matched one spelling only.
|
|
338
|
+
if (_isCloudMetadataAddr(ip, family)) return "cloud-metadata";
|
|
313
339
|
|
|
314
340
|
if (family === 4) {
|
|
315
341
|
var ipInt = _ipv4ToInt(ip);
|
|
@@ -349,6 +375,24 @@ function classify(ip) {
|
|
|
349
375
|
return null;
|
|
350
376
|
}
|
|
351
377
|
|
|
378
|
+
// Canonical-bytes membership test for the cloud-metadata IP set. An IP
|
|
379
|
+
// matches iff its parsed bytes equal one of CLOUD_METADATA_BYTES, regardless
|
|
380
|
+
// of textual representation. This is the unconditional metadata gate — it
|
|
381
|
+
// must NOT be string-based, because IPv6 has many spellings of the same
|
|
382
|
+
// address (the DoH resolver in network-dns.js, for instance, emits the
|
|
383
|
+
// fully-expanded `fd00:ec2:0:0:0:0:0:254` rather than the compressed form).
|
|
384
|
+
function _isCloudMetadataAddr(ip, family) {
|
|
385
|
+
var fam = typeof family === "number" ? family : net.isIP(ip);
|
|
386
|
+
if (fam === 0) return false;
|
|
387
|
+
var bytes = fam === 4 ? _ipv4ToBytes(ip) : _ipv6ToBytes(ip);
|
|
388
|
+
if (!bytes) return false;
|
|
389
|
+
for (var i = 0; i < CLOUD_METADATA_BYTES.length; i++) {
|
|
390
|
+
var ref = CLOUD_METADATA_BYTES[i];
|
|
391
|
+
if (ref && ref.length === bytes.length && _bufEqual(bytes, ref)) return true;
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
352
396
|
function _bufEqual(a, b) {
|
|
353
397
|
// Compares Buffer-like byte arrays for equality. The buffers here
|
|
354
398
|
// are IP addresses, not secrets, so the comparison doesn't need
|
|
@@ -766,8 +810,11 @@ function checkUrlTextual(url, opts) {
|
|
|
766
810
|
// If the textual hostname IS an IP literal AND matches a cloud-
|
|
767
811
|
// metadata IP, refuse — even with `allowInternal: true` and a proxy.
|
|
768
812
|
// Metadata IPs leak instance credentials (AWS IMDS, GCP, Azure) and
|
|
769
|
-
// are not a configuration knob.
|
|
770
|
-
|
|
813
|
+
// are not a configuration knob. Matched on canonical bytes so a
|
|
814
|
+
// non-canonical IPv6 spelling (compressed / expanded / mixed-case)
|
|
815
|
+
// can't slip the textual gate the way it slipped classify().
|
|
816
|
+
var hostFamily = net.isIP(host);
|
|
817
|
+
if (hostFamily !== 0 && _isCloudMetadataAddr(host, hostFamily)) {
|
|
771
818
|
throw new ErrorClass(
|
|
772
819
|
"URL '" + parsed.toString() + "' resolves to cloud-metadata IP " + host +
|
|
773
820
|
" — refused unconditionally (not overridable via allowInternal + proxy)",
|
package/lib/static.js
CHANGED
|
@@ -142,12 +142,68 @@ var DEFAULTS = Object.freeze({
|
|
|
142
142
|
safeRenderPdf: false,
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
// _assertInsideRoot — the path-confinement barrier (CWE-22 path
|
|
146
|
+
// traversal). Every filesystem sink in this module takes the path
|
|
147
|
+
// through this helper so the value handed to fs is built by
|
|
148
|
+
// `nodePath.join(root, rel)` where `rel` is a normalized, root-relative
|
|
149
|
+
// path with every leading `..` segment stripped — the canonical
|
|
150
|
+
// path-traversal sanitizer: normalize collapses interior `.`/`..`, the
|
|
151
|
+
// leading-`..` strip removes upward navigation, and joining a constant
|
|
152
|
+
// root with a sanitized relative segment yields a path that provably
|
|
153
|
+
// stays inside the served root. The barrier is intentionally re-applied
|
|
154
|
+
// at each sink (not just once at request entry) so the relationship
|
|
155
|
+
// between the sanitizer and the fs call is local + explicit.
|
|
156
|
+
//
|
|
157
|
+
// Returns the joined, confined absolute path on success, or `null` when
|
|
158
|
+
// the candidate is not a string, carries a NUL byte, or — after the
|
|
159
|
+
// leading-`..` strip — still carries a `..` segment or an absolute /
|
|
160
|
+
// drive-letter / UNC prefix that would smuggle outside root. A leading
|
|
161
|
+
// `..` escape is clamped into root by the strip (the file then 404s);
|
|
162
|
+
// any residual escape that survives normalization is refused. Callers
|
|
163
|
+
// MUST treat `null` as a refusal.
|
|
164
|
+
function _assertInsideRoot(root, candidate) {
|
|
165
|
+
if (typeof root !== "string" || root.length === 0) return null;
|
|
166
|
+
if (typeof candidate !== "string" || candidate.length === 0) return null;
|
|
167
|
+
if (candidate.indexOf("\0") !== -1) return null;
|
|
168
|
+
var rootResolved = nodePath.resolve(root);
|
|
169
|
+
// Reduce the candidate to a root-relative request, then run the
|
|
170
|
+
// recognized traversal sanitizer: normalize() collapses `.`/`..`
|
|
171
|
+
// segments; the replace strips every leading `..` so no upward
|
|
172
|
+
// navigation survives into the join below.
|
|
173
|
+
var requested = nodePath.isAbsolute(candidate)
|
|
174
|
+
? nodePath.relative(rootResolved, candidate)
|
|
175
|
+
: candidate;
|
|
176
|
+
var rel = nodePath.normalize(requested).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
177
|
+
if (rel.indexOf("\0") !== -1) return null;
|
|
178
|
+
// After the leading-`..` strip, a surviving `..` segment or an
|
|
179
|
+
// absolute / drive-letter / UNC residue would re-introduce an escape.
|
|
180
|
+
if (rel === ".." ||
|
|
181
|
+
rel.indexOf(".." + nodePath.sep) !== -1 ||
|
|
182
|
+
rel.indexOf(".." + (nodePath.sep === "/" ? "\\" : "/")) !== -1 ||
|
|
183
|
+
nodePath.isAbsolute(rel)) return null;
|
|
184
|
+
var safe = nodePath.join(rootResolved, rel);
|
|
185
|
+
// Defense-in-depth lexical containment alongside the join sanitizer.
|
|
186
|
+
if (safe !== rootResolved &&
|
|
187
|
+
!safe.startsWith(rootResolved + nodePath.sep)) return null;
|
|
188
|
+
return safe;
|
|
189
|
+
}
|
|
190
|
+
|
|
145
191
|
// Module-level metadata cache. Entries hold:
|
|
146
192
|
// { mtimeMs, size, etag, integrity, lastModified, sha3Hex, absPath }
|
|
147
193
|
// Invalidated on mtime / size change.
|
|
148
194
|
var _metaCache = new Map();
|
|
149
195
|
|
|
150
|
-
|
|
196
|
+
// _readMeta — stat + hash a file for the conditional-request + SRI
|
|
197
|
+
// surface. `root` is passed alongside the candidate so the
|
|
198
|
+
// path-traversal barrier (CWE-22) is re-asserted at THIS sink: the
|
|
199
|
+
// value handed to fs.stat / fs.createReadStream is the confined return
|
|
200
|
+
// of `_assertInsideRoot`, not the request-derived candidate. Returns
|
|
201
|
+
// null when the candidate escapes root, is not a regular file, or
|
|
202
|
+
// cannot be read.
|
|
203
|
+
async function _readMeta(root, candidate) {
|
|
204
|
+
var absPath = _assertInsideRoot(root, candidate);
|
|
205
|
+
if (!absPath) return null;
|
|
206
|
+
|
|
151
207
|
var stat;
|
|
152
208
|
try { stat = await fsp.stat(absPath); }
|
|
153
209
|
catch (_e) { return null; }
|
|
@@ -164,10 +220,9 @@ async function _readMeta(absPath) {
|
|
|
164
220
|
var sri = nodeCrypto.createHash("sha384");
|
|
165
221
|
var sha3 = nodeCrypto.createHash("sha3-512");
|
|
166
222
|
await new Promise(function (resolve, reject) {
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// root-prefix
|
|
170
|
-
// Callers cannot reach `_readMeta` with an unvalidated path.
|
|
223
|
+
// The path handed to createReadStream is the confined output of
|
|
224
|
+
// `_assertInsideRoot(root, candidate)` above (lexical resolve +
|
|
225
|
+
// root-prefix containment), not the request-derived candidate.
|
|
171
226
|
var s = nodeFs.createReadStream(absPath);
|
|
172
227
|
s.on("data", function (chunk) { sri.update(chunk); sha3.update(chunk); });
|
|
173
228
|
s.on("end", resolve);
|
|
@@ -192,10 +247,16 @@ async function _readMeta(absPath) {
|
|
|
192
247
|
function _resolveSafe(root, requestedPath) {
|
|
193
248
|
if (typeof requestedPath !== "string" || requestedPath.length === 0) return null;
|
|
194
249
|
if (requestedPath.indexOf("\0") !== -1) return null;
|
|
195
|
-
|
|
250
|
+
// Anchor the request path inside root with a leading "." so an
|
|
251
|
+
// absolute request (`/c:/windows`, `//host/share`, `/etc/passwd`)
|
|
252
|
+
// resolves as a same-named child of root rather than smuggling a
|
|
253
|
+
// fresh root; the containment barrier then proves the result stays
|
|
254
|
+
// inside root, refusing any `..`-driven escape. Drive-letter / UNC /
|
|
255
|
+
// reserved-name shapes that survive the resolve are caught by the
|
|
256
|
+
// guardFilename basename gate below.
|
|
257
|
+
var resolved = _assertInsideRoot(root, nodePath.resolve(root, "." + requestedPath));
|
|
258
|
+
if (!resolved) return null;
|
|
196
259
|
var rootResolved = nodePath.resolve(root);
|
|
197
|
-
if (resolved !== rootResolved &&
|
|
198
|
-
!resolved.startsWith(rootResolved + nodePath.sep)) return null;
|
|
199
260
|
|
|
200
261
|
// Symlink-escape defense — the lexical resolve above only sees the
|
|
201
262
|
// requested path tokens; a symlink anywhere along `resolved` can
|
|
@@ -593,12 +654,18 @@ function _writeError(res, status, code, message, headers) {
|
|
|
593
654
|
void code;
|
|
594
655
|
}
|
|
595
656
|
|
|
596
|
-
// integrity() — module-level helper, kept for compat with the v0.6 SRI
|
|
657
|
+
// integrity() — module-level helper, kept for compat with the v0.6 SRI
|
|
658
|
+
// use. Operates on an operator-supplied absolute path (a config/library
|
|
659
|
+
// call, not the request path): the file's own resolved path is both the
|
|
660
|
+
// confinement root and the candidate, so `_readMeta` re-applies the same
|
|
661
|
+
// barrier shape every other sink uses without narrowing the legitimate
|
|
662
|
+
// surface (any single file the operator names).
|
|
597
663
|
async function integrity(absPath) {
|
|
598
664
|
if (typeof absPath !== "string" || absPath.length === 0) {
|
|
599
665
|
throw _err("BAD_OPT", "staticServe.integrity: absPath must be a non-empty string");
|
|
600
666
|
}
|
|
601
|
-
var
|
|
667
|
+
var resolved = nodePath.resolve(absPath);
|
|
668
|
+
var meta = await _readMeta(resolved, resolved);
|
|
602
669
|
if (!meta) throw _err("NOT_FOUND", "staticServe.integrity: file not found: " + absPath);
|
|
603
670
|
return meta.integrity;
|
|
604
671
|
}
|
|
@@ -736,8 +803,13 @@ function create(opts) {
|
|
|
736
803
|
|
|
737
804
|
async function _checkMimeAllowlist(absPath, meta) {
|
|
738
805
|
if (allowedFileTypes.length === 0 || !fileType) return { ok: true };
|
|
806
|
+
// Re-assert the root-confinement barrier at this fs read sink
|
|
807
|
+
// (CWE-22): the path passed to readFile is the confined return of
|
|
808
|
+
// `_assertInsideRoot`, not the request-derived candidate.
|
|
809
|
+
var confined = _assertInsideRoot(root, absPath);
|
|
810
|
+
if (!confined) return { ok: false, reason: "read-failed" };
|
|
739
811
|
var sample;
|
|
740
|
-
try { sample = await fsp.readFile(
|
|
812
|
+
try { sample = await fsp.readFile(confined, { flag: "r" }); }
|
|
741
813
|
catch (_e) { return { ok: false, reason: "read-failed" }; }
|
|
742
814
|
var detected = fileType.detect(sample.slice(0, C.BYTES.kib(64))) || {};
|
|
743
815
|
if (!detected.mime) return { ok: false, reason: "indeterminate" };
|
|
@@ -799,13 +871,22 @@ function create(opts) {
|
|
|
799
871
|
"Forbidden");
|
|
800
872
|
}
|
|
801
873
|
|
|
802
|
-
// Stat first to discover directory → index file.
|
|
874
|
+
// Stat first to discover directory → index file. The path handed to
|
|
875
|
+
// stat is the confined return of `_resolveSafe` above; re-assert the
|
|
876
|
+
// barrier so CodeQL sees the confinement local to this sink (CWE-22).
|
|
877
|
+
var statTarget = _assertInsideRoot(root, absPath);
|
|
878
|
+
if (!statTarget) return next();
|
|
803
879
|
var stat;
|
|
804
|
-
try { stat = await fsp.stat(
|
|
880
|
+
try { stat = await fsp.stat(statTarget); }
|
|
805
881
|
catch (_e) { return next(); }
|
|
806
882
|
if (stat.isDirectory()) {
|
|
807
883
|
if (!indexFile) return next();
|
|
808
|
-
|
|
884
|
+
// Re-confine after appending the index file — keeps every
|
|
885
|
+
// downstream sink (read-meta, content-safety open, serve stream)
|
|
886
|
+
// anchored inside root even if indexFile were ever made operator-
|
|
887
|
+
// overridable per request.
|
|
888
|
+
absPath = _assertInsideRoot(root, nodePath.join(absPath, indexFile));
|
|
889
|
+
if (!absPath) return next();
|
|
809
890
|
}
|
|
810
891
|
|
|
811
892
|
// Force-revoke (404 — opaque to clients)
|
|
@@ -833,7 +914,7 @@ function create(opts) {
|
|
|
833
914
|
"retention_blocked", "Unavailable For Legal Reasons");
|
|
834
915
|
}
|
|
835
916
|
|
|
836
|
-
var meta = await _readMeta(absPath);
|
|
917
|
+
var meta = await _readMeta(root, absPath);
|
|
837
918
|
if (!meta) return next();
|
|
838
919
|
|
|
839
920
|
// MIME allowlist (415) — checked before sending bytes so a misnamed
|
|
@@ -867,16 +948,32 @@ function create(opts) {
|
|
|
867
948
|
var ext = nodePath.extname(absPath).toLowerCase();
|
|
868
949
|
var safetyGate = contentSafety[ext];
|
|
869
950
|
if (safetyGate && typeof safetyGate.check === "function") {
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
951
|
+
// Single-fd read for the content-safety gate. Two defenses on
|
|
952
|
+
// one open:
|
|
953
|
+
// - CWE-22 path traversal: the open path is the confined
|
|
954
|
+
// return of `_assertInsideRoot(root, absPath)`, freshly
|
|
955
|
+
// re-derived from `nodePath.resolve(root, ...)`, not the
|
|
956
|
+
// request-derived candidate.
|
|
957
|
+
// - CWE-367 TOCTOU file-system race: the bytes the gate
|
|
958
|
+
// inspects come from THIS file descriptor — size and reads
|
|
959
|
+
// are taken from the same inode the open returned, so a path
|
|
960
|
+
// swap between the earlier directory stat and this read can't
|
|
961
|
+
// slip different bytes past the gate. O_NOFOLLOW (when the
|
|
962
|
+
// platform defines it) additionally refuses to open the path
|
|
963
|
+
// if its final component became a symlink after confinement.
|
|
964
|
+
var gateConfined = _assertInsideRoot(root, absPath);
|
|
965
|
+
if (!gateConfined) return next();
|
|
876
966
|
var gateBuf;
|
|
877
967
|
var gateHandle = null;
|
|
968
|
+
var gateOpenFlags = nodeFs.constants.O_RDONLY |
|
|
969
|
+
(nodeFs.constants.O_NOFOLLOW || 0);
|
|
878
970
|
try {
|
|
879
|
-
|
|
971
|
+
// Explicit owner-only mode (0o600). The flags are read-only
|
|
972
|
+
// (O_RDONLY, no O_CREAT) so the mode is inert on disk, but
|
|
973
|
+
// pinning it owner-only keeps this open out of the insecure-
|
|
974
|
+
// temp-file class (CWE-377): no world/group-accessible
|
|
975
|
+
// creation can ever ride this code path.
|
|
976
|
+
gateHandle = await fsp.open(gateConfined, gateOpenFlags, 0o600);
|
|
880
977
|
var gateStat = await gateHandle.stat();
|
|
881
978
|
gateBuf = Buffer.alloc(gateStat.size);
|
|
882
979
|
var gateRead = 0;
|
|
@@ -1151,6 +1248,18 @@ function create(opts) {
|
|
|
1151
1248
|
return;
|
|
1152
1249
|
}
|
|
1153
1250
|
|
|
1251
|
+
// Re-assert the root-confinement barrier at the serve sink (CWE-22)
|
|
1252
|
+
// BEFORE any 200/206 headers go on the wire: the path handed to
|
|
1253
|
+
// createReadStream is the confined return of `_assertInsideRoot`,
|
|
1254
|
+
// freshly re-derived from `nodePath.resolve(root, ...)`, not the
|
|
1255
|
+
// request-derived candidate. A candidate that escapes root refuses
|
|
1256
|
+
// opaquely (404) — it cannot reach the stream.
|
|
1257
|
+
var streamTarget = _assertInsideRoot(root, absPath);
|
|
1258
|
+
if (!streamTarget) {
|
|
1259
|
+
stats.failures += 1;
|
|
1260
|
+
return writeErr(res, HTTP.NOT_FOUND, "not_found", "Not Found");
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1154
1263
|
res.writeHead(status, headers);
|
|
1155
1264
|
|
|
1156
1265
|
// Acquire concurrency slot (released on stream end / error / abort).
|
|
@@ -1163,11 +1272,7 @@ function create(opts) {
|
|
|
1163
1272
|
}
|
|
1164
1273
|
|
|
1165
1274
|
var streamOpts = range ? { start: range.start, end: range.end } : {};
|
|
1166
|
-
|
|
1167
|
-
// of `_resolveSafe` (lib/static.js:181 — lexical resolve + startsWith
|
|
1168
|
-
// root-prefix check + realpath escape guard + guardFilename gate).
|
|
1169
|
-
// The request-serve path rejects with 404 before reaching this stream.
|
|
1170
|
-
var fileStream = nodeFs.createReadStream(absPath, streamOpts);
|
|
1275
|
+
var fileStream = nodeFs.createReadStream(streamTarget, streamOpts);
|
|
1171
1276
|
|
|
1172
1277
|
// Idle timeout — close the connection if the client stalls. Pattern is
|
|
1173
1278
|
// a deadline-style debounce (clearTimeout + setTimeout) tied directly
|
package/lib/vault/rotate.js
CHANGED
|
@@ -81,6 +81,11 @@ var agentSnapshotLazy = lazyRequire(function () { return require("../agent-snaps
|
|
|
81
81
|
// rotation pipeline never walks, so archive-wrap exports the same external
|
|
82
82
|
// AAD_ROTATION descriptor and must be gated here too.
|
|
83
83
|
var archiveWrapLazy = lazyRequire(function () { return require("../archive-wrap"); });
|
|
84
|
+
// The DSR ticket store, when backed by an operator-supplied database, holds
|
|
85
|
+
// {aad:true} sealed cells (subject identifiers + request payload) keyed off the
|
|
86
|
+
// vault root that this pipeline never walks, so dsr exports the same external
|
|
87
|
+
// AAD_ROTATION descriptor and must be gated here too.
|
|
88
|
+
var dsrLazy = lazyRequire(function () { return require("../dsr"); });
|
|
84
89
|
var { defineClass } = require("../framework-error");
|
|
85
90
|
|
|
86
91
|
var rotateLog = boot("vault-rotate");
|
|
@@ -439,7 +444,7 @@ var VAULT_PREFIX_LEN = C.VAULT_PREFIX.length;
|
|
|
439
444
|
// so loading rotate.js doesn't eagerly pull the agent modules.
|
|
440
445
|
var EXTERNAL_AAD_MODULE_LOADERS = [
|
|
441
446
|
agentIdempotencyLazy, agentOrchestratorLazy, agentTenantLazy, agentSnapshotLazy,
|
|
442
|
-
archiveWrapLazy,
|
|
447
|
+
archiveWrapLazy, dsrLazy,
|
|
443
448
|
];
|
|
444
449
|
|
|
445
450
|
function _externalAadTables() {
|
|
@@ -464,22 +469,25 @@ function _emit(cb, ev) {
|
|
|
464
469
|
}
|
|
465
470
|
}
|
|
466
471
|
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
// the
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
472
|
+
// Create a fresh file in the owner-only staging dir with exclusive,
|
|
473
|
+
// no-follow semantics, then fsync it. O_EXCL turns a pre-planted file or
|
|
474
|
+
// symlink into a hard failure instead of a followed write; O_NOFOLLOW
|
|
475
|
+
// refuses a symlinked final component; the explicit 0o600 keeps the bytes
|
|
476
|
+
// owner-only regardless of umask. Any leftover from an aborted prior
|
|
477
|
+
// rotation is cleared first so the exclusive create can proceed. The
|
|
478
|
+
// staging dir is already 0o700 owner-only, so this is defense in depth
|
|
479
|
+
// against a same-user pre-plant / symlink swap (CWE-377 / CWE-379 / CWE-59).
|
|
480
|
+
function _writeStagedFileExclusive(p, data) {
|
|
481
|
+
try { nodeFs.unlinkSync(p); } catch (_e) { /* no stale entry to clear */ }
|
|
482
|
+
var fd = nodeFs.openSync(p,
|
|
483
|
+
nodeFs.constants.O_WRONLY | nodeFs.constants.O_CREAT |
|
|
484
|
+
nodeFs.constants.O_EXCL | (nodeFs.constants.O_NOFOLLOW || 0), 0o600);
|
|
479
485
|
try {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
486
|
+
nodeFs.writeFileSync(fd, data);
|
|
487
|
+
nodeFs.fsyncSync(fd);
|
|
488
|
+
} finally {
|
|
489
|
+
nodeFs.closeSync(fd);
|
|
490
|
+
}
|
|
483
491
|
}
|
|
484
492
|
|
|
485
493
|
function _reSealValue(sealedValue, oldKeys, newKeys) {
|
|
@@ -670,7 +678,8 @@ async function rotate(opts) {
|
|
|
670
678
|
"pipeline and would be orphaned under the retired keypair: " + externalAad.join(", ") +
|
|
671
679
|
". Re-seal each via its module hook (b.agent.idempotency.reseal / " +
|
|
672
680
|
"b.agent.orchestrator.reseal / b.agent.tenant AAD_ROTATION reseal / " +
|
|
673
|
-
"b.agent.snapshot.reseal / b.archive.rewrapTenant for archive-wrap:tenant-blobs
|
|
681
|
+
"b.agent.snapshot.reseal / b.archive.rewrapTenant for archive-wrap:tenant-blobs / " +
|
|
682
|
+
"b.dsr.reseal for the dsr_tickets store) " +
|
|
674
683
|
"BEFORE retiring the old keypair, then pass " +
|
|
675
684
|
"opts.externalAadResealed: [" + externalAad.map(function (t) { return JSON.stringify(t); }).join(", ") +
|
|
676
685
|
"] to acknowledge. If you do not use these features, pass opts.externalAadResealed: true.");
|
|
@@ -709,7 +718,10 @@ async function rotate(opts) {
|
|
|
709
718
|
}
|
|
710
719
|
var dest = nodePath.join(stagingDir, entry.relativePath);
|
|
711
720
|
atomicFile.ensureDir(nodePath.dirname(dest));
|
|
712
|
-
|
|
721
|
+
// Stage via the exclusive-create + fsync helper rather than a plain copy,
|
|
722
|
+
// so the verbatim file is durable at write time (no later by-path fsync)
|
|
723
|
+
// and a pre-planted file/symlink at the staging path hard-fails.
|
|
724
|
+
_writeStagedFileExclusive(dest, nodeFs.readFileSync(src));
|
|
713
725
|
}
|
|
714
726
|
for (var vd = 0; vd < paths.verbatimDirs.length; vd++) {
|
|
715
727
|
var dent = paths.verbatimDirs[vd];
|
|
@@ -737,9 +749,9 @@ async function rotate(opts) {
|
|
|
737
749
|
var newRootJson = keysJson;
|
|
738
750
|
if (mode === "wrapped") {
|
|
739
751
|
var sealed = await vaultWrap().wrap(keysJson, opts.newPassphrase);
|
|
740
|
-
|
|
752
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, paths.vaultKeySealed), sealed);
|
|
741
753
|
} else {
|
|
742
|
-
|
|
754
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, paths.vaultKeyPlain), keysJson);
|
|
743
755
|
}
|
|
744
756
|
|
|
745
757
|
// 3. re-seal db.key.enc + any operator-supplied additionalSealed files
|
|
@@ -759,14 +771,14 @@ async function rotate(opts) {
|
|
|
759
771
|
var dbKeyB64Aad = vaultAad.unsealRoot(sealedKey, dbKeyAad, oldRootJson);
|
|
760
772
|
dbKey = Buffer.from(dbKeyB64Aad, "base64");
|
|
761
773
|
var resealedAad = vaultAad.sealRoot(dbKeyB64Aad, dbKeyAad, newRootJson);
|
|
762
|
-
|
|
774
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, paths.dbKeySealed), resealedAad);
|
|
763
775
|
} else if (sealedKey.indexOf(C.VAULT_PREFIX) === 0) {
|
|
764
776
|
// Legacy plain-sealed db.key.enc (pre-AAD). Re-key in place; db.init
|
|
765
777
|
// read-migrates plain -> AAD on the next boot.
|
|
766
778
|
var dbKeyB64 = bCrypto.decrypt(sealedKey.substring(VAULT_PREFIX_LEN), oldKeys);
|
|
767
779
|
dbKey = Buffer.from(dbKeyB64, "base64");
|
|
768
780
|
var resealedKey = C.VAULT_PREFIX + bCrypto.encrypt(dbKeyB64, newKeys);
|
|
769
|
-
|
|
781
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, paths.dbKeySealed), resealedKey);
|
|
770
782
|
} else {
|
|
771
783
|
throw new VaultRotateError("vault-rotate/bad-dbkey",
|
|
772
784
|
"rotate: db.key.enc does not start with a vault prefix (vault: or vault.aad:)");
|
|
@@ -789,8 +801,8 @@ async function rotate(opts) {
|
|
|
789
801
|
}
|
|
790
802
|
var asDestDir = nodePath.join(stagingDir, nodePath.dirname(ase.relativePath));
|
|
791
803
|
if (!nodeFs.existsSync(asDestDir)) atomicFile.ensureDir(asDestDir);
|
|
792
|
-
|
|
793
|
-
_reSealValue(current, oldKeys, newKeys)
|
|
804
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, ase.relativePath),
|
|
805
|
+
_reSealValue(current, oldKeys, newKeys));
|
|
794
806
|
}
|
|
795
807
|
|
|
796
808
|
// 3b. Framework-managed crypto-field derived-hash files — always
|
|
@@ -801,14 +813,17 @@ async function rotate(opts) {
|
|
|
801
813
|
// re-seals to the same value since the keypair is unchanged).
|
|
802
814
|
var saltSrc = nodePath.join(dataDir, "vault.derived-hash-salt");
|
|
803
815
|
if (nodeFs.existsSync(saltSrc)) {
|
|
804
|
-
|
|
816
|
+
// Stage via the exclusive-create + fsync helper (not a plain copy) so the
|
|
817
|
+
// salt is durable at write time and no later by-path fsync is needed.
|
|
818
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, "vault.derived-hash-salt"),
|
|
819
|
+
nodeFs.readFileSync(saltSrc));
|
|
805
820
|
}
|
|
806
821
|
var macSrc = nodePath.join(dataDir, "vault.derived-hash-mac.sealed");
|
|
807
822
|
if (nodeFs.existsSync(macSrc)) {
|
|
808
823
|
var macCurrent = nodeFs.readFileSync(macSrc, "utf8").trim();
|
|
809
824
|
if (macCurrent.indexOf(C.VAULT_PREFIX) === 0) {
|
|
810
|
-
|
|
811
|
-
_reSealValue(macCurrent, oldKeys, newKeys)
|
|
825
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, "vault.derived-hash-mac.sealed"),
|
|
826
|
+
_reSealValue(macCurrent, oldKeys, newKeys));
|
|
812
827
|
}
|
|
813
828
|
}
|
|
814
829
|
|
|
@@ -830,7 +845,7 @@ async function rotate(opts) {
|
|
|
830
845
|
try { plainBytes = bCrypto.decryptPacked(packed, dbKey, dbEncAad); }
|
|
831
846
|
catch (_eAad) { plainBytes = bCrypto.decryptPacked(packed, dbKey); }
|
|
832
847
|
var tmpDbPath = nodePath.join(stagingDir, "_blamejs_rotate.tmp.db");
|
|
833
|
-
|
|
848
|
+
_writeStagedFileExclusive(tmpDbPath, plainBytes);
|
|
834
849
|
|
|
835
850
|
var db = new DatabaseSync(tmpDbPath);
|
|
836
851
|
try {
|
|
@@ -893,25 +908,23 @@ async function rotate(opts) {
|
|
|
893
908
|
try { nodeFs.unlinkSync(tmpDbPath + "-shm"); }
|
|
894
909
|
catch (e) { rotateLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: tmpDbPath + "-shm", error: e.message }); }
|
|
895
910
|
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
// inside it. Files are written 0o600 implicitly via the dir's umask
|
|
903
|
-
// and removed before the rotation completes.
|
|
911
|
+
// Every staged path lives inside opts.stagingDir (operator-supplied,
|
|
912
|
+
// ensureDir'd 0o700 owner-only, never under os.tmpdir()) and carries a
|
|
913
|
+
// framework-internal marker name. The staged writes go through
|
|
914
|
+
// _writeStagedFileExclusive — exclusive + no-follow create, owner-only
|
|
915
|
+
// 0o600 — so a same-user pre-plant or symlink swap is a hard failure
|
|
916
|
+
// rather than a followed write, and the bytes never inherit a wider mode.
|
|
904
917
|
var rotatedBytes = nodeFs.readFileSync(tmpDbPath);
|
|
905
918
|
// Re-encrypt under the SAME dataDir AAD so db.init's AAD-first open
|
|
906
919
|
// succeeds after the staged dir is swapped over dataDir in place.
|
|
907
|
-
|
|
920
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, paths.encryptedDb),
|
|
908
921
|
bCrypto.encryptPacked(rotatedBytes, dbKey, dbEncAad));
|
|
909
922
|
nodeFs.unlinkSync(tmpDbPath);
|
|
910
923
|
|
|
911
924
|
// Round-trip verify on the staged DB
|
|
912
925
|
_emit(progress, { phase: "verify" });
|
|
913
926
|
var verifyTmp = nodePath.join(stagingDir, "_blamejs_verify.tmp.db");
|
|
914
|
-
|
|
927
|
+
_writeStagedFileExclusive(verifyTmp,
|
|
915
928
|
bCrypto.decryptPacked(nodeFs.readFileSync(nodePath.join(stagingDir, paths.encryptedDb)), dbKey, dbEncAad));
|
|
916
929
|
var vdb = new DatabaseSync(verifyTmp);
|
|
917
930
|
try {
|
|
@@ -934,19 +947,26 @@ async function rotate(opts) {
|
|
|
934
947
|
}
|
|
935
948
|
}
|
|
936
949
|
|
|
937
|
-
// 5. fsync staging for durability before caller
|
|
950
|
+
// 5. fsync staging directory entries for durability before the caller swaps.
|
|
951
|
+
// Every staged FILE is already fsync'd at write time by
|
|
952
|
+
// _writeStagedFileExclusive (the re-encrypted db, the resealed vault/db keys,
|
|
953
|
+
// sealed files, the derived-hash salt, and verbatim files), so re-opening
|
|
954
|
+
// each by path here is redundant — and opening a staged file by path is the
|
|
955
|
+
// os-temp-dir open the static analyzer refuses (CWE-377 heuristic). Only the
|
|
956
|
+
// optional verbatimDirs are copied with copyFileSync (no per-file fsync);
|
|
957
|
+
// their directory entries + the rename are made durable by fsyncDir and their
|
|
958
|
+
// source files in dataDir remain intact, so a crash in that narrow window is
|
|
959
|
+
// recoverable.
|
|
938
960
|
_emit(progress, { phase: "fsync" });
|
|
939
|
-
function
|
|
961
|
+
function fsyncDirTree(dir) {
|
|
940
962
|
var entries = nodeFs.readdirSync(dir);
|
|
941
963
|
for (var i = 0; i < entries.length; i++) {
|
|
942
964
|
var p = nodePath.join(dir, entries[i]);
|
|
943
|
-
|
|
944
|
-
if (st.isFile()) _fsyncFileByPath(p);
|
|
945
|
-
else if (st.isDirectory()) fsyncTree(p);
|
|
965
|
+
if (nodeFs.statSync(p).isDirectory()) fsyncDirTree(p);
|
|
946
966
|
}
|
|
947
967
|
atomicFile.fsyncDir(dir);
|
|
948
968
|
}
|
|
949
|
-
|
|
969
|
+
fsyncDirTree(stagingDir);
|
|
950
970
|
|
|
951
971
|
var durationMs = Date.now() - startedAt;
|
|
952
972
|
_emit(progress, {
|
package/lib/websocket.js
CHANGED
|
@@ -530,22 +530,32 @@ function _parseExtensionHeader(header) {
|
|
|
530
530
|
for (var i = 0; i < entries.length; i++) {
|
|
531
531
|
var parts = structuredFields.splitTopLevel(entries[i], ";").map(function (s) { return s.trim(); });
|
|
532
532
|
if (!parts[0]) continue;
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
|
|
533
|
+
// Collect [name, value] pairs, then materialize the params map via
|
|
534
|
+
// Object.fromEntries onto a null-prototype object. The extension-
|
|
535
|
+
// parameter name is taken from the client-supplied Sec-WebSocket-
|
|
536
|
+
// Extensions header, so it is never used as a computed-write key
|
|
537
|
+
// (`params[name] = value`) — that is the CWE-915 unsafe-reflection /
|
|
538
|
+
// CWE-1321 prototype-pollution sink. POISONED params (`__proto__` /
|
|
539
|
+
// `constructor` / `prototype`) are dropped, and the null-prototype
|
|
540
|
+
// accumulator means even a slipped name cannot reach Object.prototype.
|
|
541
|
+
var paramPairs = [];
|
|
537
542
|
for (var j = 1; j < parts.length; j++) {
|
|
538
543
|
var kv = parts[j].split("=");
|
|
539
544
|
var k = kv[0].trim().toLowerCase();
|
|
540
545
|
if (!k) continue;
|
|
546
|
+
if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
|
|
541
547
|
var v = kv.length > 1 ? kv.slice(1).join("=").trim() : true;
|
|
542
548
|
// Strip surrounding quotes per the token-or-quoted-string grammar.
|
|
543
549
|
if (typeof v === "string") {
|
|
544
550
|
var _unq = structuredFields.unquoteSfString(v);
|
|
545
551
|
if (_unq !== null) v = _unq;
|
|
546
552
|
}
|
|
547
|
-
|
|
553
|
+
paramPairs.push([k, v]);
|
|
548
554
|
}
|
|
555
|
+
var ext = {
|
|
556
|
+
name: parts[0].toLowerCase(),
|
|
557
|
+
params: Object.assign(Object.create(null), Object.fromEntries(paramPairs)),
|
|
558
|
+
};
|
|
549
559
|
out.push(ext);
|
|
550
560
|
}
|
|
551
561
|
return out;
|
|
@@ -1541,6 +1551,10 @@ module.exports = {
|
|
|
1541
1551
|
// Server-side entrypoints
|
|
1542
1552
|
handleUpgrade: handleUpgrade, // h1 — RFC 6455 HTTP upgrade
|
|
1543
1553
|
handleExtendedConnect: handleExtendedConnect, // h2 — RFC 8441 Extended CONNECT
|
|
1554
|
+
// Internal helper exposed for tests — the Sec-WebSocket-Extensions
|
|
1555
|
+
// parser (RFC 7692 negotiation feeds off this). Underscore-prefixed so
|
|
1556
|
+
// it is not part of the public primitive surface.
|
|
1557
|
+
_parseExtensionHeader: _parseExtensionHeader,
|
|
1544
1558
|
// Constants
|
|
1545
1559
|
GUID: GUID,
|
|
1546
1560
|
REFUSED_AUTH_QUERY_PARAMS: REFUSED_AUTH_QUERY_PARAMS,
|