@blamejs/core 0.10.1 → 0.10.2

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,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.10.x
10
10
 
11
+ - v0.10.2 (2026-05-16) — **CVE backstops layered on top of v0.10.0.** Five additional refusals across `b.guardRegex`, `b.otelExport`, `b.guardXml`, `b.guardGraphql`, plus a host-side ingress route for `b.cli`. Every change is opt-out (refusal at every profile); no API removals. **(a) `b.guardRegex` glob-shape detectors with explicit `inputKind` gate** — new `consecutiveStarPolicy` + `nestedExtglobPolicy` (defaults `"reject"`) + `maxConsecutiveStars` (default 2) + `inputKind: "regex" | "glob"` (default `"regex"`). The glob-shape detectors fire ONLY when the caller passes `inputKind: "glob"` — ECMAScript regex syntax cannot produce `***` (SyntaxError) and the extglob heads `*(`/`+(`/`?(`/`@(`/`!(` collide with valid `quantifier + capturing group` shapes, so applying these detectors to regex inputs is false-positive territory. Callers handling glob fragments (picomatch / micromatch-style patterns) opt in via `inputKind: "glob"` and get refusals for ≥3 consecutive `*` metacharacters ([CVE-2026-26996](https://nvd.nist.gov/vuln/detail/CVE-2026-26996) — O(4^N) backtracking on non-matching literal) and for any extglob whose body contains another extglob ([CVE-2026-33671](https://nvd.nist.gov/vuln/detail/CVE-2026-33671) — picomatch nested-quantifier backtracking). `**` recursive-glob stays permitted under `maxConsecutiveStars: 2`. **(b) `b.cli --ignore` ReDoS ingress closure** — `cli --ignore <pattern>` arguments route through `b.guardRegex.sanitize({ profile: "strict" })` before reaching `new RegExp(pattern)`. Strict-profile refusal of nested-quantifier / lookaround-quantifier / unbounded-bounded-repeat shapes still applies in default `inputKind: "regex"` mode, closing the host-side surface for the classic ReDoS classes. **(c) `b.otelExport.flush()` response cap** — every outbound OTLP request now pins `maxResponseBytes: 1 MiB` + a typed `errorClass`, so a malicious / misconfigured collector cannot exhaust memory in the export loop ([CVE-2026-40891](https://nvd.nist.gov/vuln/detail/CVE-2026-40891) / [CVE-2026-40182](https://nvd.nist.gov/vuln/detail/CVE-2026-40182) class). **(d) `b.guardXml` numeric-character-reference fan-out cap** — new `maxNumericCharRefs` opt (strict 1024 / balanced 16384 / permissive 262144). NCRs are counted independently of `entityPolicy`, so a signed-XML path that legitimately permits entity expansion cannot accidentally disable the NCR cap ([CVE-2026-26278](https://nvd.nist.gov/vuln/detail/CVE-2026-26278) / [CVE-2026-33036](https://nvd.nist.gov/vuln/detail/CVE-2026-33036) — billion-NCR fan-out class). **(e) `b.guardGraphql` prototype-pollution refusal** — refuses `__proto__` / `constructor` / `prototype` as top-level variable keys (`Object.prototype.hasOwnProperty.call(variables, ...)` check, sidesteps a poisoned-prototype `in` lookup) AND as field / alias / `$variable` identifiers in the query body, including the no-whitespace alias form `query { a:__proto__ }` (the colon is a valid identifier-position prefix). Refused at every profile, severity `critical` ([CVE-2026-32621](https://nvd.nist.gov/vuln/detail/CVE-2026-32621) class). **(f) `b.auth.sdJwtVc.present()` defense-in-depth comment** — documents that the holder-side pre-parse of `_sd_alg` reads from unsigned bytes safely because `verify()` re-parses from the cryptographically-verified signing input; no behavioral change. **Regression coverage** — `test/fixtures/exploit-corpus/corpus.json` gains four entries: glob-mode positive refusal for `***+nonmatch` and `*(*(a))`, regex-mode pass for `a*(b+(c))` (false-positive class the design refused to ship), and the colon-prefix GraphQL alias `query { a:__proto__ }`. **Operator impact:** existing operators see no change in default behavior — the new glob detectors are opt-in via `inputKind: "glob"`. Operators wiring `b.guardRegex` over glob fragments (file-pattern allowlists, rsync-style rules) opt in and get the CVE-2026-26996 / -33671 refusals; opt back out per call via `consecutiveStarPolicy: "allow"` / `nestedExtglobPolicy: "allow"`. `b.guardXml` operators on signed-XML pipelines opt out via `maxNumericCharRefs: Infinity` if they bound NCRs upstream. GraphQL variable / query-body refusals are not opt-out — `__proto__` / `constructor` / `prototype` are never legitimate identifiers in operator-supplied input. References: [picomatch CVE-2024-4067 family](https://nvd.nist.gov/vuln/detail/CVE-2024-4067), [OWASP ReDoS](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS), [OWASP XXE / Billion Laughs](https://owasp.org/www-community/vulnerabilities/XML_Entity_Expansion), [GraphQL Server Security Best Practices](https://www.apollographql.com/docs/router/configuration/overview/).
11
12
  - v0.10.1 (2026-05-16) — **First npm-published v0.10.x artifact.** v0.10.0 was tagged + released on GitHub but its npm-publish workflow OOM'd at the lint+smoke gate (default Node ~4GB heap couldn't load the expanded mail-stack + audit-fix test surface). v0.10.1 adds `NODE_OPTIONS=--max-old-space-size=8192` to the workflow's smoke step so the parent process gets the same headroom the forked test workers already get. No runtime / API changes from v0.10.0 — every primitive, posture, and security default ships exactly as documented in the v0.10.0 release notes below. Operators who fetched the v0.10.0 git tag can re-tag from v0.10.1 (`git fetch origin v0.10.1`) to land on the npm-published commit; the framework code itself is byte-identical apart from the workflow file + version bump.
12
13
  - v0.10.0 (2026-05-16) — **Mail-stack feature-complete + cross-surface hardening.** Bundled minor closing the blamepost mail-stack roadmap (five new operator-facing namespaces) plus a multi-domain hardening sweep across auth / crypto / vendor data / mail-protocol / mail-auth / agent substrate / Node.js CVE backstops.
13
14
 
@@ -301,6 +301,17 @@ function present(opts) {
301
301
  // Hardcoded sha256 here previously diverged from the verifier when
302
302
  // an issuer used a non-default hash, producing sd-hash-mismatch on
303
303
  // valid presentations.
304
+ //
305
+ // Defense-in-depth: this pre-parse runs on the holder side
306
+ // (presentation builder) and reads `_sd_alg` from UNSIGNED bytes,
307
+ // because the holder needs to know which hash to use BEFORE the
308
+ // verifier sees the presentation. The presentation itself carries
309
+ // the JWS-signed issuer JWT verbatim; verify() re-parses the
310
+ // payload from the cryptographically-verified signing input. That
311
+ // post-verify decode is the source of truth — a holder who tampers
312
+ // with `_sd_alg` here only breaks their own KB-JWT digest, since
313
+ // the verifier recomputes from the signed bytes. No security
314
+ // boundary is crossed by reading the value here.
304
315
  var _issuerPayload = null;
305
316
  var _jwtParts = jwt.split(".");
306
317
  if (_jwtParts.length === 3) {
package/lib/cli.js CHANGED
@@ -48,6 +48,7 @@ var C = require("./constants");
48
48
  var bCrypto = require("./crypto");
49
49
  var dev = require("./dev");
50
50
  var fileType = require("./file-type");
51
+ var guardRegex = require("./guard-regex");
51
52
  var migrations = require("./migrations");
52
53
  var passwordModule = require("./auth/password");
53
54
  var requestHelpers = require("./request-helpers");
@@ -357,6 +358,18 @@ async function _runDev(args, ctx) {
357
358
  "blamejs dev: --ignore pattern exceeds max length " +
358
359
  MAX_IGNORE_PATTERN_LENGTH + " (got " + str.length + ")");
359
360
  }
361
+ // ReDoS / catastrophic-backtracking defense — refuses nested-quant
362
+ // (CVE-2024-21538 class), consecutive-* (CVE-2026-26996), nested
363
+ // extglob (CVE-2026-33671), and lookaround-quant shapes before the
364
+ // pattern reaches RegExp(). Operator typo / hostile-input identical
365
+ // shape from here on — both want the same refusal.
366
+ try {
367
+ guardRegex.sanitize(str, { profile: "strict" });
368
+ } catch (e) {
369
+ throw new CliError("cli/bad-ignore-pattern",
370
+ "blamejs dev: --ignore pattern refused by guardRegex: " +
371
+ ((e && e.message) || String(e)));
372
+ }
360
373
  return RegExp(str);
361
374
  });
