@blamejs/core 0.15.43 → 0.15.45

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 CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.45 (2026-06-28) — **`b.selfUpdate.swap` now re-hashes the asset against the hash `verify` returned and refuses to install on a mismatch, closing the window where an asset swapped between verify and swap could be installed after the signature check passed.** selfUpdate.verify (signature-checks the asset and returns its hash) and selfUpdate.swap (renames the new asset into place) were two separate path-keyed operations with nothing binding the bytes swap installs to the bytes verify checked. An attacker able to write the asset path in the window between the two calls — or who pointed verify at a different inode via a symlink — could have the signature-verified bytes replaced before swap renamed the file into place, installing unverified code. swap now requires an expectedHash and re-hashes the from bytes against it immediately before the install, refusing with selfupdate/swap-hash-mismatch on any difference; the re-check runs right before the rename so the residual is a sub-millisecond window rather than the operator-controlled gap between verify and swap. Because swap now requires expectedHash, callers must pass the hash that verify returned (await verify(...) then swap({ ..., expectedHash: result.hash })). **Security:** *self-update install is bound to the signature-verified bytes (verify→swap TOCTOU closed)* — selfUpdate.swap renamed the new asset into place with no integrity re-check, so the bytes it installed were not bound to the bytes selfUpdate.verify had signature-checked: an attacker who could write the asset path between verify and swap (or repoint a symlink) could install unverified code. swap now re-hashes the from bytes against a required expectedHash (the hash verify returns) immediately before the install and refuses a mismatch (selfupdate/swap-hash-mismatch), with the check positioned right before the rename to minimize the residual window. A symlinked or differing-inode asset is caught the same way, because the install source is what gets re-hashed. **Migration:** *selfUpdate.swap requires expectedHash* — swap now requires an expectedHash option (refused at the call with selfupdate/bad-expected-hash if absent). Pass the hash that selfUpdate.verify returns: const v = await selfUpdate.verify({ assetPath, signaturePath, pubkeyPem }); await selfUpdate.swap({ from, to, backupTo, expectedHash: v.hash }). An optional hashAlgo (default sha3-512, matching verify's default) selects the digest when verify used a non-default algorithm.
12
+
13
+ - v0.15.44 (2026-06-28) — **`b.fileUpload` now rejects the bare path tokens "." and ".." as upload IDs, closing a path traversal where a ".." id resolved to the staging directory's parent and a cancel or cleanup could recursively delete it.** b.fileUpload validated the uploadId with a character-class regex that permits the dot character, so the bare path tokens "." and ".." passed validation even though they are not ordinary identifiers. The uploadId is joined under the configured staging directory to locate an upload's files, so "." resolved to the staging directory itself and ".." to its parent: an operation keyed on a ".." uploadId acted OUTSIDE the staging directory. The most damaging path is cancelUpload (and finalize/expiry cleanup), which removes the upload directory with a recursive rmSync — keyed on ".." that would recursively delete the staging directory's parent; chunk writes for a ".." id also land outside staging. The validator now rejects "." and ".." before any filesystem operation. Every public method validates the uploadId there, so init, acceptChunk, finalize, status, and cancelUpload are all covered, and legitimate dotted IDs (for example "build.v2") are unaffected. **Security:** *Upload IDs of "." and ".." are refused (path-traversal / parent-directory deletion)* — fileUpload's uploadId regex allows the dot character, so the bare tokens "." and ".." passed validation and, joined under the staging directory, resolved to the staging directory or its parent. An operation keyed on a ".." id acted outside staging — most seriously cancelUpload / cleanup, whose recursive rmSync would have deleted the staging directory's parent, and chunk writes that would land outside staging. The validator now rejects "." and ".." up front; because every public method validates the id, init / acceptChunk / finalize / status / cancelUpload are all covered. Legitimate dotted IDs are unaffected. Operators who never pass caller-controlled upload IDs were not exposed.
14
+
11
15
  - v0.15.43 (2026-06-28) — **`b.auth.lockout`'s failed-attempt counter now accumulates with an atomic compare-and-set, so a brute-force attacker spreading failed logins across multiple nodes can no longer lose increments and stay under the lockout threshold.** b.auth.lockout tracked failed attempts with a cache read-modify-write: read the counter, increment it, write it back. On a multi-node deployment two concurrent failures for the same account on different nodes both read the same value, each added one, and one write clobbered the other — a lost update that let an attacker spreading attempts across nodes exceed maxAttempts without ever triggering the lockout, weakening brute-force protection on a cluster. The counter now runs the whole decision (window decay, increment, lockout-ladder) through the cache's atomic update() (compare-and-set, retried on the cluster backend under contention), so every failure is counted regardless of which node records it. The lockout's documented fail-open posture is preserved — a genuinely unreachable cache still allows the attempt and signals the error — but a cache backend that cannot perform an atomic update now surfaces loud at first use instead of silently disabling the lockout. **Security:** *Lockout failure counter is atomic across nodes (no lost increments)* — b.auth.lockout accumulated failed attempts with a non-atomic cache get -> increment -> set, so concurrent failures for one account across a multi-node deployment lost increments and an attacker could exceed maxAttempts without engaging the lockout. The counter now uses the cache's atomic compare-and-set update(), counting every failure across nodes, with the existing exponential lockout ladder and window-decay logic unchanged. The cache backing a lockout must support atomic update() (b.cache does; the create() check now requires it), and a backend that can't commit an atomic update surfaces loud at first use rather than silently disabling brute-force protection. A genuinely unreachable cache still fails open by design.
12
16
 
13
17
  - v0.15.42 (2026-06-28) — **The agent orchestrator and tenant registries serialize registration per name, so two concurrent register() calls for the same name can no longer both create a row (duplicate-create / lost registration), and a new b.safeAsync.keyedSerializer exposes that per-key serialization.** b.agent.orchestrator and b.agent.tenant registered a name with a check-then-create: read the backend row for the name, throw a duplicate error if it exists, otherwise write the new row. Because the read and the write are separated by an await, two concurrent register() calls for the same name both observed absence and both wrote — a duplicate-create where the second silently clobbered the first and both callers saw success, violating the one-row-per-name invariant. register() (and unregister()) now run through a per-name in-process serializer, so concurrent calls for the same name apply one at a time and the second is correctly refused as a duplicate; distinct names still run concurrently. The serializer is exposed as b.safeAsync.keyedSerializer() for any read-modify-write or check-then-create that must not interleave per key. It is in-process only: a registry backend shared across processes still needs its own atomic create or unique constraint to refuse a cross-process duplicate. **Added:** *b.safeAsync.keyedSerializer — serialize async work per key* — b.safeAsync.keyedSerializer() returns a { run(key, fn) } that queues fn behind any in-flight or queued work for the same key and runs it once they settle, so a read-modify-write or a check-then-create on a shared store cannot interleave with another call for the same key in the same process. Distinct keys run concurrently, and the per-key chain is dropped once it drains. It is the serialization the agent registries now use, and the same primitive backs the lockout and bot-challenge per-key failure counters. **Fixed:** *Agent orchestrator + tenant registries serialize registration per name (no duplicate-create race)* — b.agent.orchestrator.register and b.agent.tenant.register did a check-then-create (await backend.get -> throw-if-exists -> await backend.set) with an await between the read and the write, so two concurrent registrations of the same name both passed the duplicate check and both wrote — the second silently clobbering the first while both callers saw success. Registration now serializes per name in-process, so concurrent calls for one name apply sequentially and the second is refused with the duplicate error; distinct names are unaffected. A backend shared across processes still needs its own uniqueness constraint to refuse a cross-process duplicate. **Detectors:** *Build guard: a registry check-then-create must serialize per key* — A codebase guard now fails the build if a primitive does an async check-then-create on a pluggable backend (await backend.get -> throw a /duplicate error -> await backend.set) without serializing per key, so the duplicate-create race fixed here cannot reappear at a new registry.
@@ -245,9 +245,16 @@ var UPLOAD_ID_RE = /^[A-Za-z0-9._-]+$/;
245
245
  var UPLOAD_ID_MAX_LENGTH = C.BYTES.bytes(128);
246
246
 
247
247
  function _validateUploadId(id) {
248
+ // The char-class allows '.', so the bare path tokens "." and ".." pass the
249
+ // regex; joined under stagingDir they resolve to the staging dir / its PARENT
250
+ // (path.join(stagingDir, "..") escapes), and a later rmSync would recurse into
251
+ // the parent. Reject them before any filesystem op — every public method
252
+ // validates the id here, so this guards init / acceptChunk / finalize /
253
+ // status / cancelUpload alike.
248
254
  if (typeof id !== "string" ||
249
255
  id.length === 0 ||
250
256
  id.length > UPLOAD_ID_MAX_LENGTH ||
257
+ id === "." || id === ".." ||
251
258
  !UPLOAD_ID_RE.test(id)) {
252
259
  var ID_PREVIEW_CHARS = C.BYTES.bytes(64);
253
260
  throw _err("BAD_UPLOAD_ID",
@@ -28,9 +28,11 @@
28
28
  * P-384 from the supplied PEM) and reports the bytes' hash for
29
29
  * SBOM correlation. A mismatched signature throws and the swap
30
30
  * never runs.
31
- * 4. `b.selfUpdate.swap({ from, to, backupTo })` performs the
32
- * atomic install: copy the current `to` to `backupTo`, rename
33
- * `from` `to`, fsync both directories. Cross-device renames
31
+ * 4. `b.selfUpdate.swap({ from, to, backupTo, expectedHash })` performs
32
+ * the atomic install: re-hash `from` and refuse unless it matches
33
+ * `expectedHash` (the hash step 3 returned binding the installed
34
+ * bytes to the verified bytes), copy the current `to` to `backupTo`,
35
+ * rename `from` → `to`, fsync both directories. Cross-device renames
34
36
  * fall back to copy + unlink. Any failure rolls back from the
35
37
  * backup. `b.selfUpdate.rollback({ to, backupTo })` restores
36
38
  * the backup post-swap when a healthcheck reports the new
@@ -614,6 +616,18 @@ function _validateSwapOpts(opts, label) {
614
616
  if (label === "swap") {
615
617
  schema.from = { rule: "required-string", code: "selfupdate/bad-from",
616
618
  label: "selfUpdate.swap: opts.from" };
619
+ // The bytes about to be installed are re-hashed and checked against the
620
+ // hash selfUpdate.verify returned, closing the verify -> swap window. The
621
+ // binding is mandatory — an optional integrity check is opt-in security.
622
+ schema.expectedHash = { rule: "required-string", code: "selfupdate/bad-expected-hash",
623
+ label: "selfUpdate.swap: opts.expectedHash (the hash selfUpdate.verify returned)" };
624
+ schema.hashAlgo = function (value) {
625
+ if (value !== undefined &&
626
+ (typeof value !== "string" || ALLOWED_HASH_ALGS.indexOf(value) === -1)) {
627
+ throw new SelfUpdateError("selfupdate/bad-hash-algo",
628
+ "selfUpdate.swap: opts.hashAlgo must be one of " + ALLOWED_HASH_ALGS.join(", "));
629
+ }
630
+ };
617
631
  }
618
632
  schema.to = { rule: "required-string", code: "selfupdate/bad-to",
619
633
  label: "selfUpdate." + label + ": opts.to" };
@@ -664,24 +678,31 @@ async function _safeRollback(backupTo, to, hadOriginal) {
664
678
  * @since 0.6.0
665
679
  * @related b.selfUpdate.verify, b.selfUpdate.rollback, b.atomicFile.copy
666
680
  *
667
- * Atomic install: copy the existing `to` to `backupTo`, rename `from`
668
- * `to`, then fsync both directories. Cross-device renames fall back
669
- * to copy + unlink on the destination filesystem. On any failure the
670
- * original `to` is restored from `backupTo`. Throws SelfUpdateError on
671
- * a missing `from`, backup-copy failure, cross-device install failure,
681
+ * Atomic install: re-hash `from` and refuse unless it matches `expectedHash`
682
+ * (the hash selfUpdate.verify returned this binds the installed bytes to the
683
+ * signature-verified bytes and closes the verify→swap window), copy the
684
+ * existing `to` to `backupTo`, rename `from` `to`, then fsync both
685
+ * directories. Cross-device renames fall back to copy + unlink on the
686
+ * destination filesystem. On any failure the original `to` is restored from
687
+ * `backupTo`. Throws SelfUpdateError on a missing `from`, an
688
+ * expectedHash mismatch, backup-copy failure, cross-device install failure,
672
689
  * or rename failure.
673
690
  *
674
691
  * @opts
675
- * from: string, // required — newly-installed asset path
676
- * to: string, // required — target install path
677
- * backupTo: string, // required — backup path for the existing `to`
692
+ * from: string, // required — newly-installed asset path
693
+ * to: string, // required — target install path
694
+ * backupTo: string, // required — backup path for the existing `to`
695
+ * expectedHash: string, // required — the hash selfUpdate.verify returned
696
+ * hashAlgo: string, // sha3-512 (default) | sha-256 | sha-512 | shake256
678
697
  *
679
698
  * @example
699
+ * var v = await b.selfUpdate.verify({ assetPath, signaturePath, pubkeyPem });
680
700
  * try {
681
701
  * await b.selfUpdate.swap({
682
- * from: "/tmp/blamejs-doc-missing.bin",
683
- * to: "/tmp/blamejs-doc-target.bin",
684
- * backupTo: "/tmp/blamejs-doc-backup.bin",
702
+ * from: "/tmp/blamejs-doc-missing.bin",
703
+ * to: "/tmp/blamejs-doc-target.bin",
704
+ * backupTo: "/tmp/blamejs-doc-backup.bin",
705
+ * expectedHash: v.hash,
685
706
  * });
686
707
  * } catch (e) {
687
708
  * e.code; // → "selfupdate/missing-from"
@@ -698,6 +719,37 @@ async function swap(opts) {
698
719
  "selfUpdate.swap: from path does not exist: " + from);
699
720
  }
700
721
 
722
+ // Bind the installed object to the signature-verified bytes. Read `from` with
723
+ // O_NOFOLLOW (refuseSymlink) so a symlinked source is refused AT OPEN rather
724
+ // than followed — otherwise the bytes hashed (the link target) would differ
725
+ // from the object a by-path rename installs (the link itself). The verified
726
+ // bytes are then installed FROM MEMORY below, so the installed object is
727
+ // exactly what was hashed: no symlink-install surface and no time-of-check /
728
+ // time-of-use window between the hash and the install (which a by-path rename
729
+ // or re-read would reopen).
730
+ var swapAlg = opts.hashAlgo || DEFAULT_HASH_ALG;
731
+ var fromMode;
732
+ try { fromMode = (nodeFs.statSync(from).mode & 0o777); } catch (_m) { fromMode = 0o600; }
733
+ var fromBytes;
734
+ try {
735
+ fromBytes = atomicFile.fdSafeReadSync(from, {
736
+ maxBytes: typeof opts.maxBytes === "number" ? opts.maxBytes : C.BYTES.gib(1),
737
+ refuseSymlink: true,
738
+ });
739
+ } catch (e) {
740
+ throw new SelfUpdateError("selfupdate/swap-read-failed",
741
+ "selfUpdate.swap: failed to read from for the integrity re-check (a symlinked source is refused): " +
742
+ ((e && e.message) || String(e)));
743
+ }
744
+ var actualHash = nodeCrypto.createHash(swapAlg).update(fromBytes).digest("hex");
745
+ if (actualHash !== opts.expectedHash) {
746
+ _safeAuditEmit("selfupdate.swap.hash_mismatch", "denied", {
747
+ from: from, to: to, alg: swapAlg, expected: opts.expectedHash, actual: actualHash,
748
+ });
749
+ throw new SelfUpdateError("selfupdate/swap-hash-mismatch",
750
+ "selfUpdate.swap: from bytes do not match expectedHash (asset changed after verify?) — refusing to install");
751
+ }
752
+
701
753
  var toDir = nodePath.dirname(to);
702
754
  var backupDir = nodePath.dirname(backupTo);
703
755
  atomicFile.ensureDir(toDir);
@@ -716,53 +768,33 @@ async function swap(opts) {
716
768
  }
717
769
  }
718
770
 
719
- // Step 3 — install. Rename is atomic on same FS; on cross-device we
720
- // fall back to copy + unlink. Rollback failure on either branch
721
- // surfaces as a DISTINCT error class and audit event so operators
722
- // don't silently lose both binaries (the prior best-effort comment
723
- // swallowed the rollback exception SSDF RV.1 violation).
771
+ // Step 3 — install the verified in-memory bytes via an atomic temp+fsync+
772
+ // rename on the DESTINATION filesystem (atomicFile.write), so the installed
773
+ // object is exactly the bytes just hashed: cross-device is handled (the temp
774
+ // is created on the dest FS), there is no by-path re-read to race, and a
775
+ // symlinked source can't be moved into place. Roll back from the backup on
776
+ // failure; a rollback failure surfaces as a DISTINCT error class + audit
777
+ // event so operators don't silently lose both binaries (SSDF RV.1).
724
778
  try {
725
- atomicFile.renameWithRetry(from, to);
779
+ await atomicFile.write(to, fromBytes, { fileMode: fromMode, overwrite: true });
726
780
  } catch (e) {
727
- if (e && e.code === "EXDEV") {
728
- // Cross-device — copy + unlink. Use atomicFile.copy for the safety
729
- // net (temp+fsync+rename on dest FS); then remove the source.
730
- try {
731
- await atomicFile.copy(from, to, { fileMode: 0o600 });
732
- try { nodeFs.unlinkSync(from); } catch (_u) { /* tmp source leak — operator-cleanable */ }
733
- } catch (ce) {
734
- // Roll back from backup if we have one. Rollback failure here
735
- // is catastrophic — both the new asset (corrupt cross-device
736
- // copy) AND the original (overwritten partial) are now
737
- // unreachable. Surface to operator with a dedicated error.
738
- var rbErrXdev = await _safeRollback(backupTo, to, hadOriginal);
739
- if (rbErrXdev) {
740
- throw new SelfUpdateError("selfupdate/swap-rollback-failed",
741
- "selfUpdate.swap: cross-device install failed AND rollback ALSO " +
742
- "failed — operator must manually restore from backupTo=" + backupTo +
743
- ". install-error=" + ((ce && ce.message) || String(ce)) +
744
- "; rollback-error=" + rbErrXdev.message);
745
- }
746
- throw new SelfUpdateError("selfupdate/cross-device",
747
- "selfUpdate.swap: cross-device install failed: " + ((ce && ce.message) || String(ce)));
748
- }
749
- } else {
750
- // Other rename failure — try to roll back. Same rollback-failure
751
- // semantics as the cross-device branch.
752
- var rbErr = await _safeRollback(backupTo, to, hadOriginal);
753
- if (rbErr) {
754
- throw new SelfUpdateError("selfupdate/swap-rollback-failed",
755
- "selfUpdate.swap: rename " + from + " -> " + to + " failed AND " +
756
- "rollback ALSO failed — operator must manually restore from " +
757
- "backupTo=" + backupTo + ". rename-error=" + e.message +
758
- "; rollback-error=" + rbErr.message);
759
- }
760
- throw new SelfUpdateError("selfupdate/swap-failed",
761
- "selfUpdate.swap: rename " + from + " -> " + to + " failed: " + e.message);
781
+ var rbErr = await _safeRollback(backupTo, to, hadOriginal);
782
+ if (rbErr) {
783
+ throw new SelfUpdateError("selfupdate/swap-rollback-failed",
784
+ "selfUpdate.swap: install of " + to + " failed AND rollback ALSO failed — " +
785
+ "operator must manually restore from backupTo=" + backupTo +
786
+ ". install-error=" + ((e && e.message) || String(e)) +
787
+ "; rollback-error=" + rbErr.message);
762
788
  }
789
+ throw new SelfUpdateError("selfupdate/swap-failed",
790
+ "selfUpdate.swap: install of " + to + " failed: " + ((e && e.message) || String(e)));
763
791
  }
792
+ // Consume the source asset now that the verified bytes are installed
793
+ // (best-effort — the install already succeeded; a leftover temp is
794
+ // operator-cleanable).
795
+ try { nodeFs.unlinkSync(from); } catch (_u) { /* tmp source leak — operator-cleanable */ }
764
796
 
765
- // Step 4 — fsync directories so the rename is durable.
797
+ // Step 4 — fsync directories so the install is durable.
766
798
  atomicFile.fsyncDir(toDir);
767
799
  if (backupDir !== toDir) atomicFile.fsyncDir(backupDir);
768
800
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.43",
3
+ "version": "0.15.45",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:bcd0c33c-a657-4ed3-b17c-eb1691a77bc5",
5
+ "serialNumber": "urn:uuid:d5a2ec6c-d7a4-4d6c-a799-5b72ffcf1191",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-28T11:16:40.494Z",
8
+ "timestamp": "2026-06-28T13:37:01.021Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.15.43",
22
+ "bom-ref": "@blamejs/core@0.15.45",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.43",
25
+ "version": "0.15.45",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.15.43",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.45",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.15.43",
57
+ "ref": "@blamejs/core@0.15.45",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]