@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/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 (string-equality, exact match) ----
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
- if (CLOUD_METADATA_IPS.indexOf(ip) !== -1) return "cloud-metadata";
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
- if (net.isIP(host) && CLOUD_METADATA_IPS.indexOf(host) !== -1) {
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
- async function _readMeta(absPath) {
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
- // lgtm[js/path-injection] `absPath` is the sandbox-validated return
168
- // of `_resolveSafe` (lib/static.js:181 — lexical resolve + startsWith
169
- // root-prefix check + realpath escape guard + guardFilename gate).
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
- var resolved = nodePath.resolve(root, "." + requestedPath);
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 use.
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 meta = await _readMeta(nodePath.resolve(absPath));
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(absPath, { flag: "r" }); }
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(absPath); }
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
- absPath = nodePath.join(absPath, indexFile);
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
- // CodeQL js/file-system-race defense single fd anchored to the
871
- // inode for the bytes we hand to the content-safety gate. The
872
- // absPath was anchored under root by _resolveSafe above; the
873
- // filehandle pattern binds size + read to the same inode so a
874
- // swap between stat (line 771) and read can't slip different
875
- // bytes past the gate.
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
- gateHandle = await fsp.open(absPath, "r");
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
- // lgtm[js/path-injection] `absPath` is the sandbox-validated return
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
@@ -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
- // Open a file for fsync. Different from atomicFile.fsync (which takes
468
- // an already-open fd) vault-rotate's fsync-by-path semantic opens
469
- // then syncs then closes, which is the right shape when we don't have
470
- // the original write fd around.
471
- //
472
- // CodeQL js/insecure-temporary-file: `p` is an operator-supplied path
473
- // inside opts.stagingDir (an owner-only 0o700 framework directory
474
- // established via atomicFile.ensureDir at the top of rotate()). Not an
475
- // os.tmpdir-reachable path. The fd is used solely for fsync and is
476
- // closed immediately; no bytes are read or written through it, so the
477
- // tmp-file predictability heuristic does not apply.
478
- function _fsyncFileByPath(p) {
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
- var fd = nodeFs.openSync(p, "r+");
481
- try { nodeFs.fsyncSync(fd); } finally { nodeFs.closeSync(fd); }
482
- } catch (_e) { /* best-effort across platforms */ }
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
- nodeFs.copyFileSync(src, dest);
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
- nodeFs.writeFileSync(nodePath.join(stagingDir, paths.vaultKeySealed), sealed, { mode: 0o600 });
752
+ _writeStagedFileExclusive(nodePath.join(stagingDir, paths.vaultKeySealed), sealed);
741
753
  } else {
742
- nodeFs.writeFileSync(nodePath.join(stagingDir, paths.vaultKeyPlain), keysJson, { mode: 0o600 });
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
- nodeFs.writeFileSync(nodePath.join(stagingDir, paths.dbKeySealed), resealedAad, { mode: 0o600 });
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
- nodeFs.writeFileSync(nodePath.join(stagingDir, paths.dbKeySealed), resealedKey, { mode: 0o600 });
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
- nodeFs.writeFileSync(nodePath.join(stagingDir, ase.relativePath),
793
- _reSealValue(current, oldKeys, newKeys), { mode: 0o600 });
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
- nodeFs.copyFileSync(saltSrc, nodePath.join(stagingDir, "vault.derived-hash-salt"));
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
- nodeFs.writeFileSync(nodePath.join(stagingDir, "vault.derived-hash-mac.sealed"),
811
- _reSealValue(macCurrent, oldKeys, newKeys), { mode: 0o600 });
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
- nodeFs.writeFileSync(tmpDbPath, plainBytes, { mode: 0o600 });
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
- // CodeQL js/insecure-temporary-file: every "tmp" path here is inside
897
- // opts.stagingDir — operator-supplied, ensureDir'd 0o700 owner-only,
898
- // never under os.tmpdir(). The filenames are framework-internal
899
- // markers (`_blamejs_rotate.tmp.db`, `_blamejs_verify.tmp.db`); their
900
- // predictability does not enable a symlink attack because the staging
901
- // dir's owner-only perms prevent any other user from creating entries
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
- nodeFs.writeFileSync(nodePath.join(stagingDir, paths.encryptedDb),
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
- nodeFs.writeFileSync(verifyTmp,
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 does the swap
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 fsyncTree(dir) {
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
- var st = nodeFs.statSync(p);
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
- fsyncTree(stagingDir);
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
- // `params` has no prototype chain `Object.create(null)` defends
534
- // against `__proto__` / `constructor` / `prototype` parameter names
535
- // in the Sec-WebSocket-Extensions header polluting downstream lookups.
536
- var ext = { name: parts[0].toLowerCase(), params: Object.create(null) };
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
- ext.params[k] = v;
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.14.25",
3
+ "version": "0.14.27",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",