362
375
  var graceMs = args.flags["grace-ms"] !== undefined ? Number(args.flags["grace-ms"]) : undefined;
@@ -89,6 +89,15 @@ void observability;
89
89
 
90
90
  var _err = GuardGraphqlError.factory;
91
91
 
92
+ // Query-body proto-poison literal (CVE-2026-32621). Matches the bare
93
+ // identifier in field / alias / variable-declaration positions —
94
+ // `$__proto__: String`, `__proto__: realField`, `__proto__ { ... }`,
95
+ // and the no-whitespace alias form `query { a:__proto__ }` /
96
+ // `query { a:constructor }` (GraphQL parsers accept the colon with
97
+ // or without trailing whitespace, so `:` is a valid identifier-
98
+ // position prefix that must also trigger refusal).
99
+ var PROTO_POISON_QUERY_RE = /[\s,({:]\$?(?:__proto__|constructor|prototype)\b/;
100
+
92
101
  // ---- Profile presets ----
93
102
 
94
103
  var PROFILES = Object.freeze({
@@ -319,6 +328,34 @@ function _detectIssues(req, opts) {
319
328
  } catch (_e) { /* unstringifiable variables */ }
320
329
  }
321
330
 
331
+ // Prototype-pollution defense (CVE-2026-32621). A `__proto__` /
332
+ // `constructor` / `prototype` variable key OR query-body identifier
333
+ // pivots a downstream deep-merge / deep-set into a poisoned shape.
334
+ // Refused at every profile.
335
+ var pVar = req.variables;
336
+ var pHas = Object.prototype.hasOwnProperty;
337
+ var pName = (pVar && typeof pVar === "object" && !Array.isArray(pVar) &&
338
+ (pHas.call(pVar, "__proto__") ? "__proto__" :
339
+ pHas.call(pVar, "constructor") ? "constructor" :
340
+ pHas.call(pVar, "prototype") ? "prototype" : null));
341
+ if (pName) {
342
+ issues.push({
343
+ kind: "variable-prototype-poison", severity: "critical",
344
+ ruleId: "graphql.variable-prototype-poison",
345
+ snippet: "variable name `" + pName + "` — prototype-pollution " +
346
+ "gadget (CVE-2026-32621)",
347
+ });
348
+ }
349
+ if (PROTO_POISON_QUERY_RE.test(req.query)) { // allow:regex-no-length-cap — input bounded by maxQueryBytes above
350
+ issues.push({
351
+ kind: "query-prototype-poison", severity: "critical",
352
+ ruleId: "graphql.query-prototype-poison",
353
+ snippet: "query references `__proto__` / `constructor` / " +
354
+ "`prototype` as a field / alias / variable — prototype-" +
355
+ "pollution gadget (CVE-2026-32621)",
356
+ });
357
+ }
358
+
322
359
  // Introspection.
323
360
  if (opts.introspectionPolicy !== "allow") {
324
361
  if (req.query.indexOf("__schema") !== -1 ||
@@ -70,6 +70,14 @@ var BOUNDED_REPEAT_RE = /\{(\d+)(?:,(\d*))?\}/g;
70
70
  // Lookaround with internal quantifier — `(?=.*+)`, `(?!a*)`.
71
71
  var LOOKAROUND_QUANT_RE = /\(\?[=!<][^()]*[*+]/;
72
72
 
73
+ // Nested extglob detector — picomatch `*(...)` / `+(...)` / `?(...)` /
74
+ // `@(...)` / `!(...)` containing another extglob inside (CVE-2026-33671
75
+ // nested-extglob catastrophic-backtracking class). Two extglob heads in
76
+ // the same pattern with no closing paren between them indicates nesting.
77
+ // The consecutive-star detector (CVE-2026-26996) walks the input by
78
+ // char so doesn't need a regex literal.
79
+ var EXTGLOB_HEAD_RE = /[*+?@!]\(/g; // allow:regex-no-length-cap — input bounded by maxPatternBytes
80
+
73
81
  // ---- Profile presets ----
74
82
 
75
83
  var PROFILES = Object.freeze({
@@ -82,7 +90,11 @@ var PROFILES = Object.freeze({
82
90
  alternationQuantPolicy: "reject",
83
91
  boundedRepeatPolicy: "reject",
84
92
  lookaroundQuantPolicy: "reject",
93
+ consecutiveStarPolicy: "reject",
94
+ nestedExtglobPolicy: "reject",
95
+ inputKind: "regex", // CVE-2026-26996 + CVE-2026-33671 detectors apply only when inputKind=="glob"
85
96
  maxBoundedRepeat: 100, // allow:raw-byte-literal — bounded repeat ceiling
97
+ maxConsecutiveStars: 2, // allow:raw-byte-literal — `**` recursive glob permitted; >=3 refused
86
98
  maxPatternBytes: C.BYTES.kib(1),
87
99
  maxBytes: C.BYTES.kib(1),
88
100
  maxRuntimeMs: C.TIME.seconds(2),
@@ -96,7 +108,10 @@ var PROFILES = Object.freeze({
96
108
  alternationQuantPolicy: "audit",
97
109
  boundedRepeatPolicy: "audit",
98
110
  lookaroundQuantPolicy: "audit",
111
+ consecutiveStarPolicy: "reject", // CVE-2026-26996 refused at every profile
112
+ nestedExtglobPolicy: "reject", // CVE-2026-33671 refused at every profile
99
113
  maxBoundedRepeat: 1000, // allow:raw-byte-literal — bounded repeat ceiling
114
+ maxConsecutiveStars: 2, // allow:raw-byte-literal — `**` recursive glob permitted; >=3 refused
100
115
  maxPatternBytes: C.BYTES.kib(2),
101
116
  maxBytes: C.BYTES.kib(2),
102
117
  maxRuntimeMs: C.TIME.seconds(2),
@@ -110,7 +125,10 @@ var PROFILES = Object.freeze({
110
125
  alternationQuantPolicy: "allow",
111
126
  boundedRepeatPolicy: "audit",
112
127
  lookaroundQuantPolicy: "audit",
128
+ consecutiveStarPolicy: "reject", // CVE-2026-26996 refused at every profile
129
+ nestedExtglobPolicy: "reject", // CVE-2026-33671 refused at every profile
113
130
  maxBoundedRepeat: 10000, // allow:raw-byte-literal — bounded repeat ceiling
131
+ maxConsecutiveStars: 2, // allow:raw-byte-literal — `**` recursive glob permitted; >=3 refused
114
132
  maxPatternBytes: C.BYTES.kib(8),
115
133
  maxBytes: C.BYTES.kib(8),
116
134
  maxRuntimeMs: C.TIME.seconds(2),
@@ -223,9 +241,116 @@ function _detectIssues(input, opts) {
223
241
  }
224
242
  }
225
243
 
244
+ _detectConsecutiveStar(input, opts, issues);
245
+ _detectNestedExtglob(input, opts, issues);
246
+
226
247
  return issues;
227
248
  }
228
249
 
250
+ // Consecutive-star wildcard cap (CVE-2026-26996). Operator-supplied
251
+ // glob fragments compile to picomatch / RegExp; a long run of `*`
252
+ // against a non-matching literal walks O(4^N). Three-or-more
253
+ // consecutive `*` is the canonical bad shape; `**` (recursive glob)
254
+ // stays permitted, gated by the profile's `maxConsecutiveStars`.
255
+ function _detectConsecutiveStar(input, opts, issues) {
256
+ if (opts.consecutiveStarPolicy === "allow") return;
257
+ // CVE-2026-26996 is a picomatch / glob-shape backtracking class —
258
+ // `***+literal` walks O(4^N) when picomatch translates the run to a
259
+ // backtracking-heavy regex. Native ECMAScript regex syntax cannot
260
+ // produce three consecutive `*` quantifiers (it's a SyntaxError),
261
+ // so applying this detector to `inputKind: "regex"` strings only
262
+ // produces false positives on legitimate regex shapes like
263
+ // `a*(b)*` where `*(` is quantifier+group, not extglob.
264
+ if (opts.inputKind !== "glob") return;
265
+ var starRun = 0;
266
+ var starRunMax = 0;
267
+ for (var si = 0; si < input.length; si += 1) {
268
+ if (input.charAt(si) === "*") {
269
+ starRun += 1;
270
+ if (starRun > starRunMax) starRunMax = starRun;
271
+ } else {
272
+ starRun = 0;
273
+ }
274
+ }
275
+ var starCeiling = opts.maxConsecutiveStars === undefined ?
276
+ 2 : opts.maxConsecutiveStars; // allow:raw-byte-literal — `**` glob ceiling
277
+ if (starRunMax > starCeiling) {
278
+ issues.push({
279
+ kind: "consecutive-star",
280
+ severity: opts.consecutiveStarPolicy === "reject" ? "critical" : "high",
281
+ ruleId: "regex.consecutive-star",
282
+ snippet: "pattern has " + starRunMax + " consecutive `*` " +
283
+ "wildcards (cap " + starCeiling + ") — O(4^N) " +
284
+ "backtracking on non-matching literal (CVE-2026-26996)",
285
+ });
286
+ }
287
+ }
288
+
289
+ // Nested-extglob detector (CVE-2026-33671). picomatch `*(...)` /
290
+ // `+(...)` / `?(...)` / `@(...)` / `!(...)` containing another
291
+ // extglob inside compiles to catastrophic-backtracking regex.
292
+ function _detectNestedExtglob(input, opts, issues) {
293
+ if (opts.nestedExtglobPolicy === "allow") return;
294
+ // CVE-2026-33671 is picomatch-specific: the extglob heads `*(`/
295
+ // `+(`/`?(`/`@(`/`!(` collide with valid ECMAScript regex shapes
296
+ // (quantifier + capturing group). Restricting this detector to
297
+ // `inputKind: "glob"` avoids false-positive refusal of regex
298
+ // patterns like `a*(b+(c))` where the heads are quantifier
299
+ // groupings, not extglob.
300
+ if (opts.inputKind !== "glob") return;
301
+ // Collect extglob head positions via match() — read-only scan.
302
+ var heads = [];
303
+ var allHeads = input.match(EXTGLOB_HEAD_RE); // allow:regex-no-length-cap — input bounded by maxPatternBytes
304
+ if (allHeads === null || allHeads.length < 2) return;
305
+ // Locate each head index manually (match returns substrings, not idx).
306
+ var scanFrom = 0;
307
+ for (var hh = 0; hh < allHeads.length; hh += 1) {
308
+ var ch0 = allHeads[hh].charAt(0);
309
+ var idx = scanFrom;
310
+ while (idx < input.length - 1) {
311
+ var c0 = input.charAt(idx);
312
+ var c1 = input.charAt(idx + 1);
313
+ if (c1 === "(" && c0 === ch0) break;
314
+ idx += 1;
315
+ }
316
+ heads.push(idx);
317
+ scanFrom = idx + 1;
318
+ if (heads.length > 1024) break; // allow:raw-byte-literal — head-count safety cap
319
+ }
320
+ var nested = false;
321
+ for (var hi = 0; hi < heads.length && !nested; hi += 1) {
322
+ var headStart = heads[hi];
323
+ // Walk forward tracking paren depth. Inner head before close = nested.
324
+ var pdepth = 1;
325
+ for (var pj = headStart + 2; pj < input.length && pdepth > 0; pj += 1) {
326
+ var ch = input.charAt(pj);
327
+ if (ch === "(") {
328
+ pdepth += 1;
329
+ if (pj > 0) {
330
+ var preVerb = input.charAt(pj - 1);
331
+ if (preVerb === "*" || preVerb === "+" || preVerb === "?" ||
332
+ preVerb === "@" || preVerb === "!") {
333
+ nested = true;
334
+ break;
335
+ }
336
+ }
337
+ } else if (ch === ")") {
338
+ pdepth -= 1;
339
+ }
340
+ }
341
+ }
342
+ if (nested) {
343
+ issues.push({
344
+ kind: "nested-extglob",
345
+ severity: opts.nestedExtglobPolicy === "reject" ? "critical" : "high",
346
+ ruleId: "regex.nested-extglob",
347
+ snippet: "pattern contains nested extglob quantifier " +
348
+ "(`*(...*(...))`) — catastrophic backtracking class " +
349
+ "(CVE-2026-33671 picomatch)",
350
+ });
351
+ }
352
+ }
353
+
229
354
  /**
230
355
  * @primitive b.guardRegex.validate
231
356
  * @signature b.guardRegex.validate(input, opts)
@@ -252,7 +377,11 @@ function _detectIssues(input, opts) {
252
377
  * alternationQuantPolicy: "reject"|"audit"|"allow",
253
378
  * boundedRepeatPolicy: "reject"|"audit"|"allow",
254
379
  * lookaroundQuantPolicy: "reject"|"audit"|"allow",
380
+ * consecutiveStarPolicy: "reject"|"audit"|"allow",
381
+ * nestedExtglobPolicy: "reject"|"audit"|"allow",
382
+ * inputKind: "regex"|"glob",
255
383
  * maxBoundedRepeat: number,
384
+ * maxConsecutiveStars: number,
256
385
  * maxPatternBytes: number,
257
386
  * maxBytes: number,
258
387
  * maxRuntimeMs: number,
@@ -268,7 +397,7 @@ function _detectIssues(input, opts) {
268
397
  function validate(input, opts) {
269
398
  opts = _resolveOpts(opts);
270
399
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
271
- ["maxBytes", "maxPatternBytes", "maxBoundedRepeat"],
400
+ ["maxBytes", "maxPatternBytes", "maxBoundedRepeat", "maxConsecutiveStars"],
272
401
  "guardRegex.validate", GuardRegexError, "regex.bad-opt");
273
402
  return gateContract.aggregateIssues(_detectIssues(input, opts));
274
403
  }
@@ -298,7 +427,11 @@ function validate(input, opts) {
298
427
  * alternationQuantPolicy: "reject"|"audit"|"allow",
299
428
  * boundedRepeatPolicy: "reject"|"audit"|"allow",
300
429
  * lookaroundQuantPolicy: "reject"|"audit"|"allow",
430
+ * consecutiveStarPolicy: "reject"|"audit"|"allow",
431
+ * nestedExtglobPolicy: "reject"|"audit"|"allow",
432
+ * inputKind: "regex"|"glob",
301
433
  * maxBoundedRepeat: number,
434
+ * maxConsecutiveStars: number,
302
435
  * maxPatternBytes: number,
303
436
  *
304
437
  * @example
@@ -350,7 +483,11 @@ function sanitize(input, opts) {
350
483
  * alternationQuantPolicy: "reject"|"audit"|"allow",
351
484
  * boundedRepeatPolicy: "reject"|"audit"|"allow",
352
485
  * lookaroundQuantPolicy: "reject"|"audit"|"allow",
486
+ * consecutiveStarPolicy: "reject"|"audit"|"allow",
487
+ * nestedExtglobPolicy: "reject"|"audit"|"allow",
488
+ * inputKind: "regex"|"glob",
353
489
  * maxBoundedRepeat: number,
490
+ * maxConsecutiveStars: number,
354
491
  * maxPatternBytes: number,
355
492
  *
356
493
  * @example
package/lib/guard-xml.js CHANGED
@@ -91,6 +91,16 @@ var PROCESSING_INSTR_RE = /<\?[A-Za-z][\w:-]*/;
91
91
  var CDATA_RE = /<!\[CDATA\[/;
92
92
  var XMLDSIG_RE = /<\w*:?Signature\b[^>]*xmldsig/i;
93
93
 
94
+ // Numeric character reference (NCR) detector. Per XML 1.0 §4.1 every
95
+ // `&#<digits>;` / `&#x<hex>;` is a character reference; a hostile input
96
+ // fanning these out in the hundreds of thousands bypasses entity-
97
+ // expansion caps that count only `&name;` general entities (CVE-2026-
98
+ // 26278 / CVE-2026-33036 .NET XmlReader class). Per-document NCR count
99
+ // is gated by `maxNumericCharRefs` independent of the entity-policy
100
+ // branch so the operator can't disable the cap by setting
101
+ // `entityPolicy: "allow"` for a downstream signed-XML case.
102
+ var NUMERIC_CHAR_REF_RE = /&#(?:[0-9]+|x[0-9a-fA-F]+);/g; // allow:regex-no-length-cap — input bounded by maxBytes above
103
+
94
104
  // ---- Profile presets ----
95
105
 
96
106
  var PROFILES = Object.freeze({
@@ -112,6 +122,7 @@ var PROFILES = Object.freeze({
112
122
  maxElements: 8192, // allow:raw-byte-literal — element count cap, not byte size
113
123
  maxAttrsPerElement: 64, // allow:raw-byte-literal — attr count, not byte size
114
124
  maxAttrValueBytes: C.BYTES.kib(8),
125
+ maxNumericCharRefs: 1024, // allow:raw-byte-literal — NCR fan-out cap (CVE-2026-26278)
115
126
  },
116
127
  "balanced": {
117
128
  doctypePolicy: "reject", // DOCTYPE is XXE vector regardless
@@ -131,6 +142,7 @@ var PROFILES = Object.freeze({
131
142
  maxElements: 65536, // allow:raw-byte-literal — element count cap, not byte size
132
143
  maxAttrsPerElement: 128, // allow:raw-byte-literal — attr count, not byte size
133
144
  maxAttrValueBytes: C.BYTES.kib(32),
145
+ maxNumericCharRefs: 16384, // allow:raw-byte-literal — NCR fan-out cap (CVE-2026-26278)
134
146
  },
135
147
  "permissive": {
136
148
  doctypePolicy: "reject", // billion-laughs class always
@@ -150,6 +162,7 @@ var PROFILES = Object.freeze({
150
162
  maxElements: 262144, // allow:raw-byte-literal — element count cap, not byte size
151
163
  maxAttrsPerElement: 256, // allow:raw-byte-literal — attr count, not byte size
152
164
  maxAttrValueBytes: C.BYTES.kib(64),
165
+ maxNumericCharRefs: 262144, // allow:raw-byte-literal — NCR fan-out cap (CVE-2026-26278)
153
166
  },
154
167
  });
155
168
 
@@ -282,6 +295,30 @@ function _detectIssues(input, opts) {
282
295
  });
283
296
  }
284
297
 
298
+ // 8a. Numeric character reference fan-out — `&#NNNN;` / `&#xHHHH;`.
299
+ // Bypasses the `<!ENTITY>`-counting expansion caps because NCRs are
300
+ // parser-resolved, not document-level entities (CVE-2026-26278 /
301
+ // CVE-2026-33036 .NET XmlReader class). Counted regardless of
302
+ // entityPolicy so signed-XML paths that need entities-allowed don't
303
+ // get the NCR cap disabled with them. The `maxNumericCharRefs` opt
304
+ // is validated by `numericBounds.requireAllPositiveFiniteIntIfPresent`
305
+ // at the public-surface boundary above.
306
+ var ncrCap = opts.maxNumericCharRefs; // allow:numeric-opt-no-bounds-check — validated at public boundary
307
+ if (ncrCap !== undefined && ncrCap !== null) {
308
+ var ncrMatches = input.match(NUMERIC_CHAR_REF_RE); // allow:regex-no-length-cap — input bounded by maxBytes above
309
+ var ncrCount = ncrMatches === null ? 0 : ncrMatches.length;
310
+ if (ncrCount > ncrCap) {
311
+ issues.push({
312
+ kind: "numeric-char-ref-cap", severity: "critical",
313
+ ruleId: "xml.numeric-char-ref-cap",
314
+ snippet: "numeric character reference count " + ncrCount +
315
+ " exceeds maxNumericCharRefs " + ncrCap +
316
+ " — NCR fan-out bypasses entity-expansion caps " +
317
+ "(CVE-2026-26278 / CVE-2026-33036)",
318
+ });
319
+ }
320
+ }
321
+
285
322
  // 9. Codepoint-class threats.
286
323
  issues.push.apply(issues, codepointClass.detectCharThreats(input, opts, "xml"));
287
324
 
@@ -369,6 +406,7 @@ function _detectIssues(input, opts) {
369
406
  * maxElements: number, // total open-tag count cap
370
407
  * maxAttrsPerElement: number, // attribute count cap per element
371
408
  * maxAttrValueBytes: number, // per-attr-value length cap
409
+ * maxNumericCharRefs: number, // numeric character reference cap
372
410
  *
373
411
  * @example
374
412
  * var hostile = '<?xml version="1.0"?>\n' +
@@ -381,7 +419,7 @@ function validate(input, opts) {
381
419
  opts = _resolveOpts(opts);
382
420
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
383
421
  ["maxBytes", "maxDepth", "maxElements", "maxAttrsPerElement",
384
- "maxAttrValueBytes"],
422
+ "maxAttrValueBytes", "maxNumericCharRefs"],
385
423
  "guardXml.validate", GuardXmlError, "xml.bad-opt");
386
424
  if (typeof input !== "string") {
387
425
  return {
@@ -50,6 +50,13 @@ var OtelExportError = defineClass("OtelExportError", { alwaysPermanent: false })
50
50
 
51
51
  var DEFAULT_INTERVAL_MS = C.TIME.seconds(15);
52
52
 
53
+ // OTLP collector response is `Empty` (zero protobuf bytes) on success
54
+ // or a short ExportPartialSuccess message on partial accept. A response
55
+ // past this cap is a hostile / misbehaving collector; refusing the
56
+ // body keeps the exporter from buffering megabytes per flush (CVE-2026-
57
+ // 40891 / CVE-2026-40182 OTLP class).
58
+ var MAX_RESPONSE_BYTES = C.BYTES.mib(1);
59
+
53
60
  // OTLP aggregation temporality:
54
61
  // 1 = DELTA — counters report deltas since last export
55
62
  // 2 = CUMULATIVE — counters report running totals
@@ -221,10 +228,12 @@ function create(opts) {
221
228
  var body = JSON.stringify(payload);
222
229
  try {
223
230
  var res = await effectiveHttpClient.request({
224
- method: "POST",
225
- url: endpoint,
226
- headers: Object.assign({ "Content-Type": "application/json" }, headers),
227
- body: body,
231
+ method: "POST",
232
+ url: endpoint,
233
+ headers: Object.assign({ "Content-Type": "application/json" }, headers),
234
+ body: body,
235
+ maxResponseBytes: MAX_RESPONSE_BYTES,
236
+ errorClass: OtelExportError,
228
237
  });
229
238
  if (res.statusCode < 200 || res.statusCode >= 300) {
230
239
  throw new OtelExportError("otel-export/upstream-rejected",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
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.6",
5
- "serialNumber": "urn:uuid:a2a2b296-e132-44ac-95f4-4c762331e4c2",
5
+ "serialNumber": "urn:uuid:c1165275-044f-4a5a-b7dc-061727bfd076",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-16T17:49:20.883Z",
8
+ "timestamp": "2026-05-16T23:28:39.659Z",
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.10.1",
22
+ "bom-ref": "@blamejs/core@0.10.2",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.10.1",
25
+ "version": "0.10.2",
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.10.1",
29
+ "purl": "pkg:npm/%40blamejs/core@0.10.2",
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.10.1",
57
+ "ref": "@blamejs/core@0.10.2",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]