@blamejs/core 0.15.37 → 0.15.38

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.38 (2026-06-27) — **Regex patterns supplied in feature-flag targeting rules and MCP tool input schemas are now screened for catastrophic-backtracking (ReDoS) shapes before compilation, so a pattern matched against request data can't pin a CPU.** Two places compiled a caller-supplied regex pattern and `.test()`'d it against request-controlled input with only a length bound as the stated defense: a feature-flag targeting condition (`op: "regex"`) matched against runtime attribute values, and an MCP tool's input-schema `pattern` matched against tool-call arguments. A length bound is not a ReDoS defense — a catastrophic-backtracking pattern such as `(a+)+$` is six characters and pins a CPU on a crafted input. Both patterns now pass through `b.guardRegex` (strict profile) before compilation, which refuses nested-quantifier, alternation-with-quantifier, and quantifier-inside-lookaround shapes. A ReDoS-shaped flag pattern is refused when the rules are validated; a ReDoS-shaped MCP schema pattern fails tool-input validation. Patterns built from the framework's own static tables, operator-owned JSON Schema patterns, the Sieve glob translator (which cannot express nested quantifiers), and the I-Regexp translator (linear by dialect) are unchanged. **Security:** *Feature-flag regex targeting conditions are screened for ReDoS before compilation* — A flag targeting rule with `op: "regex"` compiled the operator-supplied pattern and `.test()`'d it against runtime attribute values, guarded only by a 200-character length cap. Length does not bound catastrophic backtracking, so a pattern like `(a+)+$` combined with an attacker-controlled attribute value could pin a CPU during flag evaluation. The pattern is now screened through b.guardRegex (strict) when the rules are validated, and a catastrophic-backtracking shape is refused with a clear error. · *MCP tool input-schema patterns are screened for ReDoS before matching request input* — b.mcp.validateToolInput compiled a tool author's input-schema `pattern` and matched it against tool-call argument values; the 4096-character input cap does not bound backtracking (a `(a+)+$` pattern blows up at roughly forty input characters). The schema pattern is now screened through b.guardRegex (strict) before compilation, so a ReDoS-shaped pattern in a registered tool's schema fails input validation instead of letting hostile arguments hang the validator. · *b.guardRegex now catches wrapped nested-quantifier patterns* — The nested-quantifier detector matched a quantified group whose body contained a quantifier, but its inner match was paren-blind, so wrapping the inner quantifier in an extra group — `((a)+)+`, `(([a-z]+)*)*`, `((a+))+` — slipped past it while remaining catastrophic. A structural scan now tracks group nesting and refuses an unbounded-quantified group whose body itself contains an unbounded quantifier at any depth, so the wrapped forms are rejected alongside the bare `(a+)+`. Bounded repeats (`{n}`, `{n,m}`) are unaffected. This strengthens every b.guardRegex caller, including the flag-targeting and MCP screening above.
12
+
11
13
  - v0.15.37 (2026-06-27) — **Several numeric options that silently accepted a non-finite value — disabling a clock-skew / freshness check or a resource cap — now reject it, so an `Infinity` skew or limit can no longer turn off the protection it configures.** A number of configuration options validated a numeric value with a bare `typeof === "number" && value >= 0` check, which accepts `Infinity`. Where the value is a clock-skew tolerance or a resource cap, an `Infinity` (or `NaN`) silently disabled the very check it tunes: a CWT / OCSP-staple / ARC clock-skew of `Infinity` made the expiry, freshness, and expiration comparisons unsatisfiable (an expired token / a replayed pre-revocation "good" response / an expired ARC seal would be accepted); a WebSocket-client `maxMessageBytes` / `maxFrameBytes` / `handshakeTimeoutMs` of `Infinity` disabled the inbound-OOM and stalled-handshake defenses; and inbox / flag-cache / audit-chain size and count caps of `Infinity` admitted unbounded data. These options now route through the finite-bounds validator: a present non-finite value is refused at the entry point (or falls back to the safe default on the result-returning paths). Options where an unbounded value is a deliberate intent — reconnect "retry indefinitely", inbox "retain indefinitely" — continue to accept `Infinity`. **Security:** *A non-finite clock-skew no longer disables CWT / OCSP / ARC time checks* — b.cwt.verify, the OCSP-staple freshness check in b.network.tls, and b.mail.arc.verify each took an operator clock-skew tolerance validated as `typeof === "number" && >= 0`, which accepts `Infinity`. Because each check is of the form `now > deadline + skew`, a skew of `Infinity` made it unsatisfiable and silently turned the check off: an expired CWT verified, a stale (post-nextUpdate) OCSP "good" response — the exact reply an attacker replays after the certificate is revoked — was accepted, and an expired ARC seal passed. A present clock-skew that is not a non-negative finite integer is now refused (b.cwt.verify throws cwt/bad-clock-skew; the OCSP and ARC paths fall back to their safe default). Regression tests assert an expired token / stale staple / expired ARC seal is still rejected when the skew is `Infinity`. · *WebSocket-client inbound caps can no longer be disabled by an Infinity value* — b.wsClient.connect validated maxMessageBytes, maxFrameBytes, and handshakeTimeoutMs (and the ping/pong keepalive intervals) with a bare numeric check that accepted `Infinity`, which disabled the inbound-message and frame size limits — the defenses against a malicious server sending an unbounded message — and the handshake timeout. A present non-finite value for these is now refused at connect time. The reconnect maxAttempts still accepts `Infinity` (a deliberate "reconnect indefinitely" intent). · *Inbox, flag-cache, and audit-chain caps reject a non-finite limit* — The inbox maxPayloadBytes / messageIdMaxLen / sourceMaxLen caps, the flag-cache ttlMs / maxEntries, and the audit-chain partition fan-out cap each accepted `Infinity`, disabling the cap (unbounded stored payloads, a never-expiring or unbounded cache, unbounded fan-out). These now require a positive finite integer — refused at create time, or clamped to the bounded default on the result-returning verify path. The inbox retentionDays still accepts `Infinity` (retain indefinitely).
12
14
 
13
15
  - v0.15.36 (2026-06-27) — **Internal test-suite hardening only — the published library's runtime behavior and public API are unchanged (source-comment marker text aside).** The codebase-patterns guard class that allows a bare comma/semicolon split on token-only RFC header grammars was re-verified from scratch and renamed to a descriptive token, with its old name recorded as retired. Each live marked site (RRULE, RFC 9421 component identifiers, TLS-RPT rua, SCIM attribute paths) was re-read and confirmed to split a grammar with no quoted-string members. Five marker comments that suppressed nothing were removed or turned into plain explanatory comments. No runtime code, public API, or wire format changed. **Detectors:** *Bare token-only header-split suppression class re-verified, renamed, and pruned of inert markers* — Each marked bare `.split(",")` / `.split(";")` on an RFC header value was re-read and confirmed to operate on a token-only grammar (no quoted-string members, so a quote-aware splitter is unnecessary): RFC 5545 RRULE, RFC 9421 signature component identifiers, RFC 8460 TLS-RPT rua, and RFC 7644 SCIM attribute paths. The guard class was renamed to a descriptive token and its old name added to the retired-token set. Five marker comments that the detector never actually evaluated (two sat on a date-normalizing `.replace`, three in header parsers the guard intentionally does not scan) were removed or converted to plain comments. This is test-suite tooling plus source-comment text; no shipped framework behavior changed.
@@ -27,6 +27,8 @@
27
27
  */
28
28
 
29
29
  var validateOpts = require("./validate-opts");
30
+ var lazyRequire = require("./lazy-require");
31
+ var guardRegex = lazyRequire(function () { return require("./guard-regex"); });
30
32
  var { defineClass } = require("./framework-error");
31
33
  var FlagError = defineClass("FlagError", { alwaysPermanent: true });
32
34
 
@@ -171,8 +173,19 @@ function validateRules(rules, label) {
171
173
  throw new FlagError("flag/bad-condition",
172
174
  clabel + ".value: regex pattern must be <= 200 chars (DoS defense)");
173
175
  }
