@blamejs/core 0.15.64 → 0.15.66

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.66 (2026-06-29) — **Recursive serializers and parsers refuse pathologically deep input with a typed error instead of overflowing the stack and crashing the process.** Several of the framework's recursive walkers over attacker-reachable input lacked an effective nesting cap, so a deeply nested value could exhaust the V8 call stack and throw an uncaught RangeError — crashing the process (a denial of service). b.canonicalJson is the most exposed: content-credentials verification canonicalises an untrusted manifest before it checks the signature, so a hostile credential could crash a verifier pre-authentication. b.jsonSchema.validate had a depth guard, but its limit was set so high the stack overflowed before the guard could fire, so validating a request body against a recursive schema crashed rather than returning a typed error. b.i18n.messageFormat parsed and rendered nested plural/select cases with no cap. Each walker now throws a typed framework error well before native overflow, and legitimate (even deeply nested) input is unaffected. The release also corrects DNSSEC signature-window comparison to the RFC 1982 serial-number arithmetic the spec requires, and makes the Redis client treat a malformed reply frame as a connection fault rather than letting it crash the host. **Fixed:** *DNSSEC compares RRSIG validity windows with RFC 1982 serial arithmetic* — b.network.dns.dnssec compared an RRSIG's inception and expiration against the current time by magnitude. RFC 4034 §3.1.5 requires the 32-bit timestamps to be interpreted with RFC 1982 serial-number arithmetic, which agrees with a plain comparison only while the values stay below 2^31. A signature whose window straddles the 2^31 (January 2038) or 2^32 (February 2106) boundary was mis-ordered — an in-window signature rejected, or a stale one accepted. The comparison now masks the clock into the same 32-bit serial space and tests the wrapped signed delta. **Security:** *Canonical JSON refuses excessively nested input before it can overflow the stack* — b.canonicalJson.stringify and stringifyJcs walk the value recursively. They detected circular references but had no nesting cap, so a deeply nested (acyclic) object or array overflowed the V8 stack with an uncaught RangeError. This is reachable before authentication: content-credentials verification canonicalises the untrusted manifest to hash it before verifying the signature, so a hostile credential could crash the verifier. Both serializers now throw a typed nesting-depth error at a depth far beyond any legitimate signed document and well short of native overflow. · *JSON Schema validation depth guard now fires before the stack overflows* — b.jsonSchema.validate caps subschema-validation nesting to defend against a recursive schema (for example items pointing back at the root with $ref) applied to a deeply nested instance — both attacker-controlled when validating a request body. The cap was set so high the V8 stack overflowed first, so the guard never ran and a crafted body crashed the validator with an uncaught RangeError instead of the typed reference-depth error. The limit is now well under native overflow; legitimate documents — deeply nested or wide — continue to validate. · *ICU MessageFormat refuses pathologically nested templates* — b.i18n.messageFormat.parse and format recurse once per nested plural/select case, with no depth cap. A template nested thousands of levels deep overflowed the stack. format() and parse() are public and b.i18n.t can render operator-supplied entries, so a hostile template is a denial-of-service vector; it now fails as a typed bad-template error. Real-world nesting (a handful of levels) is unaffected. · *Redis client treats a malformed reply frame as a connection fault, not a crash* — The RESP decoder recurses on nested arrays with no depth cap, and the data handler did not guard the parse, so a malformed or hostilely deep frame from a server threw out of the socket callback and could crash the host. The decoder now caps reply nesting and any parse fault tears the socket down and rejects in-flight commands for a reconnect — the same path as any other lost connection.
12
+
13
+ - v0.15.65 (2026-06-29) — **The account-takeover kill-switch now actually locks the account: b.auth.lockout gains a lock() method and the kill-switch engages it through the operator's lockout instance.** b.auth.atoKillSwitch.trigger is the incident-response path for a compromised account: it destroys the user's sessions and then locks them out of new logins. The lockout step called b.auth.lockout.lock(), but no such method existed (and the kill-switch invoked it on the lockout module, which has no store), so the call threw and was swallowed — the kill-switch reported success while never locking the account, leaving an attacker who still held the credentials free to log straight back in. b.auth.lockout instances now expose lock(key, { durationMs?, untilMs?, reason? }) to force an account into lockout immediately, atomically, independent of the failure counter, and atoKillSwitch.trigger engages it through the lockout instance the operator passes as opts.lockout. When no lockout instance is supplied the step is skipped and the result's lockoutApplied is false (with an audit row), rather than silently claiming the account was locked. The release also closes a lock-clear race in lockout and refuses a self-disabling window. **Added:** *b.auth.lockout instances expose lock()* — lock(key, { durationMs?, untilMs?, reason? }) forces an account into lockout immediately — the operator action behind an ATO kill-switch or incident response — independent of the failure counter, defaulting to a long admin lock. A forced lock is released only by an explicit unlock(): recordSuccess() does not clear it, so a successful login by someone who still holds the compromised password cannot release a kill-switch lock. It uses the same atomic compare-and-set as the failure counter and throws if the lock cannot be committed (the caller must know it did not lock). **Security:** *Account-takeover kill-switch engages the lockout instead of silently doing nothing* — b.auth.atoKillSwitch.trigger invoked b.auth.lockout.lock(), which did not exist; the call threw and was caught by a best-effort guard, so the kill-switch destroyed sessions but never locked the account — an attacker still holding valid credentials could immediately re-authenticate. b.auth.lockout instances now provide lock() (an atomic, compare-and-set forced lockout), and the kill-switch calls it on the lockout instance supplied as opts.lockout. Without a lockout instance the lockout step cannot run; the result's lockoutApplied is false and an audit row records that the account was not locked, so an operator is not misled into believing a compromised account was secured. opts.lockout is now the lockout instance (or false to skip), not a boolean toggle. · *lockout clears the failure counter atomically and refuses a zero window* — b.auth.lockout.recordSuccess and unlock cleared state with a read-then-delete, which could erase a lock a concurrent recordFailure had just engaged; they now clear under the same compare-and-set the failure path uses. create() also refuses windowMs: 0, which previously disabled lockout entirely (every failure decayed immediately and the zero-TTL state never persisted).
14
+
11
15
  - v0.15.64 (2026-06-29) — **Token and HTTP-message-signature verifiers now reject a non-finite clock-skew or tolerance value instead of letting it disable the expiry, not-before, and future-dating checks.** Several verifiers read an operator-supplied clock-skew or tolerance value with a bare type check that accepted Infinity and NaN. Because the resulting comparison (for example exp + skew < now, or age > tolerance) is always false when the skew or tolerance is Infinity, a misconfigured non-finite value silently disabled the time-window check — so an expired token, a not-yet-valid token, a future-dated signature, or a replayed (too-old) signature would verify. The affected paths are the shared external-JWT verifier b.auth.jwt.verifyExternal (which the JAR / signed-request-object path also uses), the SD-JWT-VC credential verifier, the OAuth ID-token and client-attestation verifiers, and the HTTP message signature verifier's freshness and clock-skew windows. Each now requires a present skew or tolerance to be a non-negative finite number: the throw-based JWT, SD-JWT-VC, and OAuth verifiers reject a malformed value, and the verdict-returning HTTP-message-signature verifier falls back to its safe default rather than disabling the window. A build check now fails if any verifier reads one of these options without a finiteness guard. **Added:** *verifyExternal and jar.parse accept an `now` clock override* — b.auth.jwt.verifyExternal and b.auth.jar.parse now accept an optional `now` (a finite epoch-milliseconds value) to evaluate a token's exp / nbf / iat as of a specific instant instead of the wall clock — consistent with b.auth.sdJwtVc.verify. A negative clockSkewMs is no longer accepted as a way to shift the evaluation time; use `now`. **Security:** *Non-finite clock-skew / tolerance no longer disables a verifier's time-window check* — b.auth.jwt.verifyExternal, b.auth.sdJwtVc.verify, b.auth.oauth (ID-token and client-attestation verification), and the HTTP message signature verifier read their clock-skew, max-clock-skew, max-PoP-age, and tolerance options with a bare `typeof === "number"` check that let Infinity and NaN through. With such a value the expiry / not-before / future-dating comparison is always false, so the check was silently skipped and an expired or not-yet-valid token — or a future-dated or replayed signature — would verify. The JWT, SD-JWT-VC, and OAuth verifiers now reject a non-finite or negative skew (the option is operator configuration, not attacker-controlled), and the HTTP message signature verifier falls back to its default tolerance and skew. The CWT, DPoP, SAML, and OpenID Federation verifiers already enforced this and are unchanged. **Detectors:** *Verifier clock-skew / tolerance options must be finite-guarded* — A build check fails if a verifier reads a clock-skew or tolerance option with a bare `typeof === "number"` without a finiteness guard (a non-negative-finite check, an inline isFinite fallback, or a create-time non-negative-finite schema), preventing a future verifier from reintroducing the disable-the-window class.
12
16
 
13
17
  - v0.15.63 (2026-06-29) — **OID4VCI now enforces single-use of a pre-authorized code and of a single-use access token under concurrency, so two simultaneous requests can no longer mint two credentials from one.** On the OID4VCI credential issuer, two single-use values were consumed by a delete whose result was ignored, so concurrent requests could each act on the same value. exchangePreAuthorizedCode read the pre-authorized code's entry, validated the transaction code, then deleted the code and minted an access token without checking that its own call had removed the entry — two simultaneous /token requests with the same code each saw the entry, each deleted it, and each minted a distinct access token, issuing two access tokens (and ultimately two credentials) from a code OID4VCI requires to be single-use. issueCredential had the same shape: with single-use access tokens (the default), it minted the credential first and deleted the access token afterward as best-effort cleanup, so two concurrent requests bearing the same token both read it and the same not-yet-rotated c_nonce, both proofs verified, and both minted a credential. Both paths now claim the value with an atomic delete and proceed only when that delete succeeded; the losing request is refused. The transaction-code and proof checks still run first, so a bad transaction code or proof does not consume the value (a wallet can retry). **Security:** *OID4VCI pre-authorized code and access token are single-use under concurrency* — exchangePreAuthorizedCode and issueCredential consumed their single-use value (the pre-authorized code, and the single-use access token) with a delete whose return was discarded, and in issueCredential's case after the credential was already minted. Two concurrent requests carrying the same value therefore each succeeded — minting two access tokens from one pre-authorized code, or two credentials from one single-use access token — defeating the single-use guarantee OID4VCI §3.5 requires (an authorization intended for one credential could yield two). Both paths now delete the value atomically and issue only if that delete removed it, refusing the request that lost the race; the transaction-code and proof verifications run before the claim, so an invalid attempt does not burn the value. If the operator's credential issuer throws after the access token has been claimed (a transient signer outage), the token is restored so the wallet can retry rather than being permanently consumed without a credential.
@@ -10,17 +10,22 @@
10
10
  * cleanup path once the trigger fires:
11
11
  *
12
12
  * 1. destroy every session for the user across the cluster
13
- * 2. lock the user out of new logins (b.auth.lockout)
13
+ * 2. lock the user out of new logins via the supplied lockout instance
14
14
  * 3. emit an audit row with reason / actor for downstream forensics
15
15
  *
16
16
  * await b.auth.atoKillSwitch.trigger({
17
17
  * userId: "u_42",
18
18
  * reason: "fraud-signal: 14 failed MFA from new geo",
19
19
  * actor: { id: req.user && req.user.id, role: req.user && req.user.role },
20
- * lockout: true, // default true
20
+ * lockout: appLockout, // a b.auth.lockout instance (or false to skip)
21
21
  * accessLock: "locked", // optional — flip the global access-lock mode
22
22
  * });
23
23
  *
24
+ * The lockout step needs the operator's configured b.auth.lockout instance
25
+ * (there is no module-level lockout store); pass it as `opts.lockout`. When it
26
+ * is omitted, the lockout step is skipped and the returned `lockoutApplied` is
27
+ * false (with an audit row) — the account is NOT locked.
28
+ *
24
29
  * Returns `{ sessionsDestroyed, lockoutApplied, accessLockMode }`.
25
30
  */
26
31
 
@@ -29,7 +34,6 @@ var validateOpts = require("../validate-opts");
29
34
  var { defineClass } = require("../framework-error");
30
35
 
31
36
  var session = lazyRequire(function () { return require("../session"); });
32
- var lockout = lazyRequire(function () { return require("./lockout"); });
33
37
  var accessLock = lazyRequire(function () { return require("./access-lock"); });
34
38
  var audit = lazyRequire(function () { return require("../audit"); });
35
39
 
