@blamejs/core 0.18.49 → 0.18.50

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,34 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.50 (2026-08-22) — **Five places let someone else choose how much CPU your server spends on one match.** A Sieve `:matches` wildcard, a JSONPath `match()` filter and a JSON Schema `pattern` each took a pattern from outside the framework and ran it on a backtracking engine. A Sieve rule with five wildcards spent 452ms on a 60-character Subject; a JSONPath filter spent 7 seconds on a 29-character value; a schema pattern spent 1.5 seconds on 28 characters and doubled with every two more. Two more were the same cost from the other direction, a fixed pattern against attacker-shaped input: a DKIM tag scan and a scanner-reply trim, both quadratic. All five now refuse the shape or match without backtracking. **Security:** *`b.mail.sieve` matches `:matches` wildcards without a regular expression* — RFC 5228 `:matches` uses `*` for any sequence and `?` for one character. Translating that into a regular expression turns each `*` into `.*`, and a backtracking engine facing several of them tries every way of dividing the subject between them. The cost is polynomial in the subject length with degree equal to the number of wildcards.
12
+
13
+ Measured on the shipped path, `b.mail.sieve.runScript` with `if header :matches "Subject" ...` and a subject that never supplies the pattern's trailing literal: three wildcards grew by a factor of 7.7 for each doubling of the subject, and ten wildcards — which a script author can type without meaning anything by it — did not finish on a 64-character Subject.
14
+
15
+ The `maxGas` budget did not bound it. Gas counts operations, and one match is one operation however long the engine spends inside it: five wildcards cost 452ms for three gas units.
16
+
17
+ This matters because of who writes the script. Sieve is a user-level filtering language, so in most deployments the pattern comes from the mailbox owner rather than from you. The wildcard is now matched directly against the value, walking both strings with a single remembered wildcard position, so the work is bounded by their lengths rather than by how the pattern is shaped. Every pattern decides exactly as it did before, checked across 42,976 pattern/subject/case combinations against the previous implementation.
18
+
19
+ One deliberate change of behaviour comes with it. `:comparator "i;ascii-casemap"` now folds US-ASCII `A-Z` only, which is what RFC 4790 §9.2 defines it as; the regular expression it replaced carried JavaScript's `i` flag and folded the whole of Unicode. The narrower fold is also the only one that preserves length: `"İ".toLowerCase()` is two UTF-16 units, so folding a subject up front shifts every position after it and leaves `?`, which matches exactly one character, facing two. A script relying on non-ASCII case folding under `i;ascii-casemap` was relying on something the comparator does not promise. · *`b.jsonPath` refuses a catastrophic `match()` or `search()` pattern* — The second argument of an RFC 9535 `match()` or `search()` filter is an I-Regexp (RFC 9485), which is a general regular-expression language: a filter can carry `(a+)+$` as easily as `a+`. `$[?match(@.name, "(a+)+$")]` against a 29-character value that fails the match took 7 seconds.
20
+
21
+ The node ceilings on descendant walks and running nodelists never saw it, because the whole cost is inside one match on one node.
22
+
23
+ The pattern is now screened with `b.guardRegex.assertSafe` before it can run, which is what the framework already does wherever else it compiles a caller-supplied pattern — `b.flag` targeting conditions and MCP tool schemas both go through the same screen. A refusal raises `json-path/unsafe-pattern` rather than being folded into a false test result: a malformed I-Regexp yields false per RFC 9535 §2.4.6, and a refused one must not wear those clothes, or the caller is left with a filter that silently matches nothing. · *A folded DKIM-Signature header made the submission listener spend its CPU on a trim* — `b.mail.server.submission` reads the `d=` tag out of each DKIM-Signature header, and trimmed each tag with `/^\s+|\s+$/`. That is the quadratic trim: the `\s+$` alternative is retried from every start position when the whitespace run does not reach the end of the string.
24
+
25
+ The tag was believed to be bounded by the line cap. It is not, because the header is unfolded first. Every empty continuation line contributes one space to the joined value, so the lever is the number of folded lines rather than the length of any one of them, and the real bound is `maxMessageBytes` — 50 MiB by default. An authenticated client sending 40,000 empty continuation lines, a 120 KB message, produced a 40,000-character run and 446ms of backtracking on one header. Three megabytes would have bought minutes.
26
+
27
+ The trim is now `String.prototype.trim`, which decides identically (the same WhiteSpace and LineTerminator set as `\s`) and does not backtrack. · *A virus-scanner reply with a long newline run cost the same shape* — The ClamAV INSTREAM reply is trimmed of trailing CR / LF / NUL before it is classified, and the trim was `/[\r\n\0]+$/`. Anchoring at `$` does not stop the engine retrying from every start position; it is what makes the scan quadratic. A reply carrying a long interior run of newlines took 1.2 seconds through the real scan path.
28
+
29
+ The reply is capped, but by `maxResponseBytes`, which is 50 MiB on the strict profile and 300 MiB on permissive, so the cap was not what stood between a reply and the cost. The trim now walks backwards from the end.
30
+
31
+ A local daemon is normally trusted, which makes this hardening rather than a hole — but `b.mail.scan` dials a host and a port, a scanning service can be remote, and a reply parser is the wrong place to assume the peer is well behaved. · *`b.jsonSchema` screens `pattern` and `patternProperties` when the schema compiles* — A `pattern` keyword is compiled and then run against the instance, so the schema author decides what each validation costs. `(a+)+$` against a 28-character string that fails took 1.5 seconds, doubling with every two characters added.
32
+
33
+ The reasoning that left this unscreened was that a schema is operator-supplied and therefore trusted. The framework does not hold that assumption elsewhere: `b.mcp` already screens the `pattern` in a tool schema for exactly this shape before matching it against request input. A schema arrives from a registry, a tool manifest, an upload or a config file as readily as from your own source.
34
+
35
+ Every pattern in a schema is now screened, and screened at `b.jsonSchema.compile` rather than on the first instance that reaches it — a bad schema should be refused while you are still looking at the schema, not on a request, through a validator you have already been handed and have no reason to doubt.
36
+
37
+ The screen reads the compiled expression rather than the pattern text, which is what `b.safeJson` does with the same keyword and for the reason recorded there: the flags decide what the source means, so a text-only screen reads `(a|A)+` as two disjoint branches where the engine sees one branch twice.
38
+
11
39
  - v0.18.49 (2026-08-22) — **Comments in the shipped tree that answered a question you cannot ask.** A comment in `lib/` reaches anyone who opens the published package, and twenty of them were written for a reader who remembers the previous version or can look up an issue number that resolves nowhere outside this repository. They now say what the code does and why, in the present tense, keeping every reason they carried. No behaviour changes. **Changed:** *Comments that narrated a change now describe the code* — Fourteen comments across `lib/` were written against a version the reader has never seen: "this used to be opt-in", "it used to declare them in every profile anyway", "the legacy fallback was removed". Each is now a property of the design rather than a diff, and each keeps the reason it carried. The zero-width scan's comment still explains that an opt-in argument is an argument a caller can omit, and still names the six call sites that omitted it — it just no longer reads as a changelog entry pasted into the source.
