@blamejs/core 0.15.65 → 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,8 @@ 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
+
11
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).
12
14
 
13
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.
@@ -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.65",
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:107d84f4-f93e-4798-846a-d948f93d854d",
5
+ "serialNumber": "urn:uuid:38fe8005-1751-4103-b424-551b80f8f722",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-29T23:25:12.730Z",
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.65",
22
+ "bom-ref": "@blamejs/core@0.15.66",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.65",
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.65",
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.65",
57
+ "ref": "@blamejs/core@0.15.66",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]