@@ -43,8 +47,15 @@ async function trigger(opts) {
43
47
 
44
48
  validateOpts.requireNonEmptyString(opts.userId, "userId", AtoKillSwitchError, "auth-ato-kill-switch/missing-user-id");
45
49
  validateOpts.requireNonEmptyString(opts.reason, "reason", AtoKillSwitchError, "auth-ato-kill-switch/missing-reason");
46
- var doLockout = opts.lockout !== false;
47
- var accessLockMode = typeof opts.accessLock === "string" ? opts.accessLock : null;
50
+ // opts.lockout is the operator's configured b.auth.lockout INSTANCE to lock
51
+ // the account in (or false to skip the lockout step). The kill-switch needs
52
+ // that instance's store to engage the lock — there is no module-level lockout
53
+ // singleton — so without an instance the lockout step cannot run and is
54
+ // reported (rather than silently claiming success while never locking).
55
+ var skipLockout = opts.lockout === false;
56
+ var lockoutInst = (opts.lockout && typeof opts.lockout === "object" &&
57
+ typeof opts.lockout.lock === "function") ? opts.lockout : null;
58
+ var accessLockMode = typeof opts.accessLock === "string" ? opts.accessLock : null;
48
59
 
49
60
  var sessionsDestroyed = 0;
50
61
  try {
@@ -63,13 +74,37 @@ async function trigger(opts) {
63
74
  }
64
75
 
65
76
  var lockoutApplied = false;
66
- if (doLockout) {
67
- try {
68
- await lockout().lock(opts.userId, {
69
- reason: "ato-kill-switch:" + opts.reason,
77
+ if (!skipLockout) {
78
+ if (lockoutInst) {
79
+ try {
80
+ await lockoutInst.lock(opts.userId, {
81
+ reason: "ato-kill-switch:" + opts.reason,
82
+ });
83
+ lockoutApplied = true;
84
+ } catch (e) {
85
+ // The lockout step failed (e.g. a cache outage) AFTER sessions were
86
+ // destroyed. Don't fail the whole kill-switch — but surface it so the
87
+ // operator knows the account was NOT locked, rather than swallowing.
88
+ audit().safeEmit({
89
+ action: "auth.ato_kill_switch.partial",
90
+ outcome: "failure",
91
+ metadata: { userId: opts.userId, step: "lockout", reason: e && e.message },
92
+ });
93
+ }
94
+ } else {
95
+ // No lockout instance supplied: the kill-switch cannot lock the account.
96
+ // Report it (the result's lockoutApplied stays false) so the operator
97
+ // doesn't believe the user was locked out of new logins.
98
+ audit().safeEmit({
99
+ action: "auth.ato_kill_switch.partial",
100
+ outcome: "failure",
101
+ metadata: {
102
+ userId: opts.userId,
103
+ step: "lockout",
104
+ reason: "no lockout instance supplied (pass opts.lockout = b.auth.lockout.create({ cache, namespace }))",
105
+ },
70
106
  });
71
- lockoutApplied = true;
72
- } catch (_e) { /* lockout is best-effort; sessions already destroyed */ }
107
+ }
73
108
  }
74
109
 
75
110
  var modeApplied = null;
@@ -90,6 +90,11 @@ var observability = lazyRequire(function () { return require("../observability")
90
90
 
91
91
  var _err = LockoutError.factory;
92
92
 
93
+ // Default duration for an operator-forced lock() (ATO kill-switch / incident
94
+ // response) when neither untilMs nor durationMs is supplied — long enough to
95
+ // require an explicit admin unlock() during an active incident.
96
+ var DEFAULT_ADMIN_LOCK_MS = C.TIME.hours(24);
97
+
93
98
  var DEFAULTS = Object.freeze({
94
99
  maxAttempts: 5,
95
100
  windowMs: C.TIME.minutes(15),
@@ -173,7 +178,11 @@ function create(opts) {
173
178
  _requirePositiveInt("maxAttempts", maxAttempts);
174
179
 
175
180
  var windowMs = opts.windowMs !== undefined ? opts.windowMs : DEFAULTS.windowMs;
176
- _requireNonNegFinite("windowMs", windowMs);
181
+ // windowMs must be POSITIVE: a 0 window makes every failure "decay" on the
182
+ // next request (the `now - lastFailureAt > windowMs` reset fires for any
183
+ // elapsed time) so the counter never reaches maxAttempts, AND the cache TTL
184
+ // of 0 makes the state non-persistent — together silently disabling lockout.
185
+ _requirePositiveInt("windowMs", windowMs);
177
186
 
178
187
  var lockoutDurations = opts.lockoutDurations !== undefined
179
188
  ? opts.lockoutDurations : DEFAULTS.lockoutDurations;
@@ -242,6 +251,49 @@ function create(opts) {
242
251
  }
243
252
  }
244
253
 
254
+ // Atomically clear the lockout state under a compare-and-set, so a clear
255
+ // (recordSuccess / unlock) cannot race a concurrent recordFailure that just
256
+ // engaged a lock — a read-then-del would erase that fresh lock. onState is
257
+ // invoked with the pre-clear state inside the CAS mutator (it may re-run on a
258
+ // retry; the last invocation reflects the committed state) to capture audit
259
+ // detail. On a backend that can't do an atomic update at runtime it falls
260
+ // back to read-then-del (a lost clear leaves a lock in place — fail-safe).
261
+ // preserveIf(state) → true to KEEP the state (abort the clear) rather than
262
+ // delete it. recordSuccess passes a predicate that preserves an active forced
263
+ // (admin / ATO kill-switch) lock, so a successful login by someone who still
264
+ // holds the compromised password cannot clear it — only an explicit unlock()
265
+ // releases a forced lock.
266
+ async function _atomicClear(key, onState, preserveIf) {
267
+ try {
268
+ await cache.update(_scopedKey(key), function (state) {
269
+ state = state || null;
270
+ onState(state);
271
+ if (!state) return { abort: true };
272
+ if (preserveIf && preserveIf(state)) return { abort: true };
273
+ return { delete: true };
274
+ }, { ttlMs: windowMs });
275
+ } catch (e) {
276
+ if (e && e.code === "UNSUPPORTED") {
277
+ var st = await _readState(key);
278
+ onState(st);
279
+ if (st && !(preserveIf && preserveIf(st))) await _deleteState(key);
280
+ return;
281
+ }
282
+ _signalCacheError("update");
283
+ // Best-effort fallback so a transient cache error doesn't leave the
284
+ // counter un-cleared on the happy path.
285
+ var st2 = await _readState(key);
286
+ onState(st2);
287
+ if (st2 && !(preserveIf && preserveIf(st2))) await _deleteState(key);
288
+ }
289
+ }
290
+
291
+ // An active forced/admin lock (set by lock()) must survive recordSuccess.
292
+ function _isActiveForcedLock(state) {
293
+ return !!(state && state.forced === true &&
294
+ typeof state.lockedUntil === "number" && state.lockedUntil > clock());
295
+ }
296
+
245
297
  function _verdictFromState(state, now) {
246
298
  if (!state) return { locked: false, attempts: 0 };
247
299
  if (state.lockedUntil && state.lockedUntil > now) {
@@ -380,13 +432,19 @@ function create(opts) {
380
432
  async function recordSuccess(key, callOpts) {
381
433
  _requireKey(key);
382
434
  callOpts = callOpts || {};
383
- var state = await _readState(key);
384
- var hadCounter = !!(state && (state.attempts > 0 || state.lockedUntil));
385
- if (state) await _deleteState(key);
435
+ var hadCounter = false;
436
+ var clearedAttempts = 0;
437
+ // Preserve an active forced (admin / ATO) lock: a successful credential
438
+ // verification must not release a kill-switch lock — the password may still
439
+ // be the compromised one. A forced lock yields only to unlock().
440
+ await _atomicClear(key, function (state) {
441
+ hadCounter = !!(state && (state.attempts > 0 || state.lockedUntil));
442
+ clearedAttempts = (state && state.attempts) || 0;
443
+ }, _isActiveForcedLock);
386
444
  _emitObs("auth.lockout.success", { namespace: namespace });
387
445
  if (auditSuccess) {
388
446
  _emitAudit("auth.lockout.success", key, "success",
389
- { attemptsCleared: (state && state.attempts) || 0,
447
+ { attemptsCleared: clearedAttempts,
390
448
  hadCounter: hadCounter },
391
449
  callOpts.req);
392
450
  }
@@ -401,26 +459,93 @@ function create(opts) {
401
459
  async function unlock(key, callOpts) {
402
460
  _requireKey(key);
403
461
  callOpts = callOpts || {};
404
- var state = await _readState(key);
405
462
  var now = clock();
406
- var hadLock = !!(state && (
407
- (state.lockedUntil && state.lockedUntil > now) ||
408
- (state.attempts || 0) > 0
409
- ));
410
- if (state) await _deleteState(key);
463
+ var hadLock = false;
464
+ var prior = { attempts: 0, lockedUntil: null, lockNumber: 0 };
465
+ await _atomicClear(key, function (state) {
466
+ hadLock = !!(state && (
467
+ (state.lockedUntil && state.lockedUntil > now) ||
468
+ (state.attempts || 0) > 0
469
+ ));
470
+ prior = {
471
+ attempts: (state && state.attempts) || 0,
472
+ lockedUntil: (state && state.lockedUntil) || null,
473
+ lockNumber: (state && state.lockNumber) || 0,
474
+ };
475
+ });
411
476
  _emitObs("auth.lockout.unlock", { namespace: namespace });
412
477
  if (auditUnlock) {
413
478
  _emitAudit("auth.lockout.unlock", key, "success",
414
479
  { hadLock: hadLock,
415
- priorAttempts: (state && state.attempts) || 0,
416
- priorLockedUntil: (state && state.lockedUntil) || null,
417
- priorLockNumber: (state && state.lockNumber) || 0,
480
+ priorAttempts: prior.attempts,
481
+ priorLockedUntil: prior.lockedUntil,
482
+ priorLockNumber: prior.lockNumber,
418
483
  reason: callOpts.reason || null },
419
484
  callOpts.req);
420
485
  }
421
486
  return hadLock;
422
487
  }
423
488
 
489
+ // Force an account into lockout immediately — the operator action behind an
490
+ // ATO kill-switch / incident response, independent of the failure counter.
491
+ // Sets lockedUntil to `untilMs` (absolute) or now+`durationMs`, defaulting to
492
+ // a long admin lock; bumps lockNumber so a subsequent failure ladders from
493
+ // here. Uses the same compare-and-set as recordFailure (atomic, retried). An
494
+ // admin lock that cannot be committed THROWS (the caller — e.g. the kill-
495
+ // switch — must know it did not lock), rather than the hot-path fail-open.
496
+ async function lock(key, callOpts) {
497
+ _requireKey(key);
498
+ callOpts = callOpts || {};
499
+ var now = clock();
500
+ var lockedUntil;
501
+ if (typeof callOpts.untilMs === "number" && isFinite(callOpts.untilMs)) {
502
+ lockedUntil = callOpts.untilMs;
503
+ } else if (typeof callOpts.durationMs === "number" && isFinite(callOpts.durationMs) && callOpts.durationMs > 0) {
504
+ lockedUntil = now + callOpts.durationMs;
505
+ } else {
506
+ lockedUntil = now + DEFAULT_ADMIN_LOCK_MS;
507
+ }
508
+ if (lockedUntil <= now) {
509
+ throw _err("BAD_OPT", "lock: resolved lockedUntil is not in the future " +
510
+ "(untilMs/durationMs) — use unlock() to clear a lock");
511
+ }
512
+ var ttl = lockedUntil - now + windowMs;
513
+ var lockNumber = 0;
514
+ try {
515
+ await cache.update(_scopedKey(key), function (state) {
516
+ lockNumber = ((state && state.lockNumber) || 0) + 1;
517
+ return {
518
+ value: {
519
+ attempts: 0,
520
+ lockNumber: lockNumber,
521
+ firstFailureAt: (state && state.firstFailureAt) || now,
522
+ lastFailureAt: now,
523
+ lockedUntil: lockedUntil,
524
+ // Marks this as an operator-forced lock so recordSuccess won't clear
525
+ // it — it is released only by an explicit unlock().
526
+ forced: true,
527
+ },
528
+ ttlMs: ttl,
529
+ };
530
+ }, { ttlMs: ttl });
531
+ } catch (e) {
532
+ if (e && e.code === "UNSUPPORTED") {
533
+ throw _err("CACHE_NO_ATOMIC_UPDATE",
534
+ "auth.lockout: the cache backend does not support atomic update() — " +
535
+ "lock() cannot enforce the lockout across nodes on a get/set-only backend.");
536
+ }
537
+ throw e;
538
+ }
539
+ _emitObs("auth.lockout.engaged", { namespace: namespace, lockNumber: String(lockNumber) });
540
+ if (auditEngaged) {
541
+ _emitAudit("auth.lockout.engaged", key, "denied",
542
+ { lockNumber: lockNumber, lockedUntil: lockedUntil, durationMs: lockedUntil - now,
543
+ forced: true, reason: callOpts.reason || null },
544
+ callOpts.req);
545
+ }
546
+ return { locked: true, lockedUntil: lockedUntil, lockNumber: lockNumber };
547
+ }
548
+
424
549
  async function attempts(key) {
425
550
  _requireKey(key);
426
551
  var state = await _readState(key);
@@ -437,6 +562,7 @@ function create(opts) {
437
562
  recordFailure: recordFailure,
438
563
  recordSuccess: recordSuccess,
439
564
  check: check,
565
+ lock: lock,
440
566
  unlock: unlock,
441
567
  attempts: attempts,
442
568
  close: close,
@@ -42,7 +42,17 @@
42
42
  // RFC 8785 §3.2.3 sort. Primitives, strings, and numbers use
43
43
  // JSON.stringify, whose escaping (§3.2.2.2) and ECMAScript number format
44
44
  // (§3.2.2.3) are exactly what JCS references.
45
- function _emit(value, seen, bufferAs) {
45
+
46
+ // Maximum nesting depth. The walk is recursive, so a deeply-nested input
47
+ // (e.g. an attacker-supplied manifest handed to content-credentials.verify
48
+ // BEFORE signature verification, or untrusted MCP tool-call args) would
49
+ // otherwise overflow the V8 stack with an unhandled RangeError — a crash /
50
+ // pre-auth DoS. Throw a typed framework error well before native overflow.
51
+ // The cycle WeakSet below catches references that loop; this catches a tree
52
+ // that is merely very deep. 512 is far beyond any legitimate signed document.
53
+ var MAX_DEPTH = 512;
54
+
55
+ function _emit(value, seen, bufferAs, depth) {
46
56
  if (value === null || typeof value === "undefined") return "null";
47
57
  var t = typeof value;
48
58
  if (t === "number" || t === "string" || t === "boolean") return JSON.stringify(value);
@@ -90,6 +100,10 @@ function _emit(value, seen, bufferAs) {
90
100
  if (seen.has(value)) {
91
101
  throw new Error("canonical-json: circular reference detected");
92
102
  }
103
+ depth = depth || 0;
104
+ if (depth > MAX_DEPTH) {
105
+ throw new Error("canonical-json: maximum nesting depth exceeded (" + MAX_DEPTH + ")");
106
+ }
93
107
  seen.add(value);
94
108
  if (Array.isArray(value)) {
95
109
  // Index loop, not .map(): map() skips holes in a sparse array,
@@ -97,7 +111,7 @@ function _emit(value, seen, bufferAs) {
97
111
  // reads as undefined → _emit returns "null" (matching JSON.stringify).
98
112
  var items = [];
99
113
  for (var ai = 0; ai < value.length; ai += 1) {
100
- items.push(_emit(value[ai], seen, bufferAs));
114
+ items.push(_emit(value[ai], seen, bufferAs, depth + 1));
101
115
  }
102
116
  return "[" + items.join(",") + "]";
103
117
  }
@@ -107,7 +121,7 @@ function _emit(value, seen, bufferAs) {
107
121
  keys.sort();
108
122
  var parts = [];
109
123
  for (var i = 0; i < keys.length; i++) {
110
- parts.push(JSON.stringify(keys[i]) + ":" + _emit(value[keys[i]], seen, bufferAs));
124
+ parts.push(JSON.stringify(keys[i]) + ":" + _emit(value[keys[i]], seen, bufferAs, depth + 1));
111
125
  }
112
126
  return "{" + parts.join(",") + "}";
113
127
  }
@@ -63,6 +63,18 @@ function _err(code, message) {
63
63
  return new I18nMessageFormatError(code, message, true);
64
64
  }
65
65
 
66
+ // Maximum case-nesting depth. The parser and the renderer both recurse
67
+ // once per nested plural/select case body; a template like
68
+ // `{a,select,x{{b,select,x{{c,select,...}}}}}` nested thousands deep would
69
+ // otherwise overflow the V8 stack with an uncaught RangeError. Templates
70
+ // usually come from operator translation files, but format()/parse() are
71
+ // public and `b.i18n.t(key, vars, { messageFormat: true })` renders entries
72
+ // that may be operator-supplied per-tenant, so a hostile template must fail
73
+ // as a typed BAD_TEMPLATE rather than crashing the process. Real
74
+ // translations nest 2-3 deep; 100 is far beyond any legitimate message yet
75
+ // well under native overflow.
76
+ var MAX_NESTING_DEPTH = 100;
77
+
66
78
  // ---- Parser ----
67
79
  //
68
80
  // AST node shapes:
@@ -89,6 +101,12 @@ function parse(template) {
89
101
  }
90
102
 
91
103
  function _parseSequence(state, topLevel) {
104
+ state.depth = (state.depth || 0) + 1;
105
+ if (state.depth > MAX_NESTING_DEPTH) {
106
+ throw _err("BAD_TEMPLATE",
107
+ "messageFormat.parse: case nesting too deep (max " +
108
+ MAX_NESTING_DEPTH + ")");
109
+ }
92
110
  var nodes = [];
93
111
  var lit = "";
94
112
  while (state.pos < state.src.length) {
@@ -146,6 +164,7 @@ function _parseSequence(state, topLevel) {
146
164
  state.pos += 1;
147
165
  }
148
166
  if (lit.length > 0) nodes.push({ type: "literal", value: lit });
167
+ state.depth -= 1;
149
168
  return nodes;
150
169
  }
151
170
 
@@ -322,18 +341,26 @@ function _pluralRules(locale, type) {
322
341
 
323
342
  function format(template, vars, locale) {
324
343
  var nodes = parse(template);
325
- return _renderSequence(nodes, vars || {}, locale || "en", null);
344
+ return _renderSequence(nodes, vars || {}, locale || "en", null, 0);
326
345
  }
327
346
 
328
- function _renderSequence(nodes, vars, locale, hashContext) {
347
+ function _renderSequence(nodes, vars, locale, hashContext, depth) {
348
+ depth = depth || 0;
349
+ // parse() already bounds AST nesting to MAX_NESTING_DEPTH, so this guard
350
+ // is defence-in-depth for any future caller that hands render a hand-built
351
+ // tree — it must still fail typed rather than overflow the stack.
352
+ if (depth > MAX_NESTING_DEPTH) {
353
+ throw _err("BAD_TEMPLATE",
354
+ "messageFormat: render nesting too deep (max " + MAX_NESTING_DEPTH + ")");
355
+ }
329
356
  var out = "";
330
357
  for (var i = 0; i < nodes.length; i++) {
331
- out += _renderNode(nodes[i], vars, locale, hashContext);
358
+ out += _renderNode(nodes[i], vars, locale, hashContext, depth);
332
359
  }
333
360
  return out;
334
361
  }
335
362
 
336
- function _renderNode(node, vars, locale, hashContext) {
363
+ function _renderNode(node, vars, locale, hashContext, depth) {
337
364
  if (node.type === "literal") return node.value;
338
365
  if (node.type === "hash") {
339
366
  return hashContext != null ? String(hashContext) : "#";
@@ -358,13 +385,13 @@ function _renderNode(node, vars, locale, hashContext) {
358
385
  var category = pr.select(adjusted);
359
386
  caseBody = node.cases[category] || node.cases.other;
360
387
  }
361
- return _renderSequence(caseBody, vars, locale, adjusted);
388
+ return _renderSequence(caseBody, vars, locale, adjusted, depth + 1);
362
389
  }
363
390
  if (node.type === "select") {
364
391
  var sv = vars[node.name];
365
392
  var key = (sv === undefined || sv === null) ? "other" : String(sv);
366
393
  var body = node.cases[key] || node.cases.other;
367
- return _renderSequence(body, vars, locale, hashContext);
394
+ return _renderSequence(body, vars, locale, hashContext, depth + 1);
368
395
  }
369
396
  return "";
370
397
  }
@@ -54,7 +54,17 @@ var { defineClass } = require("./framework-error");
54
54
  var JsonSchemaError = defineClass("JsonSchemaError", { alwaysPermanent: true });
55
55
 
56
56
  var DIALECT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
57
- var MAX_REF_DEPTH = 10000; // recursion-depth cap (count, not a byte size)
57
+ // Subschema-validation nesting cap. ctx.depth is incremented on entry to
58
+ // _validate and decremented in its finally, so it tracks the live nesting
59
+ // depth (sibling keywords/properties do not accumulate) — the deepest the
60
+ // instance×schema walk has recursed. validate(schema, requestBody) reaches
61
+ // it with attacker input on BOTH sides: a recursive schema (items:{$ref:"#"})
62
+ // against a deeply-nested array/object, or a deeply-nested allOf chain.
63
+ // Left unbounded the walk overflows the V8 stack (~1000 levels) with an
64
+ // uncaught RangeError before this guard fired — a pre-validation DoS. 256 is
65
+ // far beyond any legitimate document yet well under native overflow, so the
66
+ // typed json-schema/ref-loop error is what an operator sees.
67
+ var MAX_REF_DEPTH = 256; // recursion-depth cap (count, not a byte size)
58
68
  var DEFAULT_MAX_ERRORS = 100; // error-collection cap
59
69
 
60
70
  function _typeOf(v) {
@@ -295,8 +295,19 @@ function verifyRrset(opts) {
295
295
  validateOpts.optionalDate(opts.at, "dnssec.verifyRrset: opts.at", DnssecError, "dnssec/bad-at");
296
296
  var atMs = (opts.at !== undefined && opts.at !== null) ? opts.at.getTime() : Date.now();
297
297
  var nowSec = Math.floor(atMs / 1000);
298
- if (nowSec < (rrsig.inception >>> 0)) throw new DnssecError("dnssec/not-yet-valid", "dnssec.verifyRrset: RRSIG inception is in the future");
299
- if (nowSec > (rrsig.expiration >>> 0)) throw new DnssecError("dnssec/expired", "dnssec.verifyRrset: RRSIG has expired");
298
+ // RFC 4034 §3.1.5: the RRSIG inception/expiration fields are 32-bit and
299
+ // MUST be compared with RFC 1982 serial-number arithmetic, not magnitude.
300
+ // A plain `<` / `>` agrees with serial arithmetic only while both operands
301
+ // stay under 2^31; it mis-orders any window that straddles the 2^31 (Jan
302
+ // 2038) or 2^32 (Feb 2106) boundary — accepting an expired signature or
303
+ // rejecting a valid one. Mask the clock into the same 32-bit serial space
304
+ // and compare the wrapped signed delta: a negative (now - inception) means
305
+ // inception is in the future; a negative (expiration - now) means expired.
306
+ var nowSer = nowSec >>> 0;
307
+ var incepSer = rrsig.inception >>> 0;
308
+ var expirSer = rrsig.expiration >>> 0;
309
+ if (((nowSer - incepSer) | 0) < 0) throw new DnssecError("dnssec/not-yet-valid", "dnssec.verifyRrset: RRSIG inception is in the future");
310
+ if (((expirSer - nowSer) | 0) < 0) throw new DnssecError("dnssec/expired", "dnssec.verifyRrset: RRSIG has expired");
300
311
 
301
312
  var klass = typeof opts.class === "number" ? opts.class : 1;
302
313
  var ownerWire = _canonicalName(opts.name);
@@ -78,7 +78,20 @@ function _encodeCommand(args) {
78
78
  // { type: "int", value, consumed } — integer (:42)
79
79
  // { type: "bulk", value, consumed } — bulk string buffer (or null)
80
80
  // { type: "array", value, consumed } — array of decoded items
81
- function _parseFrame(buf, offset) {
81
+ //
82
+ // RESP arrays nest (an array whose elements are arrays), so the decoder
83
+ // recurses. A hostile or compromised server can stream an arbitrarily deep
84
+ // nest of `*1\r\n` headers to overflow the V8 stack with an uncaught
85
+ // RangeError out of the socket 'data' handler — a crash. Cap the nesting
86
+ // well above any real reply (cluster-slots / XRANGE replies nest a handful
87
+ // deep) and throw a typed PROTOCOL error, which _onData turns into a socket
88
+ // teardown + reconnect instead of a process crash.
89
+ var MAX_RESP_DEPTH = 64;
90
+ function _parseFrame(buf, offset, depth) {
91
+ depth = depth || 0;
92
+ if (depth > MAX_RESP_DEPTH) {
93
+ throw _err("PROTOCOL", "reply nesting exceeds " + MAX_RESP_DEPTH + " levels");
94
+ }
82
95
  if (offset >= buf.length) return { type: "incomplete" };
83
96
  var marker = buf[offset];
84
97
  // Find next CRLF after the marker
@@ -121,7 +134,7 @@ function _parseFrame(buf, offset) {
121
134
  var items = [];
122
135
  var cursor = crlf + 2;
123
136
  for (var i = 0; i < arrLen; i++) {
124
- var sub = _parseFrame(buf, cursor);
137
+ var sub = _parseFrame(buf, cursor, depth + 1);
125
138
  if (sub.type === "incomplete") return { type: "incomplete" };
126
139
  items.push(sub);
127
140
  cursor += sub.consumed;
@@ -299,9 +312,19 @@ function create(opts) {
299
312
  function _onData(chunk) {
300
313
  rxBuffer = rxBuffer.length === 0 ? chunk : Buffer.concat([rxBuffer, chunk]);
301
314
  while (rxBuffer.length > 0) {
302
- var frame = _parseFrame(rxBuffer, 0);
303
- if (frame.type === "incomplete") return;
304
- var value = _frameToValue(frame);
315
+ var frame, value;
316
+ try {
317
+ frame = _parseFrame(rxBuffer, 0);
318
+ if (frame.type === "incomplete") return;
319
+ value = _frameToValue(frame);
320
+ } catch (parseErr) {
321
+ // A malformed or hostilely-nested RESP frame must not throw out of
322
+ // the socket 'data' handler and crash the host. Treat it as a fatal
323
+ // connection fault: reject in-flight commands and tear the socket
324
+ // down for a reconnect, the same as any other lost-socket path.
325
+ _teardownSocket(parseErr);
326
+ return;
327
+ }
305
328
  rxBuffer = rxBuffer.slice(frame.consumed);
306
329
 
307
330
  // Pub/sub push detection — server-initiated arrays beginning with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.64",
3
+ "version": "0.15.66",
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:f9782178-0584-49aa-8fd6-3029b774391a",
5
+ "serialNumber": "urn:uuid:38fe8005-1751-4103-b424-551b80f8f722",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-29T22:06:28.985Z",
8
+ "timestamp": "2026-06-30T00:16:16.691Z",
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.64",
22
+ "bom-ref": "@blamejs/core@0.15.66",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.64",
25
+ "version": "0.15.66",
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.64",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.66",
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.64",
57
+ "ref": "@blamejs/core@0.15.66",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]