12
40
 
13
41
  One of them was wrong in a way worth naming: a note in the gate-contract context builder said a guard "serves bytes it used to examine", which reads as a past version's behaviour. It means bytes the guard would otherwise examine, and now says so. · *Issue numbers that resolve nowhere are gone; the upstream ones stay* — Six comments cited a bare `issue #100` / `#330` / `#430` / `#532`. Opened from the published tarball those name nothing an operator can look up, and the explanation beside them was already doing the work. The references are gone and the explanations stay.
package/lib/json-path.js CHANGED
@@ -33,6 +33,11 @@
33
33
  */
34
34
 
35
35
  var { defineClass } = require("./framework-error");
36
+ var lazyRequire = require("./lazy-require");
37
+ // Lazy: guard-regex composes the parser in this file's sibling modules, and the
38
+ // screen is only reached by a filter that calls match() or search().
39
+ var guardRegex = lazyRequire(function () { return require("./guard-regex"); });
40
+ var boundedMap = require("./bounded-map");
36
41
 
37
42
  var JsonPathError = defineClass("JsonPathError", { alwaysPermanent: true });
38
43
 
@@ -573,7 +578,25 @@ function _evalFunctionValue(node, current, root) {
573
578
  return NOTHING;
574
579
  }
575
580
 
581
+ // A filter runs once per candidate node, and the pattern is almost always the
582
+ // same literal throughout a query, so translating and screening it per node
583
+ // would put the cost of the analysis on the nodelist length. Screened once per
584
+ // distinct (pattern, anchored) pair instead.
585
+ //
586
+ // Bounded, because the key comes from the query: an unbounded map keyed by
587
+ // caller-supplied text is a slower way to spend the same memory. The framework's
588
+ // own ceiling primitive owns the eviction, so the oldest entry goes rather than
589
+ // the whole cache.
590
+ var _filterRegexCache = boundedMap.boundedMap({ maxEntries: 256 });
591
+
592
+ // Only an accepted pattern is stored: a refusal throws out of `query()` on the
593
+ // first candidate node, so there is no second node to re-decide it for.
576
594
  function _iRegexpToJs(pattern, anchored) {
595
+ return boundedMap.getOrInsert(_filterRegexCache, (anchored ? "1" : "0") + pattern,
596
+ function () { return _buildFilterRegex(pattern, anchored); });
597
+ }
598
+
599
+ function _buildFilterRegex(pattern, anchored) {
577
600
  // I-Regexp (RFC 9485) is close to a JS regex subset. Translate the one
578
601
  // systematic difference — "." must not match line separators — and
579
602
  // (for match) anchor the whole input.
@@ -582,16 +605,43 @@ function _iRegexpToJs(pattern, anchored) {
582
605
  if (cls) return cls;
583
606
  return "[^\\n\\r]";
584
607
  });
