@blamejs/core 0.15.38 → 0.15.40

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.40 (2026-06-27) — **The durable webhook dispatcher's retry poller now claims due deliveries with FOR UPDATE SKIP LOCKED on Postgres and MySQL, so two pollers running at once (multiple app nodes) can no longer both grab the same delivery and send the webhook twice in one cycle.** The webhook dispatcher's retry poller claimed due deliveries by flipping them pending->in-flight and then re-selecting the in-flight rows by id. On Postgres or MySQL under the default READ COMMITTED isolation, two pollers running concurrently could both re-select the same row: the loser's UPDATE matched zero rows (the winner had already flipped it), but its reselect-by-id still re-read the row the winner had just claimed, so both pollers attempted the same HTTP delivery in one cycle. The claim now row-locks the due rows with SELECT ... FOR UPDATE SKIP LOCKED on the row-locking backends, so concurrent pollers see disjoint sets and each delivery is claimed by exactly one poller; sqlite (a single writer) keeps the existing mark-then-reselect, which it serializes safely. This matches the claim used by the framework's transactional outbox and cluster queue. Receivers that already dedup on the X-Webhook-Delivery-Id header were protected from a duplicate POST; this closes the at-most-once-per-cycle gap at the dispatcher itself. **Fixed:** *Webhook retry pollers no longer double-deliver under concurrency on Postgres / MySQL* — b.webhook.dispatcher's processRetries() claimed due deliveries with a mark-then-reselect that had no row lock, so on Postgres / MySQL at READ COMMITTED two concurrent pollers (for example, the dispatcher running on more than one app node) could both hand back the same delivery and POST it twice in a single retry cycle. The claim now uses SELECT ... FOR UPDATE SKIP LOCKED on those backends so each due row is locked by exactly one poller and concurrent pollers claim disjoint sets; the rows a poller selects are exactly the rows it owns. sqlite keeps the mark-then-reselect path, which its single writer serializes. Operators running the dispatcher on a single node, or whose receivers dedup on X-Webhook-Delivery-Id, were not exposed to a duplicate delivery; no configuration change is required to pick up the fix. **Detectors:** *Build guard: a competing-consumer claim must use FOR UPDATE SKIP LOCKED* — A codebase guard now fails the build if a poller that claims due rows across workers — SELECT status='pending' inside a transaction, then flip the rows to in-flight — omits FOR UPDATE SKIP LOCKED on the row-locking backends. Without the row lock, two pollers under READ COMMITTED both claim the same row; the guard keeps any future poller from re-introducing the shape, with the transactional outbox and cluster queue as the reference claims.
12
+
13
+ - v0.15.39 (2026-06-27) — **Nine more places that matched an operator-supplied regex against request data — User-Agent, Origin, request path, form fields, SMTP HELO, release-asset names — now screen the pattern for catastrophic backtracking (ReDoS) before use, and a new b.guardRegex.assertSafe helper makes that screening one call.** The previous release screened feature-flag and MCP regex patterns for ReDoS but did not sweep every place the framework matches an operator-supplied regex against attacker-controlled input. Nine more were found and fixed: the bot guard (User-Agent), CORS (Origin), the HTTP span middleware and the shared request skip-matcher used by CSRF / fetch-metadata / rate-limit / access-lock / age-gate and the request logger (request path), static serving (hashed-asset path pattern), form field validation (submitted field value), SMTP HELO generic-rDNS patterns (HELO name), and the self-updater's asset/signature patterns (names from a remote release feed). Each accepted an operator RegExp with only a type check and ran it on every matching request, so an accidentally catastrophic pattern such as (a+)+$ or ((a)+)+$ could pin a CPU on a crafted input — a length cap does not bound backtracking. Every one now screens the pattern through b.guardRegex at configuration time. A new b.guardRegex.assertSafe(input, label?, ErrorClass?, code?) primitive performs that screen in one call (accepting a RegExp or a pattern string), which operators can also use on their own patterns. **Added:** *b.guardRegex.assertSafe — screen a RegExp or pattern string for ReDoS in one call* — b.guardRegex.assertSafe(input, label?, ErrorClass?, code?, opts?) screens an already-compiled RegExp (its source) or a raw pattern string for the catastrophic-backtracking classes — nested, alternation-with, and lookaround quantifiers — throwing the supplied framework-error class (or the underlying GuardRegexError) on a hostile pattern and returning the input on success. It allows large or open-ended bounded repeats (`{8,}`, `{n,m}`): a single counted repeat matches in linear time and legitimate patterns (including the framework's own defaults) use them. It is the config-time guard used by the request-lifecycle fixes above, and operators can apply it to their own patterns before matching them against untrusted input. **Security:** *Operator regex patterns matched against request data are screened for ReDoS framework-wide* — An operator-supplied RegExp matched against attacker-controllable input is a denial-of-service surface if it has a catastrophic-backtracking shape: the input triggers exponential work in the regex engine. Nine sites accepted such patterns with only an `instanceof RegExp` type check and executed them per request — bot-guard against the User-Agent, CORS against the Origin header, the HTTP span middleware and the shared skip-path matcher (CSRF / fetch-metadata / rate-limit / access-lock / age-gate / request-log) against the request path, static serving against the request path, form validation against the submitted field value, SMTP HELO checks against the HELO name, and the self-updater against asset names from a remote release feed. Each now routes the pattern through b.guardRegex at configuration time, so a catastrophic shape is refused up front instead of being weaponized by a crafted request. A length bound on the input is not a defense: a nested-quantifier pattern backtracks catastrophically at a few dozen characters. **Detectors:** *Build guard: an operator regex matched against request input must be ReDoS-screened* — A codebase guard now fails the build if a primitive accepts an operator-supplied RegExp and executes it against request input without screening the pattern through b.guardRegex.assertSafe — so the catastrophic-backtracking class fixed in this release cannot be reintroduced at a new site (the trusted-input cases — local filesystem paths, operator config keys, operator-owned schemas — are explicitly allowlisted). · *Build guard: process.moduleLoadList filters must match the 'NativeModule X' naming* — A guard now fails the build if a test filters process.moduleLoadList by the 'node:X' name only. Node 20+ records a loaded builtin as 'NativeModule X', so a 'node:'-only filter in an edge-runtime no-eager-load test would rot green and miss a reintroduced top-level networking require.
14
+
11
15
  - 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
16
 
13
17
  - 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).
@@ -178,14 +178,9 @@ function validateRules(rules, label) {
178
178
  // bound does NOT defend ReDoS (`(a+)+$` is 6 chars); screen the pattern
179
179
  // through b.guardRegex first — it refuses nested-quantifier / alternation-
180
180
  // quantifier / lookaround-quantifier shapes before compilation.
181
+ guardRegex().assertSafe(cond.value, clabel + ".value", FlagError, "flag/bad-condition");
181
182
  try {
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
183
+ // allow:dynamic-regex — operator targeting pattern, ReDoS-screened via guardRegex.assertSafe + length-bounded to 200 chars above
189
184
  validatedCond._compiledRegex = new RegExp(cond.value);
190
185
  } catch (e) {
191
186
  throw new FlagError("flag/bad-condition",
package/lib/forms.js CHANGED
@@ -34,6 +34,7 @@
34
34
  */
35
35
  var C = require("./constants");
36
36
  var { generateToken, timingSafeEqual } = require("./crypto");
37
+ var guardRegex = require("./guard-regex");
37
38
  var safeSchema = require("./safe-schema");
38
39
  var safeUrl = require("./safe-url");
39
40
  var template = require("./template");
@@ -198,6 +199,10 @@ function _renderInput(field) {
198
199
  "'.pattern must be a pre-compiled RegExp; got " +
199
200
  (typeof field.pattern) + ". Wrap the source string with `RegExp` at config time.");
200
201
  }
202
+ // Screen the operator-supplied pattern for catastrophic-backtracking
203
+ // (ReDoS) shapes at config time so a hostile-form-spec can't stage a
204
+ // pathological regex against the engine.
205
+ guardRegex.assertSafe(field.pattern, "forms: field[" + field.name + "].pattern");
201
206
  attrs.push('pattern="' + escapeAttribute(field.pattern.source) + '"');
202
207
  }
203
208
  if (field.min !== undefined) attrs.push('min="' + escapeAttribute(field.min) + '"');
@@ -490,6 +495,10 @@ function validate(spec, body) {
490
495
  "'.pattern must be a pre-compiled RegExp; got " +
491
496
  (typeof f.pattern) + ". Wrap the source string with `RegExp` at config time.");
492
497
  }
498
+ // Screen the operator-supplied pattern for catastrophic-backtracking
499
+ // (ReDoS) shapes before the test, so a pathological regex can't be
500
+ // run against the submitted value.
501
+ guardRegex.assertSafe(f.pattern, "forms: field[" + f.name + "].pattern");
493
502
  if (!f.pattern.test(coerced)) {
494
503
  errors[f.name] = f.errorMessages && f.errorMessages.pattern
495
504
  ? f.errorMessages.pattern
@@ -512,6 +512,61 @@ function gate(opts) {
512
512
  // ---- adaptive integration-test fixtures (consumed by layer-5 host harness) ----
513
513
  var INTEGRATION_FIXTURES = gateContract.identifierFixtures("^[a-z]+$", "(a+)+b");
514
514
 
515
+ /**
516
+ * @primitive b.guardRegex.assertSafe
517
+ * @signature b.guardRegex.assertSafe(input, label?, ErrorClass?, code?, opts?)
518
+ * @since 0.15.39
519
+ * @status stable
520
+ * @related b.guardRegex.sanitize, b.guardRegex.validate
521
+ *
522
+ * Screen an already-compiled <code>RegExp</code> (or a raw pattern string) for
523
+ * catastrophic-backtracking (ReDoS) shapes, throwing if the pattern is unsafe.
524
+ * This is the config-time guard for request-lifecycle code that matches an
525
+ * operator-supplied regex against attacker-controlled input (User-Agent,
526
+ * Origin, request path, form field, HELO) — an accidentally-catastrophic
527
+ * operator pattern would otherwise be a per-request DoS once a hostile input
528
+ * triggers the backtracking.
529
+ *
530
+ * Pass a <code>RegExp</code> instance (its <code>.source</code> is screened) or
531
+ * a pattern string. On a hostile shape it throws <code>ErrorClass(code, ...)</code>
532
+ * when an error class is supplied, otherwise the underlying
533
+ * <code>GuardRegexError</code>. Returns the input unchanged on success.
534
+ *
535
+ * By default it rejects the catastrophic-backtracking classes — nested,
536
+ * alternation-with, and lookaround quantifiers — but ALLOWS large/open bounded
537
+ * repeats (<code>{8,}</code>, <code>{n,m}</code>): a single counted repeat is
538
+ * linear, not exponential, and legitimate patterns (e.g. a hex hash of 8+
539
+ * digits) use them. Pass an explicit <code>opts</code> to override.
540
+ *
541
+ * @opts
542
+ * profile: string, // guardRegex profile (default: "strict")
543
+ * boundedRepeatPolicy: string, // default: "allow" (large bounded repeats are linear)
544
+ *
545
+ * @example
546
+ * b.guardRegex.assertSafe(/^[a-z]+$/); // ok — returns the RegExp
547
+ * b.guardRegex.assertSafe(/\.[a-f0-9]{8,}\./); // ok — a single bounded repeat is linear
548
+ * try { b.guardRegex.assertSafe(/((a)+)+$/); } // throws — nested quantifier
549
+ * catch (e) { e.code; } // → "regex/unsafe-pattern"
550
+ */
551
+ function assertSafe(input, label, ErrorClass, code, opts) {
552
+ var source = (input instanceof RegExp) ? input.source : input;
553
+ try {
554
+ // Screen the catastrophic-backtracking classes (nested / alternation /
555
+ // lookaround quantifiers — held at every profile) but allow large bounded
556
+ // repeats: a counted repeat matches in linear time, and rejecting `{n,}`
557
+ // would refuse legitimate operator patterns (and the framework's own
558
+ // defaults, e.g. b.staticServe.DEFAULT_HASHED_PATTERN's `{8,}`).
559
+ _guard.sanitize(source, opts || { profile: "strict", boundedRepeatPolicy: "allow" });
560
+ } catch (e) {
561
+ if (ErrorClass) {
562
+ throw new ErrorClass(code || "regex/unsafe-pattern",
563
+ (label || "regex") + ": pattern rejected as unsafe (ReDoS shape) - " + (e && e.message));
564
+ }
565
+ throw e;
566
+ }
567
+ return input;
568
+ }
569
+
515
570
  // Assembled from the gate-contract guard factory: error class, registry
516
571
  // exports (NAME / KIND / INTEGRATION_FIXTURES), buildProfile /
517
572
  // compliancePosture / loadRulePack wiring, plus the per-guard inspection
@@ -530,3 +585,5 @@ var _guard = module.exports = gateContract.defineGuard({
530
585
  intOpts: ["maxBytes", "maxPatternBytes", "maxBoundedRepeat", "maxConsecutiveStars"],
531
586
  gate: gate,
532
587
  });
588
+
589
+ _guard.assertSafe = assertSafe;
package/lib/mail-helo.js CHANGED
@@ -105,6 +105,7 @@ var { defineClass } = require("./framework-error");
105
105
  var lazyRequire = require("./lazy-require");
106
106
  var ipUtils = require("./ip-utils");
107
107
  var gateContract = require("./gate-contract");
108
+ var guardRegex = require("./guard-regex");
108
109
 
109
110
  var audit = lazyRequire(function () { return require("./audit"); });
110
111
 
@@ -210,6 +211,19 @@ async function evaluate(ctx, opts) {
210
211
  var selfNames = (opts.selfNames || []).map(function (n) { return String(n).toLowerCase(); });
211
212
  var auditImpl = opts.audit || audit();
212
213
 
214
+ // Screen operator-supplied generic-rDNS patterns ONCE at build time —
215
+ // they are .test()'d per-match against attacker-controlled rDNS / claim
216
+ // names, so a catastrophic-backtracking shape would be a ReDoS lever.
217
+ if (Array.isArray(opts.genericRdnsPatterns)) {
218
+ for (var gi = 0; gi < opts.genericRdnsPatterns.length; gi += 1) {
219
+ var gre = opts.genericRdnsPatterns[gi];
220
+ if (gre instanceof RegExp) {
221
+ guardRegex.assertSafe(gre, "mail.helo.evaluate: genericRdnsPatterns[" + gi + "]",
222
+ MailHeloError, "mail-helo/unsafe-pattern");
223
+ }
224
+ }
225
+ }
226
+
213
227
  if (!ctx || typeof ctx !== "object") {
214
228
  throw new MailHeloError("mail-helo/bad-input",
215
229
  "evaluate: ctx must be a plain object");
package/lib/mcp.js CHANGED
@@ -648,10 +648,10 @@ function _validateValueAgainstSchema(value, schema, path) {
648
648
  // (a `(a+)+$` pattern blows up at ~40 input chars). Screen the tool
649
649
  // author's pattern through b.guardRegex so a ReDoS-shaped schema pattern
650
650
  // can't pin a CPU when matched against request input.
651
- try { guardRegex().sanitize(schema.pattern, { profile: "strict" }); }
651
+ try { guardRegex().assertSafe(schema.pattern, path); }
652
652
  catch (_ge) { return path + ": schema pattern rejected as unsafe (ReDoS shape)"; }
653
653
  try {
654
- var pat = new RegExp(schema.pattern); // allow:dynamic-regex — schema.pattern is ReDoS-screened via guardRegex.sanitize (strict) above + input length-capped
654
+ var pat = new RegExp(schema.pattern); // allow:dynamic-regex — schema.pattern is ReDoS-screened via guardRegex.assertSafe above + input length-capped
655
655
  if (!pat.test(value)) return path + ": does not match pattern";
656
656
  }
657
657
  catch (_e) { return path + ": invalid pattern in schema"; }
@@ -54,6 +54,7 @@ var validateOpts = require("../validate-opts");
54
54
  var denyResponse = require("./deny-response").denyResponse;
55
55
  var { defineClass } = require("../framework-error");
56
56
  var audit = lazyRequire(function () { return require("../audit"); });
57
+ var guardRegex = lazyRequire(function () { return require("../guard-regex"); });
57
58
 
58
59
  var BotGuardError = defineClass("BotGuardError", { alwaysPermanent: true });
59
60
 
@@ -64,7 +65,13 @@ var BotGuardError = defineClass("BotGuardError", { alwaysPermanent: true });
64
65
  // constructing patterns dynamically compile at their own call site so
65
66
  // the pattern source is visible in their code.
66
67
  function _coerceAgentPattern(r, where) {
67
- if (r instanceof RegExp) return r;
68
+ if (r instanceof RegExp) {
69
+ // Screen the operator's pattern for catastrophic-backtracking (ReDoS)
70
+ // shapes ONCE at create()-time — it is later .test()'d against the
71
+ // attacker-controlled User-Agent on every request.
72
+ guardRegex().assertSafe(r, where, BotGuardError, "bot-guard/unsafe-pattern");
73
+ return r;
74
+ }
68
75
  throw new BotGuardError("bot-guard/bad-pattern",
69
76
  where + " must be a RegExp instance; got " + (typeof r) +
70
77
  " (compile the pattern at the call site so the source is visible " +
@@ -52,6 +52,7 @@
52
52
  var C = require("../constants");
53
53
  var lazyRequire = require("../lazy-require");
54
54
  var audit = lazyRequire(function () { return require("../audit"); });
55
+ var guardRegex = lazyRequire(function () { return require("../guard-regex"); });
55
56
  var requestHelpers = require("../request-helpers");
56
57
  var safeUrl = require("../safe-url");
57
58
  var validateOpts = require("../validate-opts");
@@ -237,6 +238,10 @@ function create(opts) {
237
238
  }
238
239
  origins.push({ kind: "string", canonical: canonEntry, original: entry });
239
240
  } else if (entry instanceof RegExp) {
241
+ // Screen the operator's RegExp once at create() time for
242
+ // catastrophic-backtracking (ReDoS) shapes before it ever runs
243
+ // .test() against an attacker-controlled Origin header per request.
244
+ guardRegex().assertSafe(entry, "middleware.cors: origins[" + oi + "]", CorsError, "cors/unsafe-pattern");
240
245
  origins.push({ kind: "regex", pattern: entry });
241
246
  } else {
242
247
  throw new CorsError("cors/bad-origin",
@@ -29,6 +29,7 @@
29
29
  * for fully custom logic (e.g. "warn" only on slow-path requests).
30
30
  */
31
31
  var C = require("../constants");
32
+ var guardRegex = require("../guard-regex");
32
33
  var requestHelpers = require("../request-helpers");
33
34
  var validateOpts = require("../validate-opts");
34
35
 
@@ -95,6 +96,9 @@ function create(opts) {
95
96
  if (typeof skipPaths[i] !== "string" && !(skipPaths[i] instanceof RegExp)) {
96
97
  throw new Error("middleware.requestLog: skipPaths[" + i + "] must be a string prefix or RegExp");
97
98
  }
99
+ if (skipPaths[i] instanceof RegExp) {
100
+ guardRegex.assertSafe(skipPaths[i], "middleware.requestLog: skipPaths[" + i + "]");
101
+ }
98
102
  }
99
103
  var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
100
104
  ? opts.trustProxy : false;
@@ -49,6 +49,7 @@ var { defineClass } = require("../framework-error");
49
49
  var SpanHttpError = defineClass("SpanHttpError", { alwaysPermanent: true });
50
50
 
51
51
  var observability = lazyRequire(function () { return require("../observability"); });
52
+ var guardRegex = lazyRequire(function () { return require("../guard-regex"); });
52
53
 
53
54
  function _shouldIgnore(path, ignorePaths) {
54
55
  if (!ignorePaths || !Array.isArray(ignorePaths)) return false;
@@ -177,6 +178,15 @@ function create(opts) {
177
178
  var tracer = opts.tracer;
178
179
  var onEnd = opts.onEnd || null;
179
180
  var ignorePaths = opts.ignorePaths || null;
181
+ if (Array.isArray(ignorePaths)) {
182
+ for (var ip = 0; ip < ignorePaths.length; ip++) {
183
+ if (ignorePaths[ip] instanceof RegExp) {
184
+ guardRegex().assertSafe(ignorePaths[ip],
185
+ "middleware.spanHttpServer: ignorePaths[" + ip + "]",
186
+ SpanHttpError, "span-http/unsafe-pattern");
187
+ }
188
+ }
189
+ }
180
190
  var captureReqHeaders = opts.captureRequestHeaders || null;
181
191
  var captureResHeaders = opts.captureResponseHeaders || null;
182
192
  var spanNameFn = opts.spanNameFn || null;
@@ -49,6 +49,9 @@ var lazyRequire = require("./lazy-require");
49
49
  // required very early in the boot graph. Only touched at middleware-construction
50
50
  // time by trustedClientIp(), never on the hot path.
51
51
  var _ssrfGuard = lazyRequire(function () { return require("./ssrf-guard"); });
52
+ // Lazy for the same boot-graph reason — only touched at middleware-construction
53
+ // time by makeSkipMatcher() to screen operator-supplied skip RegExps for ReDoS.
54
+ var _guardRegex = lazyRequire(function () { return require("./guard-regex"); });
52
55
 
53
56
  var HTTP_STATUS = Object.freeze({
54
57
  OK: 0xC8,
@@ -991,6 +994,12 @@ function makeSkipMatcher(opts, label) {
991
994
  throw new TypeError(label + ": skipPaths[" + i + "] must be a string prefix or RegExp, got " +
992
995
  typeof skipPaths[i]);
993
996
  }
997
+ // Screen operator-supplied skip RegExps for catastrophic-backtracking
998
+ // shapes once at construction time — they later run .test() against
999
+ // attacker-controlled request paths on the hot path.
1000
+ if (skipPaths[i] instanceof RegExp) {
1001
+ _guardRegex().assertSafe(skipPaths[i], label + ": skipPaths[" + i + "]");
1002
+ }
994
1003
  }
995
1004
  var skipFn = opts.skip;
996
1005
  if (skipFn !== undefined && skipFn !== null && typeof skipFn !== "function") {
@@ -54,6 +54,7 @@ var numericBounds = require("./numeric-bounds");
54
54
  var atomicFile = require("./atomic-file");
55
55
  var validateOpts = require("./validate-opts");
56
56
  var bCrypto = require("./crypto");
57
+ var guardRegex = require("./guard-regex");
57
58
  var httpClient = require("./http-client");
58
59
  var safeJson = require("./safe-json");
59
60
  var { URL: NodeUrl } = require("node:url");
@@ -242,12 +243,25 @@ function _validatePollOpts(opts) {
242
243
  throw new SelfUpdateError("selfupdate/bad-asset-pattern",
243
244
  "selfUpdate.poll: opts.assetPattern must be a RegExp or string when present");
244
245
  }
246
+ // Screen an operator-supplied RegExp once at config-time; it is
247
+ // later .test()'d against attacker-controlled asset names in the
248
+ // request path, so a catastrophic-backtracking shape would be a
249
+ // per-request DoS. The string form is matched by substring
250
+ // (indexOf), never compiled, so it carries no ReDoS risk.
251
+ if (value instanceof RegExp) {
252
+ guardRegex.assertSafe(value, "selfUpdate: assetPattern",
253
+ SelfUpdateError, "selfupdate/unsafe-asset-pattern");
254
+ }
245
255
  },
246
256
  signaturePattern: function (value) {
247
257
  if (value !== undefined && !(value instanceof RegExp) && typeof value !== "string") {
248
258
  throw new SelfUpdateError("selfupdate/bad-sig-pattern",
249
259
  "selfUpdate.poll: opts.signaturePattern must be a RegExp or string when present");
250
260
  }
261
+ if (value instanceof RegExp) {
262
+ guardRegex.assertSafe(value, "selfUpdate: signaturePattern",
263
+ SelfUpdateError, "selfupdate/unsafe-sig-pattern");
264
+ }
251
265
  },
252
266
  maxBytes: function (value) {
253
267
  numericBounds.requirePositiveFiniteIntIfPresent(value,
package/lib/static.js CHANGED
@@ -60,6 +60,7 @@ var observability = lazyRequire(function () { return require("./observability");
60
60
  // import cycles. Operators opt out via contentSafety: null (audited).
61
61
  var guardAll = lazyRequire(function () { return require("./guard-all"); });
62
62
  var guardFilename = lazyRequire(function () { return require("./guard-filename"); });
63
+ var guardRegex = lazyRequire(function () { return require("./guard-regex"); });
63
64
 
64
65
  var _err = StaticServeError.factory;
65
66
 
@@ -521,6 +522,13 @@ function _validateCreateOpts(opts) {
521
522
  if (value !== undefined && value !== null && !(value instanceof RegExp)) {
522
523
  throw errorClass.factory(code, "staticServe.create: hashedPathPattern must be a RegExp");
523
524
  }
525
+ // Screen the operator-supplied pattern once at create() time — it is
526
+ // .test()'d against the attacker-controlled request path on every
527
+ // download, so a catastrophic-backtracking (ReDoS) shape would be a
528
+ // per-request DoS. Reject the unsafe pattern at config time instead.
529
+ if (value instanceof RegExp) {
530
+ guardRegex().assertSafe(value, "staticServe: hashedPathPattern", StaticServeError, "static/unsafe-pattern");
531
+ }
524
532
  },
525
533
  // indexFile === null is the operator's "disable" sentinel; the helper
526
534
  // returns null/undefined unchanged so we keep that semantic.
@@ -574,21 +574,41 @@ function dispatcher(opts) {
574
574
  await externalDb.query(stmt.sql, stmt.params);
575
575
  }
576
576
 
577
+ // FOR UPDATE SKIP LOCKED is Postgres / MySQL-only; sqlite is a single writer
578
+ // with no row lock. Decide on the NORMALIZED dialect — the same resolution the
579
+ // SQL builders use (_sqlDialect maps the `postgresql` alias to `postgres`) — so
580
+ // the lock decision can never disagree with the rendered SQL: a `postgresql`
581
+ // caller emits Postgres SQL AND row-locks, never Postgres SQL with the sqlite
582
+ // mark-then-reselect fallback (which would reopen the double-claim race).
583
+ function _supportsForUpdateSkipLocked() {
584
+ var d = _sqlDialect(externalDb);
585
+ return d === "postgres" || d === "mysql";
586
+ }
587
+
577
588
  // Claim due-pending deliveries by flipping them to 'in-flight' inside a
578
- // transaction (mark-then-reselect; gated on status='pending' so two pollers
579
- // can't both claim the same row), then attempt each claimed delivery.
589
+ // transaction, then attempt each claimed delivery. On Postgres / MySQL the
590
+ // SELECT row-locks the due rows via FOR UPDATE SKIP LOCKED, so concurrent
591
+ // retry pollers see DISJOINT sets — the rows this poller selected are exactly
592
+ // the rows it owns. sqlite (single writer, no row lock) falls back to a
593
+ // guarded mark-then-reselect: the UPDATE gated on status='pending' transitions
594
+ // each row once, and we re-read the in-flight rows we flipped. Without the
595
+ // SKIP LOCKED branch, two pollers under READ COMMITTED both reselect the same
596
+ // in-flight row (the loser's UPDATE matches zero rows, but the reselect-by-id
597
+ // re-reads the row the winner flipped) and double-deliver it in one cycle.
580
598
  async function processRetries() {
581
599
  await _reapStaleInflight();
582
600
  var dialect = _sqlDialect(externalDb);
601
+ var supportsSkipLocked = _supportsForUpdateSkipLocked();
583
602
  var claimed = await externalDb.transaction(async function (xdb) {
584
603
  var nowDate = _nowDate();
585
- var sel = sql.select(deliveriesTable, { dialect: dialect })
604
+ var selBuilder = sql.select(deliveriesTable, { dialect: dialect })
586
605
  .columns(["delivery_id"])
587
606
  .whereRaw("status = 'pending'", [], { allowLiterals: true })
588
607
  .whereRaw("next_attempt_at <= ?", [nowDate])
589
608
  .orderBy("next_attempt_at")
590
- .limit(batchSize)
591
- .toExternalSql(dialect);
609
+ .limit(batchSize);
610
+ if (supportsSkipLocked) selBuilder.forUpdate({ skipLocked: true });
611
+ var sel = selBuilder.toExternalSql(dialect);
592
612
  var rows = await xdb.query(sel.sql, sel.params);
593
613
  var ids = ((rows && rows.rows) || []).map(function (r) { return r.delivery_id; });
594
614
  if (ids.length === 0) return [];
@@ -598,6 +618,12 @@ function dispatcher(opts) {
598
618
  .whereInArray("delivery_id", ids)
599
619
  .toExternalSql(dialect);
600
620
  await xdb.query(mark.sql, mark.params);
621
+ // Postgres / MySQL: the FOR UPDATE SKIP LOCKED SELECT already gave us an
622
+ // exclusively-locked, disjoint set, so the selected ids ARE our claim.
623
+ if (supportsSkipLocked) return ids;
624
+ // sqlite / other: no row lock, so re-read which rows WE flipped. The
625
+ // single writer serializes the gated UPDATE, so the in-flight rows in our
626
+ // id set are ours.
601
627
  var after = sql.select(deliveriesTable, { dialect: dialect })
602
628
  .columns(["delivery_id"])
603
629
  .whereRaw("status = 'in-flight'", [], { allowLiterals: true })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.38",
3
+ "version": "0.15.40",
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:742c0889-e56f-4bee-8220-91a6cac8307b",
5
+ "serialNumber": "urn:uuid:4538b9a5-f7e6-4723-be61-b03f474f0c0e",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-27T16:52:08.760Z",
8
+ "timestamp": "2026-06-28T06:47:43.444Z",
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.38",
22
+ "bom-ref": "@blamejs/core@0.15.40",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.38",
25
+ "version": "0.15.40",
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.38",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.40",
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.38",
57
+ "ref": "@blamejs/core@0.15.40",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]