176
+ // The compiled regex is .test()'d against runtime attribute values, so a
177
+ // catastrophic-backtracking (ReDoS) pattern is a DoS vector. A length
178
+ // bound does NOT defend ReDoS (`(a+)+$` is 6 chars); screen the pattern
179
+ // through b.guardRegex first — it refuses nested-quantifier / alternation-
180
+ // quantifier / lookaround-quantifier shapes before compilation.
174
181
  try {
175
- // allow:dynamic-regex — operator-supplied targeting pattern, length-bounded to 200 chars above
182
+ guardRegex().sanitize(cond.value, { profile: "strict" });
183
+ } catch (ge) {
184
+ throw new FlagError("flag/bad-condition",
185
+ clabel + ".value: regex pattern rejected as unsafe (ReDoS shape) - " + ge.message);
186
+ }
187
+ try {
188
+ // allow:dynamic-regex — operator targeting pattern, ReDoS-screened via guardRegex.sanitize (strict) + length-bounded to 200 chars above
176
189
  validatedCond._compiledRegex = new RegExp(cond.value);
177
190
  } catch (e) {
178
191
  throw new FlagError("flag/bad-condition",
@@ -128,19 +128,68 @@ var DEFAULTS = gateContract.strictDefaults(PROFILES);
128
128
 
129
129
  var COMPLIANCE_POSTURES = gateContract.compliancePostures(PROFILES, { base: 256 });
130
130
 
131
+ // Structural nested-unbounded-quantifier detector. NESTED_QUANT_RE is paren-
132
+ // blind (its `[^()]*` can't span a nested group), so it misses WRAPPED forms
133
+ // like `((a)+)+` / `(([a-z]+)*)*` / `((a+))+` — adding one extra group around
134
+ // the inner quantifier bypasses the regex while the pattern stays catastrophic.
135
+ // This linear scan tracks group nesting and flags an unbounded-quantified group
136
+ // (`)+`, `)*`, `){n,}`) whose body itself contains an unbounded quantifier — the
137
+ // two-nested-unbounded-quantifier ReDoS class — at any group depth. Bounded
138
+ // repeats (`{n}`, `{n,m}`, `?`) are not unbounded, so they don't trip it (the
139
+ // large-bound case is handled separately by maxBoundedRepeat).
140
+ function _hasNestedQuantifier(src) {
141
+ var stack = []; // open groups: each { quant } — body has an unbounded quantifier
142
+ var inClass = false; // inside a [...] character class
143
+ var i = 0;
144
+ var n = src.length;
145
+ var UNBOUNDED_AFTER_GROUP = /^(?:[*+]\??|\{\d*,\})/; // )+ )* )+? )*? ){n,}
146
+ while (i < n) {
147
+ var c = src.charAt(i);
148
+ if (c === "\\") { i += 2; continue; } // escaped atom — skip both chars
149
+ if (inClass) { if (c === "]") inClass = false; i += 1; continue; }
150
+ if (c === "[") { inClass = true; i += 1; continue; }
151
+ if (c === "(") { stack.push({ quant: false }); i += 1; continue; }
152
+ if (c === ")") {
153
+ var grp = stack.pop() || { quant: false };
154
+ var qm = UNBOUNDED_AFTER_GROUP.exec(src.slice(i + 1)); // allow:regex-no-length-cap — bounded slice of a maxPatternBytes-capped input
155
+ var closeUnbounded = qm !== null;
156
+ if (grp.quant && closeUnbounded) return true; // nested unbounded quantifier → catastrophic
157
+ // The closing group contributes an unbounded quantifier to its PARENT's
158
+ // body if its own body had one, or if it is itself unbounded-quantified.
159
+ if (stack.length && (grp.quant || closeUnbounded)) stack[stack.length - 1].quant = true;
160
+ i += 1 + (qm ? qm[0].length : 0);
161
+ continue;
162
+ }
163
+ if (c === "*" || c === "+") { // unbounded quantifier on the preceding atom
164
+ if (stack.length) stack[stack.length - 1].quant = true;
165
+ i += 1; continue;
166
+ }
167
+ if (c === "{") {
168
+ var open = /^\{\d*,\}/.exec(src.slice(i)); // allow:regex-no-length-cap — bounded slice // {n,} unbounded
169
+ if (open) { if (stack.length) stack[stack.length - 1].quant = true; i += open[0].length; continue; }
170
+ var bounded = /^\{\d+(?:,\d+)?\}/.exec(src.slice(i)); // allow:regex-no-length-cap — bounded slice // {n} / {n,m} bounded
171
+ if (bounded) { i += bounded[0].length; continue; }
172
+ i += 1; continue; // literal `{`
173
+ }
174
+ i += 1;
175
+ }
176
+ return false;
177
+ }
178
+
131
179
 
132
180
  function _detectIssues(input, opts) {
133
181
  var pre = gateContract.detectStringInput(input, opts, { name: "regex", noun: "regex pattern", cap: { bytes: opts.maxPatternBytes, kind: "pattern-cap", snippet: "regex pattern exceeds maxPatternBytes " + opts.maxPatternBytes } });
134
182
  if (pre.done) return pre.issues;
135
183
  var issues = pre.issues;
136
184
 
137
- if (opts.nestedQuantPolicy !== "allow" && NESTED_QUANT_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxPatternBytes
185
+ if (opts.nestedQuantPolicy !== "allow" &&
186
+ (NESTED_QUANT_RE.test(input) || _hasNestedQuantifier(input))) { // allow:regex-no-length-cap — input bounded by maxPatternBytes
138
187
  issues.push({
139
188
  kind: "nested-quantifier", severity: "critical",
140
189
  ruleId: "regex.nested-quantifier",
141
190
  snippet: "pattern contains nested-quantifier shape (e.g. " +
142
- "`(a+)+`) — canonical ReDoS catastrophic-backtracking " +
143
- "class (CVE-2024-21538 cross-spawn / CVE-2022-25929)",
191
+ "`(a+)+` / `((a)+)+`) — canonical ReDoS catastrophic-" +
192
+ "backtracking class (CVE-2024-21538 cross-spawn / CVE-2022-25929)",
144
193
  });
145
194
  }
146
195
 
package/lib/mcp.js CHANGED
@@ -37,6 +37,8 @@ var safeJson = require("./safe-json");
37
37
  var safeBuffer = require("./safe-buffer");
38
38
  var requestHelpers = require("./request-helpers");
39
39
  var audit = require("./audit");
40
+ var lazyRequire = require("./lazy-require");
41
+ var guardRegex = lazyRequire(function () { return require("./guard-regex"); });
40
42
  var { McpError } = require("./framework-error");
41
43
 
42
44
  var TOOL_NAME_MAX = 64; // string-length cap, not bytes
@@ -642,8 +644,14 @@ function _validateValueAgainstSchema(value, schema, path) {
642
644
  // cost scales with the number of code units the engine scans, so 4096
643
645
  // chars is the correct ReDoS bound regardless of UTF-8 byte size.
644
646
  if (value.length > 4096) return path + ": value exceeds 4096-char cap before regex test"; // ReDoS char cap (not bytes)
647
+ // The input-length cap above does NOT bound catastrophic backtracking
648
+ // (a `(a+)+$` pattern blows up at ~40 input chars). Screen the tool
649
+ // author's pattern through b.guardRegex so a ReDoS-shaped schema pattern
650
+ // can't pin a CPU when matched against request input.
651
+ try { guardRegex().sanitize(schema.pattern, { profile: "strict" }); }
652
+ catch (_ge) { return path + ": schema pattern rejected as unsafe (ReDoS shape)"; }
645
653
  try {
646
- var pat = new RegExp(schema.pattern); // allow:dynamic-regex — schema.pattern from registered tool author, not request input; bounded above
654
+ var pat = new RegExp(schema.pattern); // allow:dynamic-regex — schema.pattern is ReDoS-screened via guardRegex.sanitize (strict) above + input length-capped
647
655
  if (!pat.test(value)) return path + ": does not match pattern";
648
656
  }
649
657
  catch (_e) { return path + ": invalid pattern in schema"; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.37",
3
+ "version": "0.15.38",
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:a1be0521-cb1d-4b8e-9ed6-b0053b5b9bfd",
5
+ "serialNumber": "urn:uuid:742c0889-e56f-4bee-8220-91a6cac8307b",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-27T09:36:13.300Z",
8
+ "timestamp": "2026-06-27T16:52:08.760Z",
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.37",
22
+ "bom-ref": "@blamejs/core@0.15.38",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.37",
25
+ "version": "0.15.38",
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.37",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.38",
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.37",
57
+ "ref": "@blamejs/core@0.15.38",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]