585
- // The pattern is the I-Regexp argument of an RFC 9535 match()/search()
586
- // filter translated (not raw) and used only as a boolean test.
587
- return new RegExp(anchored ? "^(?:" + translated + ")$" : translated, "su"); // allow:dynamic-regex translated I-Regexp from a match()/search() filter argument
608
+ var source = anchored ? "^(?:" + translated + ")$" : translated;
609
+ // I-Regexp is a general regular-expression language, so the argument of a
610
+ // match() or search() filter can be `(a+)+$` as easily as `a+`, and a
611
+ // backtracking engine will spend the caller's chosen amount of CPU deciding
612
+ // it. Being a boolean test is no protection: `.test()` is exactly where the
613
+ // backtracking happens, and the node caps above never see it because the
614
+ // whole blow-up is inside one call on one node. Measured before this screen:
615
+ // 7 seconds on a 29-character subject, doubling per added character.
616
+ //
617
+ // Screened with the same primitive the framework already uses wherever it
618
+ // compiles a caller-supplied pattern — b.flag targeting conditions and MCP
619
+ // tool schemas both go through it.
620
+ //
621
+ // Compiled first and screened on the COMPILED form, matching b.safeJson: the
622
+ // flags decide what the source means, so a source-only screen reads `(a|A)+`
623
+ // as two disjoint branches where the engine sees one branch twice. Compiling
624
+ // is safe on its own; the cost is in matching, which has not happened yet.
625
+ var re = new RegExp(source, "su"); // allow:dynamic-regex — translated I-Regexp, ReDoS-screened via guardRegex.assertSafe below, before it is returned to the caller that will run it
626
+ guardRegex().assertSafe(re, "jsonPath.filter.pattern",
627
+ JsonPathError, "json-path/unsafe-pattern");
628
+ return re;
588
629
  }
589
630
  function _evalFunctionLogical(node, current, root) {
590
631
  var input = _funcArgValue(node.args[0], current, root);
591
632
  var pat = _funcArgValue(node.args[1], current, root);
592
633
  if (typeof input !== "string" || typeof pat !== "string") return false;
593
634
  var re;
594
- try { re = _iRegexpToJs(pat, node.name === "match"); } catch (_e) { return false; }
635
+ try { re = _iRegexpToJs(pat, node.name === "match"); }
636
+ catch (e) {
637
+ // A malformed I-Regexp yields Nothing, which RFC 9535 §2.4.6 renders as a
638
+ // false test result. A pattern REFUSED as catastrophic is a different
639
+ // answer and must not wear the same clothes: swallowing it would leave the
640
+ // caller with a filter that silently matches nothing and no way to tell
641
+ // that from a filter that matched nothing.
642
+ if (e && e.code === "json-path/unsafe-pattern") throw e;
643
+ return false;
644
+ }
595
645
  return re.test(input);
596
646
  }
597
647
 
@@ -52,6 +52,10 @@
52
52
  var numericBounds = require("./numeric-bounds");
53
53
  var rfc3339 = require("./rfc3339");
54
54
  var { defineClass } = require("./framework-error");
55
+ var lazyRequire = require("./lazy-require");
56
+ // Lazy: the screen is only reached by a schema that carries a `pattern`.
57
+ var guardRegex = lazyRequire(function () { return require("./guard-regex"); });
58
+ var boundedMap = require("./bounded-map");
55
59
 
56
60
  var JsonSchemaError = defineClass("JsonSchemaError", { alwaysPermanent: true });
57
61
 
@@ -288,6 +292,51 @@ function _buildModule() {
288
292
  * properties: { n: { type: "integer" } }, required: ["n"] });
289
293
  * v.validate({ n: 1 }).valid; // → true
290
294
  */
295
+ // Where a subschema can appear. Named rather than inferred, because a schema
296
+ // also carries DATA — `const`, `enum`, `default` and `examples` hold instance
297
+ // values — and a walk that recursed into everything would read a `pattern` key
298
+ // inside a `const` value as a schema keyword and refuse a legal schema for the
299
+ // shape of its example data.
300
+ //
301
+ // Missing a position here is safe in the direction that matters: the screen in
302
+ // _compileRegex still runs on whatever is actually compiled, so a subschema
303
+ // this walk does not reach is refused when it is used rather than never. The
304
+ // walk buys an earlier refusal, not the refusal itself.
305
+ // `additionalItems` and `contentSchema` are deliberately absent: this validator
306
+ // does not evaluate either keyword, so a pattern inside one never compiles and
307
+ // never runs. Screening there could only refuse a schema for a branch that has
308
+ // no effect.
309
+ var _SUBSCHEMA_KEYS = ["additionalProperties", "contains",
310
+ "else", "if", "items", "not", "propertyNames", "then",
311
+ "unevaluatedItems", "unevaluatedProperties"];
312
+ var _SUBSCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems", "items"];
313
+ var _SUBSCHEMA_MAP_KEYS = ["$defs", "definitions", "dependentSchemas",
314
+ "patternProperties", "properties"];
315
+
316
+ // Walk the subschema positions for the two keywords that carry a regular
317
+ // expression, and hand each to the same screen the compile site uses.
318
+ // Depth-bounded by the ceiling the validator applies to `$ref` chains, so a
319
+ // deeply nested schema cannot spend the walk instead of the match.
320
+ function _screenPatterns(node, depth) {
321
+ depth = depth || 0;
322
+ if (depth > MAX_REF_DEPTH || !_isObject(node)) return;
323
+
324
+ if (typeof node.pattern === "string") _compileRegex(node.pattern);
325
+ if (_isObject(node.patternProperties)) {
326
+ Object.keys(node.patternProperties).forEach(function (p) { _compileRegex(p); });
327
+ }
328
+
329
+ _SUBSCHEMA_KEYS.forEach(function (k) { _screenPatterns(node[k], depth + 1); });
330
+ _SUBSCHEMA_LIST_KEYS.forEach(function (k) {
331
+ if (!Array.isArray(node[k])) return;
332
+ node[k].forEach(function (sub) { _screenPatterns(sub, depth + 1); });
333
+ });
334
+ _SUBSCHEMA_MAP_KEYS.forEach(function (k) {
335
+ if (!_isObject(node[k])) return;
336
+ Object.keys(node[k]).forEach(function (name) { _screenPatterns(node[k][name], depth + 1); });
337
+ });
338
+ }
339
+
291
340
  function compile(schema, opts) {
292
341
  opts = opts || {};
293
342
  if (!_isObject(schema) && typeof schema !== "boolean") {
@@ -304,6 +353,15 @@ function compile(schema, opts) {
304
353
  var rootBase = (_isObject(schema) && typeof schema.$id === "string") ? _resolveUri(schema.$id, "") : "";
305
354
  registry.add(schema, rootBase);
306
355
 
356
+ // Screen every pattern the schema carries HERE, while the author is still
357
+ // looking at the schema. Compiling is where a bad schema should be refused;
358
+ // leaving it to the first instance means the refusal lands on a request, in
359
+ // a validator the caller has already been handed and treats as good.
360
+ _screenPatterns(schema);
361
+ if (_isObject(opts.schemas)) {
362
+ Object.keys(opts.schemas).forEach(function (uri) { _screenPatterns(opts.schemas[uri]); });
363
+ }
364
+
307
365
  var assertFormat = opts.assertFormat === true;
308
366
  var maxErrors = numericBounds.isPositiveFiniteInt(opts.maxErrors) ? opts.maxErrors : DEFAULT_MAX_ERRORS;
309
367
 
@@ -525,15 +583,42 @@ function _checkString(schema, s, ctx, emit) {
525
583
  }
526
584
  }
527
585
 
528
- var _regexCache = {};
586
+ // Keyed by the schema's own pattern text, so a long-lived process compiling
587
+ // many distinct schemas would otherwise grow this without bound. The
588
+ // framework's own ceiling primitive owns the eviction.
589
+ var _regexCache = boundedMap.boundedMap({ maxEntries: 512 });
529
590
  function _compileRegex(pattern, ctx) {
530
- if (Object.prototype.hasOwnProperty.call(_regexCache, pattern)) return _regexCache[pattern];
591
+ if (_regexCache.has(pattern)) return _regexCache.get(pattern);
592
+ // `pattern` / `patternProperties` decide how much CPU each validation costs,
593
+ // because the compiled expression runs against the instance. `(a+)+$` against
594
+ // a run of `a` ending in anything else measured 1.5 seconds at 28 characters
595
+ // and doubles with every two more, so a schema is a denial-of-service lever
596
+ // as much as a contract.
597
+ //
598
+ // A schema is not automatically the operator's own source: it arrives from a
599
+ // registry, a tool manifest, an upload or a config file. b.mcp already
600
+ // screens the pattern in a tool schema for this shape before matching it
601
+ // against request input, and this is the same construct.
602
+ //
603
+ // Screened once per distinct pattern — the cache below means a schema pays
604
+ // for the analysis at its first use rather than per instance.
605
+ //
606
+ // Compiled first and screened AFTER, on the compiled form, which is what
607
+ // b.safeJson does with the same keyword and for the reason recorded there:
608
+ // the flags decide what the source means, so a screen reading the source
609
+ // alone reads `(a|A)+` as two disjoint branches where the engine sees one
610
+ // branch twice. Compiling a pathological pattern is safe on its own — the
611
+ // cost is in matching, which has not happened yet.
531
612
  var re = null;
532
- try { re = new RegExp(pattern, "u"); } // allow:dynamic-regex — JSON Schema pattern is part of the (operator-trusted) schema, not instance data
613
+ try { re = new RegExp(pattern, "u"); } // allow:dynamic-regex — ReDoS-screened via guardRegex.assertSafe below, before the compiled form is returned to any caller
533
614
  catch (_e) {
534
- try { re = new RegExp(pattern); } catch (_e2) { re = null; }
615
+ try { re = new RegExp(pattern); } catch (_e2) { re = null; } // allow:dynamic-regex — same screen, non-unicode fallback for a pattern `u` rejects
616
+ }
617
+ if (re) {
618
+ guardRegex().assertSafe(re, "jsonSchema.pattern",
619
+ JsonSchemaError, "json-schema/unsafe-pattern");
535
620
  }
536
- _regexCache[pattern] = re;
621
+ _regexCache.set(pattern, re);
537
622
  return re;
538
623
  }
539
624
 
package/lib/mail-scan.js CHANGED
@@ -104,6 +104,26 @@ var CLAMAV_LENGTH_PREFIX_BYTES = 4;
104
104
  // ClamAV INSTREAM chunk size for streaming.
105
105
  var CLAMAV_CHUNK_BYTES = 65536;
106
106
 
107
+ // Trailing CR / LF / NUL off a daemon reply, walked backwards from the end.
108
+ //
109
+ // The regex form of this, `/[\r\n\0]+$/`, is quadratic: `$` does not stop the
110
+ // engine retrying from every start position, so a reply carrying a long
111
+ // interior run of newlines costs O(n^2) — measured 135ms at 20k characters,
112
+ // 538ms at 40k and 2207ms at 80k, and 1223ms through the real scan path.
113
+ // The reply IS capped, but by `maxResponseBytes`, which is 50 MiB on the strict
114
+ // profile and 300 MiB on permissive, so the cap is not what would save it.
115
+ //
116
+ // `String.prototype.trim` is not the substitute here: NUL is not whitespace.
117
+ function _stripTrailingEol(s) {
118
+ var end = s.length;
119
+ while (end > 0) {
120
+ var c = s.charCodeAt(end - 1);
121
+ if (c !== 0x0D && c !== 0x0A && c !== 0x00) break;
122
+ end -= 1;
123
+ }
124
+ return end === s.length ? s : s.slice(0, end);
125
+ }
126
+
107
127
  var PROFILES = Object.freeze({
108
128
  strict: { timeoutMs: C.TIME.seconds(30), maxMessageBytes: C.BYTES.mib(25), maxResponseBytes: C.BYTES.mib(50) }, // operator-facing default mailbox cap
109
129
  balanced: { timeoutMs: C.TIME.seconds(60), maxMessageBytes: C.BYTES.mib(50), maxResponseBytes: C.BYTES.mib(100) }, // operator-facing default mailbox cap
@@ -385,7 +405,7 @@ function create(opts) {
385
405
  if (done) return;
386
406
  done = true;
387
407
  clearTimeout(to);
388
- var reply = collector.result().toString("utf8").replace(/[\r\n\0]+$/g, ""); // allow:regex-no-length-cap — trailing-trim anchored
408
+ var reply = _stripTrailingEol(collector.result().toString("utf8"));
389
409
  // ClamAV INSTREAM reply: "<id>: <verdict>" where verdict is
390
410
  // "stream: OK", "stream: <Sig.Name> FOUND", or "INSTREAM size
391
411
  // limit exceeded. ERROR". This is a security verdict classifier,
@@ -198,7 +198,14 @@ function _extractDkimSignatures(headerBlock) {
198
198
  function _extractDkimDTag(sigValue) {
199
199
  var tags = sigValue.split(";");
200
200
  for (var i = 0; i < tags.length; i += 1) {
201
- var t = tags[i].replace(/^\s+|\s+$/g, ""); // allow:regex-no-length-cap tag length bounded by header line cap // allow:duplicate-regex — trim shape
201
+ // Native trim, not `/^\s+|\s+$/`. The regex form is the classic quadratic
202
+ // trim: `\s+$` is retried from every start position when the run does not
203
+ // reach the end. The tag is NOT bounded by the line cap, because the header
204
+ // is unfolded first — every empty continuation line adds one space to the
205
+ // run, so 40,000 of them in a 120 KB message produced a 40,000-character
206
+ // run and 446ms of backtracking. `String.prototype.trim` uses the same
207
+ // WhiteSpace and LineTerminator set as `\s`, so this decides identically.
208
+ var t = tags[i].trim();
202
209
  if (t.length > 2 && t.charAt(0) === "d" && t.charAt(1) === "=") {
203
210
  return t.slice(2).replace(/\s+/g, ""); // allow:regex-no-length-cap — value length bounded by tag length // allow:duplicate-regex — internal-WS strip
204
211
  }
package/lib/mail-sieve.js CHANGED
@@ -43,9 +43,10 @@
43
43
  * parse time (require 'comparator-NAME' not in KNOWN_CAPABILITIES).
44
44
  *
45
45
  * Match-type wildcards: `:matches` uses `*` (any sequence) and `?`
46
- * (one byte), per RFC 5228 §2.7.1. Both wildcards are converted to
47
- * a bounded RegExp built from escaped literal byte segments — no
48
- * user-controlled backtracking surface.
46
+ * (one byte), per RFC 5228 §2.7.1. The pattern is matched directly
47
+ * against the value rather than translated into a regular
48
+ * expression, so a script author cannot spend the delivery thread's
49
+ * CPU by writing a pattern that a backtracking engine explores.
49
50
  *
50
51
  * The interpreter does NOT execute multi-script chains, sieve
51
52
  * `include`s (RFC 6609), `notify` actions (RFC 5435), or `vacation`
@@ -63,7 +64,6 @@ var safeSieve = require("./safe-sieve");
63
64
  var { defineClass } = require("./framework-error");
64
65
  var numericBounds = require("./numeric-bounds");
65
66
  var validateOpts = require("./validate-opts");
66
- var codepointClass = require("./codepoint-class");
67
67
 
68
68
  var MailSieveError = defineClass("MailSieveError", { alwaysPermanent: true });
69
69
 
@@ -124,22 +124,64 @@ function _envelopeAddresses(env, key) {
124
124
 
125
125
  // ---- match-type ---------------------------------------------------------
126
126
 
127
- function _escapeRe(s) {
128
- return codepointClass.escapeRegExp(s);
127
+ // RFC 5228 §2.7.1 — `*` matches any sequence, `?` matches exactly one. Matched
128
+ // directly rather than translated into a regular expression.
129
+ //
130
+ // The translation is the obvious implementation and it is a denial of service.
131
+ // `*` becomes `.*`, so a pattern with N stars hands a backtracking engine N
132
+ // independent `.*` groups, and on a subject that never supplies the trailing
133
+ // literal the engine tries every way of dividing the subject between them: the
134
+ // cost is polynomial in the subject length with degree N. Three stars measured
135
+ // x7.7 per doubling of the subject, and ten stars — which a mailbox owner can
136
+ // type without meaning anything by it — did not finish on a 64-character
137
+ // Subject. A Sieve script is USER-authored in most deployments, and the gas
138
+ // budget does not help: gas counts operations, and one match is one operation
139
+ // however long the engine spends inside it.
140
+ //
141
+ // This walks the two strings with a single remembered wildcard position to fall
142
+ // back to. It is not linear: `*` followed by a long literal, matched against a
143
+ // subject that keeps almost matching it, re-walks the literal from each new
144
+ // alignment, so the worst case is the product of the two lengths. Measured at
145
+ // 3.9ms for a 500-character pattern against 2,000 characters, 11.4ms at
146
+ // 1,000 x 4,000, and 52.2ms at 2,000 x 8,000 — the quadratic shape, and a
147
+ // bound that both the script size and the header size already cap.
148
+ //
149
+ // What it is NOT is a function of how many wildcards the pattern contains,
150
+ // which is the property that mattered: the regex translation was polynomial
151
+ // with degree equal to the wildcard count, so a tenth `*` cost another factor
152
+ // of the subject length and a short Subject stopped returning at all.
153
+ // `i;ascii-casemap` folds US-ASCII A-Z to a-z and leaves every other character
154
+ // alone (RFC 4790 §9.2), which is also the only fold that preserves length.
155
+ // `String.prototype.toLowerCase` does not: `"İ".toLowerCase()` is two
156
+ // UTF-16 units, so folding the whole subject up front would shift every
157
+ // position after it and leave `?` — which matches exactly one character —
158
+ // facing two.
159
+ function _asciiLower(ch) {
160
+ var c = ch.charCodeAt(0);
161
+ return (c >= 0x41 && c <= 0x5A) ? String.fromCharCode(c + 0x20) : ch;
129
162
  }
130
163
 
131
- function _wildcardToRe(pattern, caseInsensitive) {
132
- // RFC 5228 §2.7.1 — `*` matches any sequence, `?` matches one. Escape
133
- // every other regex meta. Anchored both ends.
134
- var out = "^";
135
- for (var i = 0; i < pattern.length; i++) {
136
- var c = pattern[i];
137
- if (c === "*") out += ".*";
138
- else if (c === "?") out += ".";
139
- else out += _escapeRe(c);
140
- }
141
- out += "$";
142
- return new RegExp(out, caseInsensitive ? "i" : ""); // allow:dynamic-regex built from operator Sieve `:matches` pattern; every meta-char except `*`/`?` is regex-escaped, so the resulting NFA is linear in input length (no polynomial-backtrack surface)
164
+ function _wildcardMatches(pattern, subject, caseInsensitive) {
165
+ var pat = pattern, sub = subject;
166
+ var p = 0, s = 0;
167
+ var starP = -1, starS = 0;
168
+ function same(a, bChar) {
169
+ return caseInsensitive ? _asciiLower(a) === _asciiLower(bChar) : a === bChar;
170
+ }
171
+
172
+ while (s < sub.length) {
173
+ if (p < pat.length && (pat[p] === "?" || same(pat[p], sub[s]))) { p += 1; s += 1; continue; }
174
+ if (p < pat.length && pat[p] === "*") { starP = p; starS = s; p += 1; continue; }
175
+ // No match here. If a `*` came earlier, give it one more character and
176
+ // resume from just after it; otherwise the subject cannot match.
177
+ if (starP === -1) return false;
178
+ starS += 1;
179
+ s = starS;
180
+ p = starP + 1;
181
+ }
182
+ // Trailing stars can absorb the empty remainder; anything else cannot.
183
+ while (p < pat.length && pat[p] === "*") p += 1;
184
+ return p === pat.length;
143
185
  }
144
186
 
145
187
  function _matches(haystack, needle, matchType, comparator) {
@@ -155,7 +197,7 @@ function _matches(haystack, needle, matchType, comparator) {
155
197
  : haystack.indexOf(needle) !== -1;
156
198
  }
157
199
  if (matchType === "matches") {
158
- return _wildcardToRe(needle, ci).test(haystack);
200
+ return _wildcardMatches(needle, haystack, ci);
159
201
  }
160
202
  throw new MailSieveError("mail-sieve/bad-match-type",
161
203
  "unknown match-type: " + matchType);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.18.49",
3
+ "version": "0.18.50",
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:a6344adf-71aa-4268-ba0f-0073a6ead16a",
5
+ "serialNumber": "urn:uuid:0535e946-47d0-4e95-879e-65d801bc533b",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-22T18:41:08.842Z",
8
+ "timestamp": "2026-08-22T21:30:23.773Z",
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.18.49",
22
+ "bom-ref": "@blamejs/core@0.18.50",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.18.49",
25
+ "version": "0.18.50",
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.18.49",
29
+ "purl": "pkg:npm/%40blamejs/core@0.18.50",
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.18.49",
57
+ "ref": "@blamejs/core@0.18.50",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]