@blamejs/blamejs-shop 0.5.3 → 0.5.5

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.
@@ -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
 
@@ -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"; }
@@ -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",
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.15.38",
4
+ "date": "2026-06-27",
5
+ "headline": "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",
6
+ "summary": "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.",
7
+ "sections": [
8
+ {
9
+ "heading": "Security",
10
+ "items": [
11
+ {
12
+ "title": "Feature-flag regex targeting conditions are screened for ReDoS before compilation",
13
+ "body": "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."
14
+ },
15
+ {
16
+ "title": "MCP tool input-schema patterns are screened for ReDoS before matching request input",
17
+ "body": "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."
18
+ },
19
+ {
20
+ "title": "b.guardRegex now catches wrapped nested-quantifier patterns",
21
+ "body": "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."
22
+ }
23
+ ]
24
+ }
25
+ ]
26
+ }
@@ -240,6 +240,30 @@ function run() {
240
240
  },
241
241
  /invalid regex/);
242
242
 
243
+ // ReDoS-shaped pattern (nested quantifier) refused. The compiled regex is
244
+ // .test()'d against runtime (request) attribute values, so a catastrophic-
245
+ // backtracking pattern is a DoS vector; the 200-char length bound is NOT a
246
+ // ReDoS defense (`(a+)+$` is 6 chars). The pattern is screened through
247
+ // b.guardRegex before compilation.
248
+ rejects("targeting: ReDoS-shaped regex (nested quantifier) refused",
249
+ function () {
250
+ t.validateRules([{ variant: "v", conditions: [{ attribute: "x", op: "regex", value: "(a+)+$" }] }]);
251
+ },
252
+ /unsafe|ReDoS|backtrack|quantifier/i);
253
+ // Wrapping the inner quantified atom in an extra group — `((a)+)+$` — is the
254
+ // same catastrophic-backtracking class; a paren-blind `[^()]*` detector misses
255
+ // it. The structural screener must still refuse these nested-group forms.
256
+ rejects("targeting: wrapped nested-quantifier regex refused",
257
+ function () {
258
+ t.validateRules([{ variant: "v", conditions: [{ attribute: "x", op: "regex", value: "((a)+)+$" }] }]);
259
+ },
260
+ /unsafe|ReDoS|backtrack|quantifier/i);
261
+ rejects("targeting: deeper wrapped nested-quantifier regex refused",
262
+ function () {
263
+ t.validateRules([{ variant: "v", conditions: [{ attribute: "x", op: "regex", value: "(([a-z]+)*)*$" }] }]);
264
+ },
265
+ /unsafe|ReDoS|backtrack|quantifier/i);
266
+
243
267
  // nested attribute path
244
268
  var pathRules = t.validateRules([
245
269
  { variant: "v1", conditions: [{ attribute: "user.role", op: "eq", value: "admin" }] },
@@ -122,6 +122,21 @@ async function run() {
122
122
  } catch (e) { threw = /tool-input-invalid/.test(e.code); }
123
123
  check("mcp.validateToolInput: schema mismatch refused", threw);
124
124
 
125
+ // A tool-author schema pattern with a catastrophic-backtracking (ReDoS) shape
126
+ // is compiled and .test()'d against request input — the 4096-char input cap
127
+ // does not bound backtracking. The pattern is screened through b.guardRegex
128
+ // and a ReDoS shape is refused. (`"aaa"` matches `(a+)+$` instantly, so the
129
+ // pre-fix path does not hang.)
130
+ threw = false; var redosMsg = "";
131
+ try {
132
+ b.mcp.validateToolInput("t", { x: "aaa" }, {
133
+ type: "object",
134
+ properties: { x: { type: "string", pattern: "(a+)+$" } },
135
+ required: ["x"],
136
+ });
137
+ } catch (e) { threw = true; redosMsg = e.message || ""; }
138
+ check("mcp.validateToolInput: ReDoS-shaped schema pattern refused", threw && /unsafe|ReDoS/i.test(redosMsg));
139
+
125
140
  // ---- v0.8.77: assertProtocolVersion / sampling / elicitation ----
126
141
  threw = false;
127
142
  try { b.mcp.assertProtocolVersion({ headers: {} }); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {