@blamejs/core 0.18.49 → 0.18.51

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.
@@ -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
 
@@ -137,10 +141,52 @@ function _unescapePointerToken(t) { return t.replace(/~1/g, "/").replace(/~0/g,
137
141
 
138
142
  // --- registry: indexes every subschema by canonical URI + anchors ---
139
143
 
140
- function _Registry() { this.schemas = {}; this.dynamicAnchors = {}; this.baseByNode = new Map(); }
144
+ // A ceiling on how many subschema POSITIONS one registry will index, across
145
+ // every document added to it.
146
+ //
147
+ // This is the total-work half of the bound, and it is a different guarantee
148
+ // from a depth ceiling: depth limits how far one path runs, and says nothing
149
+ // about how many paths there are. The walk below cannot use identity tracking
150
+ // instead, because it indexes each node by `base#pointer` and a shared object
151
+ // legitimately has one entry per pointer that reaches it — skipping the second
152
+ // visit would drop a `$ref` target rather than save work. So what gets bounded
153
+ // is the number of entries, which is the thing that actually grows.
154
+ //
155
+ // A schema graph that reaches one object through a branching position —
156
+ // `anyOf: [shared, shared]`, which is what building a schema out of reused
157
+ // JavaScript objects produces, and which needs no `$ref` — has 2^depth pointers
158
+ // over a handful of objects. A cyclic one has no end at all. Neither is refused
159
+ // by a depth cap.
160
+ //
161
+ // Far above any authored schema: the JSON Schema meta-schema is on the order of
162
+ // a hundred positions, and an OpenAPI document of a thousand endpoints is tens
163
+ // of thousands. A schema that reaches this is not one someone wrote out.
164
+ var MAX_REGISTRY_NODES = 100000; // subschema positions, not bytes
165
+
166
+ // How deep a schema may nest before it is refused. This is the OTHER half of
167
+ // the bound, and it is not the node ceiling in disguise: a chain ten thousand
168
+ // levels deep is ten thousand positions, far under that ceiling, and still runs
169
+ // the JavaScript stack out. What comes back then is `RangeError: Maximum call
170
+ // stack size exceeded` — the engine's error, which says nothing about the
171
+ // schema and is not catchable as a JsonSchemaError.
172
+ //
173
+ // It THROWS rather than stopping the walk. A walk that silently returns at the
174
+ // ceiling leaves a node looking examined when its descendants were skipped,
175
+ // which is how a pattern below one escapes screening entirely. Refusing is the
176
+ // only reading that cannot be mistaken for completion.
177
+ //
178
+ // Well above anything authored — the JSON Schema meta-schema nests about a
179
+ // dozen levels and an OpenAPI document a few dozen — and well below where the
180
+ // stack gives out.
181
+ var MAX_SCHEMA_NESTING = 1000; // nesting levels, not bytes
182
+
183
+ function _Registry() {
184
+ this.schemas = {}; this.dynamicAnchors = {}; this.baseByNode = new Map();
185
+ this.nodesIndexed = 0;
186
+ }
141
187
 
142
188
  _Registry.prototype.add = function (schema, baseUri) {
143
- this._walk(schema, baseUri || "", "");
189
+ this._walk(schema, baseUri || "", "", null, 0);
144
190
  // A document retrieved from URI X is addressable by X even when its own
145
191
  // $id is a different (canonical) URI — register the retrieval URI too.
146
192
  if (baseUri && (_isObject(schema) || typeof schema === "boolean")) {
@@ -152,10 +198,43 @@ _Registry.prototype.add = function (schema, baseUri) {
152
198
  // Walk a schema document, registering $id base changes, $anchor and
153
199
  // $dynamicAnchor names, and indexing every subschema by its base URI +
154
200
  // JSON-pointer fragment.
155
- _Registry.prototype._walk = function (node, baseUri, pointer) {
201
+ _Registry.prototype._walk = function (node, baseUri, pointer, path, depth) {
156
202
  if (!_isObject(node) && typeof node !== "boolean") return;
203
+ if (depth > MAX_SCHEMA_NESTING) {
204
+ throw new JsonSchemaError("json-schema/schema-too-deep",
205
+ "jsonSchema: schema nests deeper than " + MAX_SCHEMA_NESTING +
206
+ " levels — deeper than the walk can index without exhausting the stack");
207
+ }
208
+ this.nodesIndexed += 1;
209
+ if (this.nodesIndexed > MAX_REGISTRY_NODES) {
210
+ throw new JsonSchemaError("json-schema/schema-too-large",
211
+ "jsonSchema: schema indexes more than " + MAX_REGISTRY_NODES +
212
+ " subschema positions — a shared or cyclic schema graph reaches one " +
213
+ "object through many pointers; give the reused subschema an $id and " +
214
+ "reference it with $ref instead of embedding the same object twice");
215
+ }
157
216
  if (typeof node === "boolean") { this.schemas[baseUri + "#" + pointer] = node; return; }
158
217
 
218
+ // A node already on the ACTIVE PATH is a cycle, and a cycle with one
219
+ // reference per level never reaches the ceiling above: it does not branch, so
220
+ // it recurses straight down and exhausts the JavaScript stack while the count
221
+ // is still in the thousands, surfacing as a RangeError from the engine rather
222
+ // than the refusal this validator documents.
223
+ //
224
+ // The test is the path and not a depth cap, because deep is not the same as
225
+ // cyclic: a schema nesting four hundred levels with no cycle and no `$ref`
226
+ // compiles today, and a depth cap would refuse it — under a `ref-loop` code
227
+ // naming a reference chain it does not have. The path set answers the
228
+ // question actually being asked, and leaves depth alone.
229
+ path = path || new Set();
230
+ var isOwnAncestor = path.has(node);
231
+ if (isOwnAncestor) {
232
+ throw new JsonSchemaError("json-schema/ref-loop",
233
+ "jsonSchema: schema contains a cycle — a subschema is its own ancestor, " +
234
+ "so indexing it has no end; break the cycle with $id + $ref");
235
+ }
236
+ path.add(node);
237
+
159
238
  var thisBase = baseUri;
160
239
  if (typeof node.$id === "string") {
161
240
  thisBase = _resolveUri(node.$id, baseUri);
@@ -187,7 +266,7 @@ _Registry.prototype._walk = function (node, baseUri, pointer) {
187
266
  // Recurse. Keywords whose values are schemas vs maps-of-schemas vs
188
267
  // arrays-of-schemas are walked with the right shape.
189
268
  var self = this;
190
- function child(key, sub, ptr) { self._walk(sub, thisBase, ptr); }
269
+ function child(key, sub, ptr) { self._walk(sub, thisBase, ptr, path, depth + 1); }
191
270
  SCHEMA_KEYWORDS.forEach(function (k) {
192
271
  if (node[k] !== undefined) child(k, node[k], pointer + "/" + k);
193
272
  });
@@ -203,6 +282,12 @@ _Registry.prototype._walk = function (node, baseUri, pointer) {
203
282
  node[k].forEach(function (sub, idx) { child(k, sub, pointer + "/" + k + "/" + idx); });
204
283
  }
205
284
  });
285
+
286
+ // Off the active path on the way out. A node reached again through a SIBLING
287
+ // branch is shared rather than cyclic, and indexing it under its second
288
+ // pointer is the correct thing to do — leaving it in the set would call that
289
+ // a cycle and refuse a schema that merely reuses a subschema.
290
+ path.delete(node);
206
291
  };
207
292
 
208
293
  _Registry.prototype.resolve = function (uri) {
@@ -251,7 +336,13 @@ var SCHEMA_KEYWORDS = ["additionalProperties", "propertyNames", "items",
251
336
  "unevaluatedProperties"];
252
337
  var SCHEMA_MAP_KEYWORDS = ["$defs", "definitions", "properties",
253
338
  "patternProperties", "dependentSchemas"];
254
- var SCHEMA_ARRAY_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems"];
339
+ // `items` appears in BOTH lists on purpose. Draft 2020-12 makes it a single
340
+ // schema, and the legacy tuple form makes it an array of them; each branch
341
+ // checks the type it wants, so exactly one of them walks any given `items`.
342
+ // Listed only as a single schema, an array-valued one was dropped at the
343
+ // `_isObject` test at the top of the walk and never indexed at all — so the
344
+ // subschemas inside a tuple were unreachable by `$ref`, and unbudgeted.
345
+ var SCHEMA_ARRAY_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems", "items"];
255
346
 
256
347
  module.exports = _buildModule();
257
348
 
@@ -288,6 +379,81 @@ function _buildModule() {
288
379
  * properties: { n: { type: "integer" } }, required: ["n"] });
289
380
  * v.validate({ n: 1 }).valid; // → true
290
381
  */
382
+ // Where a subschema can appear. Named rather than inferred, because a schema
383
+ // also carries DATA — `const`, `enum`, `default` and `examples` hold instance
384
+ // values — and a walk that recursed into everything would read a `pattern` key
385
+ // inside a `const` value as a schema keyword and refuse a legal schema for the
386
+ // shape of its example data.
387
+ //
388
+ // Missing a position here is safe in the direction that matters: the screen in
389
+ // _compileRegex still runs on whatever is actually compiled, so a subschema
390
+ // this walk does not reach is refused when it is used rather than never. The
391
+ // walk buys an earlier refusal, not the refusal itself.
392
+ // `additionalItems` and `contentSchema` are deliberately absent: this validator
393
+ // does not evaluate either keyword, so a pattern inside one never compiles and
394
+ // never runs. Screening there could only refuse a schema for a branch that has
395
+ // no effect.
396
+ var _SUBSCHEMA_KEYS = ["additionalProperties", "contains",
397
+ "else", "if", "items", "not", "propertyNames", "then",
398
+ "unevaluatedItems", "unevaluatedProperties"];
399
+ var _SUBSCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems", "items"];
400
+ var _SUBSCHEMA_MAP_KEYS = ["$defs", "definitions", "dependentSchemas",
401
+ "patternProperties", "properties"];
402
+
403
+ // Walk the subschema positions for the two keywords that carry a regular
404
+ // expression, and hand each to the same screen the compile site uses.
405
+ //
406
+ // Every object is visited ONCE, tracked by identity. The depth ceiling below is
407
+ // not a substitute for that and never was: it limits how far a single path runs
408
+ // and says nothing about how many paths there are. A schema graph reaching one
409
+ // object through a branching position — `anyOf: [shared, shared]`, which is what
410
+ // building a schema in JavaScript out of reused subschema objects produces, and
411
+ // which needs no `$ref` — was walked once per path. Twenty-one objects cost
412
+ // 2.6 seconds, doubling per added level, against a cap that allows 256; a cyclic
413
+ // schema never returned. This screen exists so a pattern cannot make the
414
+ // validator hang, and SECURITY.md promises a screen costs the length of its
415
+ // input and never a function of its shape, so a walk that is itself a function
416
+ // of shape contradicts the thing it was added to guarantee.
417
+ //
418
+ // Identity tracking bounds the total work by the number of distinct objects,
419
+ // which is the length of the schema — so the promise holds without a separate
420
+ // node budget.
421
+ //
422
+ // The nesting ceiling here THROWS, and that distinction is the whole point. A
423
+ // ceiling that silently stopped walking would leave a node looking examined
424
+ // when its descendants were skipped, so a later and shallower path would find
425
+ // it already marked and skip it too — and a catastrophic pattern below it would
426
+ // never be screened at all, which is the opposite of what this walk is for.
427
+ // Refusing cannot be mistaken for completion.
428
+ function _screenPatterns(node, seen, depth) {
429
+ if (!_isObject(node)) return;
430
+ if (depth > MAX_SCHEMA_NESTING) {
431
+ throw new JsonSchemaError("json-schema/schema-too-deep",
432
+ "jsonSchema: schema nests deeper than " + MAX_SCHEMA_NESTING +
433
+ " levels — deeper than the pattern screen can walk without exhausting " +
434
+ "the stack");
435
+ }
436
+ seen = seen || new WeakSet();
437
+ if (seen.has(node)) return;
438
+ seen.add(node);
439
+
440
+ if (typeof node.pattern === "string") _compileRegex(node.pattern);
441
+ if (_isObject(node.patternProperties)) {
442
+ Object.keys(node.patternProperties).forEach(function (p) { _compileRegex(p); });
443
+ }
444
+
445
+ var next = (depth || 0) + 1;
446
+ _SUBSCHEMA_KEYS.forEach(function (k) { _screenPatterns(node[k], seen, next); });
447
+ _SUBSCHEMA_LIST_KEYS.forEach(function (k) {
448
+ if (!Array.isArray(node[k])) return;
449
+ node[k].forEach(function (sub) { _screenPatterns(sub, seen, next); });
450
+ });
451
+ _SUBSCHEMA_MAP_KEYS.forEach(function (k) {
452
+ if (!_isObject(node[k])) return;
453
+ Object.keys(node[k]).forEach(function (name) { _screenPatterns(node[k][name], seen, next); });
454
+ });
455
+ }
456
+
291
457
  function compile(schema, opts) {
292
458
  opts = opts || {};
293
459
  if (!_isObject(schema) && typeof schema !== "boolean") {
@@ -304,6 +470,15 @@ function compile(schema, opts) {
304
470
  var rootBase = (_isObject(schema) && typeof schema.$id === "string") ? _resolveUri(schema.$id, "") : "";
305
471
  registry.add(schema, rootBase);
306
472
 
473
+ // Screen every pattern the schema carries HERE, while the author is still
474
+ // looking at the schema. Compiling is where a bad schema should be refused;
475
+ // leaving it to the first instance means the refusal lands on a request, in
476
+ // a validator the caller has already been handed and treats as good.
477
+ _screenPatterns(schema);
478
+ if (_isObject(opts.schemas)) {
479
+ Object.keys(opts.schemas).forEach(function (uri) { _screenPatterns(opts.schemas[uri]); });
480
+ }
481
+
307
482
  var assertFormat = opts.assertFormat === true;
308
483
  var maxErrors = numericBounds.isPositiveFiniteInt(opts.maxErrors) ? opts.maxErrors : DEFAULT_MAX_ERRORS;
309
484
 
@@ -525,15 +700,42 @@ function _checkString(schema, s, ctx, emit) {
525
700
  }
526
701
  }
527
702
 
528
- var _regexCache = {};
703
+ // Keyed by the schema's own pattern text, so a long-lived process compiling
704
+ // many distinct schemas would otherwise grow this without bound. The
705
+ // framework's own ceiling primitive owns the eviction.
706
+ var _regexCache = boundedMap.boundedMap({ maxEntries: 512 });
529
707
  function _compileRegex(pattern, ctx) {
530
- if (Object.prototype.hasOwnProperty.call(_regexCache, pattern)) return _regexCache[pattern];
708
+ if (_regexCache.has(pattern)) return _regexCache.get(pattern);
709
+ // `pattern` / `patternProperties` decide how much CPU each validation costs,
710
+ // because the compiled expression runs against the instance. `(a+)+$` against
711
+ // a run of `a` ending in anything else measured 1.5 seconds at 28 characters
712
+ // and doubles with every two more, so a schema is a denial-of-service lever
713
+ // as much as a contract.
714
+ //
715
+ // A schema is not automatically the operator's own source: it arrives from a
716
+ // registry, a tool manifest, an upload or a config file. b.mcp already
717
+ // screens the pattern in a tool schema for this shape before matching it
718
+ // against request input, and this is the same construct.
719
+ //
720
+ // Screened once per distinct pattern — the cache below means a schema pays
721
+ // for the analysis at its first use rather than per instance.
722
+ //
723
+ // Compiled first and screened AFTER, on the compiled form, which is what
724
+ // b.safeJson does with the same keyword and for the reason recorded there:
725
+ // the flags decide what the source means, so a screen reading the source
726
+ // alone reads `(a|A)+` as two disjoint branches where the engine sees one
727
+ // branch twice. Compiling a pathological pattern is safe on its own — the
728
+ // cost is in matching, which has not happened yet.
531
729
  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
730
+ 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
731
  catch (_e) {
534
- try { re = new RegExp(pattern); } catch (_e2) { re = null; }
732
+ try { re = new RegExp(pattern); } catch (_e2) { re = null; } // allow:dynamic-regex — same screen, non-unicode fallback for a pattern `u` rejects
733
+ }
734
+ if (re) {
735
+ guardRegex().assertSafe(re, "jsonSchema.pattern",
736
+ JsonSchemaError, "json-schema/unsafe-pattern");
535
737
  }
536
- _regexCache[pattern] = re;
738
+ _regexCache.set(pattern, re);
537
739
  return re;
538
740
  }
539
741
 
package/lib/mail-bimi.js CHANGED
@@ -54,6 +54,7 @@ var nodeCrypto = require("node:crypto");
54
54
 
55
55
  var asn1 = require("./asn1-der");
56
56
  var C = require("./constants");
57
+ var codepointClass = require("./codepoint-class");
57
58
  var pick = require("./pick");
58
59
  var httpClient = require("./http-client");
59
60
  var lazyRequire = require("./lazy-require");
@@ -87,6 +88,57 @@ var BIMI_RECORD_MAX_BYTES = C.BYTES.kib(2);
87
88
  // refused at validate-time before any tokenization.
88
89
  var TINY_PS_MAX_BYTES = C.BYTES.kib(32);
89
90
 
91
+ // The SVG namespace, which a root element carrying a prefix must bind that
92
+ // prefix to before the document is treated as a logo.
93
+ var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
94
+
95
+ // An XML namespace prefix is an NCName: XML 1.0's Name grammar without the
96
+ // colon. These are the 5th-edition NameStartChar ranges and the characters
97
+ // NameChar adds once a name has started, written as CODE POINTS rather than as
98
+ // a regular-expression character class for two reasons. Most of the boundaries
99
+ // do not render, so a literal class would put invisible characters in this
100
+ // file; and the grammar reaches past the basic plane, where a class over UTF-16
101
+ // code units cannot follow it.
102
+ //
103
+ // "Non-ASCII" is not the rule, and treating it as one is what this replaces.
104
+ // NameStartChar deliberately omits the C1 controls, most punctuation and the
105
+ // surrogate block, so a prefix built from those is not a name however faithfully
106
+ // the document then declares it.
107
+ var NCNAME_START_RANGES = [
108
+ [0x41, 0x5A], 0x5F, [0x61, 0x7A],
109
+ [0xC0, 0xD6], [0xD8, 0xF6], [0xF8, 0x2FF],
110
+ [0x370, 0x37D], [0x37F, 0x1FFF],
111
+ [0x200C, 0x200D], [0x2070, 0x218F], [0x2C00, 0x2FEF],
112
+ [0x3001, 0xD7FF], [0xF900, 0xFDCF], [0xFDF0, 0xFFFD],
113
+ [0x10000, 0xEFFFF],
114
+ ];
115
+
116
+ var NCNAME_TAIL_RANGES = [
117
+ 0x2D, 0x2E, [0x30, 0x39], 0xB7, [0x300, 0x36F], [0x203F, 0x2040],
118
+ ];
119
+
120
+ // Stepped by CODE POINT, so an astral name character is read as the single
121
+ // character it is, and a lone surrogate is read as itself — which is in no
122
+ // range, so it is refused.
123
+ function _isNcName(s) {
124
+ if (s.length === 0) return false;
125
+ var started = false;
126
+ for (var i = 0; i < s.length; ) {
127
+ var cp = s.codePointAt(i);
128
+ i += cp > 0xFFFF ? 2 : 1;
129
+ var ok = codepointClass.inRanges(cp, NCNAME_START_RANGES) ||
130
+ (started && codepointClass.inRanges(cp, NCNAME_TAIL_RANGES));
131
+ if (!ok) return false;
132
+ started = true;
133
+ }
134
+ return true;
135
+ }
136
+
137
+ // Superseded by _isNcName above. Not re-typed on the way out: two of this
138
+ // class's bounds are characters that do not render, so what follows is left
139
+ // exactly as it was rather than reconstructed from a reading of it.
140
+ // var NC_NAME_RE = /^[A-Za-z_€-￿][A-Za-z0-9._€-￿-]*$/;
141
+
90
142
  // VMC / CMC fetch cap. Production VMCs are typically ~10-20 KiB;
91
143
  // 256 KiB is a generous ceiling that still bounds the download against
92
144
  // pathological responses. Operators with a stricter posture pass
@@ -571,7 +623,13 @@ function _tokenizeTinyPsSvg(s) {
571
623
  // values are still accepted (the SVG profile is permissive on quoting).
572
624
  function _parseTinyPsAttrs(src) {
573
625
  var attrs = {};
574
- var re = /([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/g;
626
+ // The lookbehind is what keeps this linear, and it is also what XML means: an
627
+ // attribute name starts after something that is not a name character. Without
628
+ // it the name can begin at every offset inside a long run of name characters,
629
+ // consuming the run each time before `\s*=\s*` fails — 397ms on a 32 KiB
630
+ // input against 0.6ms for a well-formed SVG of the same size. The bytes come
631
+ // from a logo fetched at a URL in the sender's own DNS record.
632
+ var re = /(?<![A-Za-z0-9:._-])([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/g;
575
633
  var m;
576
634
  while ((m = re.exec(src)) !== null) {
577
635
  var name = m[1];
@@ -1002,17 +1060,232 @@ function _extractBimiCertPolicy(cert) {
1002
1060
  return rv;
1003
1061
  }
1004
1062
 
1063
+ // Is this leaf an SVG document — that is, does its root element come next once
1064
+ // everything XML permits ahead of a root has been stepped over?
1065
+ //
1066
+ // A prefix window cannot answer that. XML puts no length on the prologue, so
1067
+ // whichever number is chosen a legal document can put its root past it; the
1068
+ // first attempt here read 64 characters and dropped any logo carrying the usual
1069
+ // SVG 1.1 DOCTYPE, whose root sits at 154. What XML does bound is the KIND of
1070
+ // thing allowed before the root: whitespace (markup whitespace covers the
1071
+ // byte-order mark too), the declaration, processing instructions, comments and
1072
+ // a DOCTYPE. Stepping over those is both more permissive than any window and
1073
+ // stricter than a substring search, which called a document an SVG because a
1074
+ // comment mentioned one.
1075
+ //
1076
+ // Every branch advances the cursor past a complete construct, so the walk is
1077
+ // linear in the leaf and an unterminated construct ends it rather than
1078
+ // restarting a scan.
1079
+ // One past the `>` that closes a DOCTYPE declaration opening at `at`, or -1 if
1080
+ // it never closes.
1081
+ //
1082
+ // Every character the declaration can end on — `>`, and the `[`/`]` of the
1083
+ // internal subset — is also a character it may legally CONTAIN, inside a quoted
1084
+ // external identifier, a quoted entity value, or a comment. So none of them can
1085
+ // be located by searching for the next occurrence:
1086
+ //
1087
+ // <!DOCTYPE svg SYSTEM "urn:logo>v1"> the `>` is in the URI
1088
+ // <!DOCTYPE svg SYSTEM "urn:logo[v1"> the `[` opens no subset
1089
+ // <!DOCTYPE svg [ <!ENTITY x "a]b"> ]> the `]` is in the value
1090
+ //
1091
+ // Each of those was a separate defect while the ends were found with separate
1092
+ // searches. They are one defect: the declaration ends at the first `>` that is
1093
+ // not inside a quoted literal, a comment, or the subset. Reading it once, with
1094
+ // that rule, answers all of them and the ones not listed. Every branch advances,
1095
+ // so the walk is linear in the declaration.
1096
+ // One past a quoted literal opening at `j`; 0 when none opens there, -1 when
1097
+ // one opens and never closes. Same three-valued answer for the two helpers
1098
+ // below, so a caller reads them the same way.
1099
+ function _skipQuotedLiteral(text, j) {
1100
+ var ch = text.charAt(j);
1101
+ if (ch !== "\"" && ch !== "'") return 0;
1102
+ var end = text.indexOf(ch, j + 1);
1103
+ return end === -1 ? -1 : end + 1;
1104
+ }
1105
+
1106
+ // One past a comment or a processing instruction opening at `j`. These are the
1107
+ // two constructs that may appear almost anywhere in a prologue and may contain
1108
+ // anything, so every scan that looks for a delimiter has to step over them —
1109
+ // and each must step over the SAME set. Teaching one scanner about processing
1110
+ // instructions and not its neighbour is what produced this function: the
1111
+ // declaration knew about them and the internal subset did not, so a legal
1112
+ // `<?meta value]?>` inside a subset ended it early.
1113
+ function _skipCommentOrPi(text, j) {
1114
+ if (text.startsWith("<!--", j)) {
1115
+ // XML rules, not HTML: a certificate payload is an XML document, and the
1116
+ // HTML reader closes a comment at `--!>` and abruptly at `<!-->`, neither
1117
+ // of which XML has. Reading one grammar with the other's rules disagrees
1118
+ // about where the document begins — it would take the text after an early
1119
+ // close for a root element, and would end a comment XML says is open.
1120
+ var endComment = markupTokenizer.xmlCommentEnd(text, j);
1121
+ return endComment === -1 ? -1 : endComment;
1122
+ }
1123
+ if (text.startsWith("<?", j)) {
1124
+ var endPi = text.indexOf("?>", j + 2);
1125
+ return endPi === -1 ? -1 : endPi + 2;
1126
+ }
1127
+ return 0;
1128
+ }
1129
+
1130
+ function _endOfDoctype(text, at) {
1131
+ var j = at + 9; // past "<!DOCTYPE"
1132
+ while (j < text.length) {
1133
+ if (text.charAt(j) === ">") return j + 1;
1134
+ if (text.charAt(j) === "[") {
1135
+ var endSubset = _endOfInternalSubset(text, j);
1136
+ if (endSubset === -1) return -1;
1137
+ j = endSubset + 1;
1138
+ continue;
1139
+ }
1140
+ var skipped = _skipQuotedLiteral(text, j);
1141
+ if (skipped === 0) skipped = _skipCommentOrPi(text, j);
1142
+ if (skipped === -1) return -1;
1143
+ j = skipped === 0 ? j + 1 : skipped;
1144
+ }
1145
+ return -1;
1146
+ }
1147
+
1148
+ // The index of the `]` that closes a DOCTYPE's internal subset, given the index
1149
+ // of its `[`, or -1 if it never closes. Same rule as the declaration around it:
1150
+ // quoted runs and comments are stepped over rather than searched through.
1151
+ function _endOfInternalSubset(text, at) {
1152
+ var j = at + 1;
1153
+ while (j < text.length) {
1154
+ if (text.charAt(j) === "]") return j;
1155
+ var skipped = _skipQuotedLiteral(text, j);
1156
+ if (skipped === 0) skipped = _skipCommentOrPi(text, j);
1157
+ if (skipped === -1) return -1;
1158
+ j = skipped === 0 ? j + 1 : skipped;
1159
+ }
1160
+ return -1;
1161
+ }
1162
+
1163
+ // The characters that can END a tag name: whitespace before the attributes, the
1164
+ // slash of an empty element, or the tag's own close. A closed set, unlike the
1165
+ // set of characters that may CONTINUE an XML name.
1166
+ function _endsTagName(text, at) {
1167
+ var ch = text.charAt(at);
1168
+ return ch === ">" || ch === "/" || markupTokenizer.isMarkupSpace(text.charCodeAt(at));
1169
+ }
1170
+
1171
+ // Does the text between the root's name and its `>` read as a start tag?
1172
+ //
1173
+ // The one structural rule worth testing here is where a `/` may appear: XML
1174
+ // allows it in exactly one position, against the `>`, closing an empty element.
1175
+ // Anywhere else outside a quoted value the text is not a start tag — both
1176
+ // `<x:svg xmlns:x="…"/garbage>` and `<x:svg xmlns:x="…"/ >` have a closing
1177
+ // bracket and a perfectly good namespace declaration, and neither is one.
1178
+ //
1179
+ // Quotes are tracked because a `/` inside an attribute value is data: every
1180
+ // namespace URI here contains several.
1181
+ //
1182
+ // This is deliberately NOT a full attribute-grammar check. The function it
1183
+ // serves decides WHICH ASN.1 leaf holds the logo; whether the document is
1184
+ // conformant is validateTinyPsSvg's question, and duplicating that here would
1185
+ // be a second, weaker parser drifting out of step with the real one.
1186
+ function _tagBodyIsWellFormed(text, from, tagEnd) {
1187
+ var quote = "";
1188
+ for (var i = from; i < tagEnd; i += 1) {
1189
+ var ch = text.charAt(i);
1190
+ if (quote) { if (ch === quote) quote = ""; continue; }
1191
+ if (ch === "\"" || ch === "'") { quote = ch; continue; }
1192
+ if (ch !== "/") continue;
1193
+ // A slash outside a quoted value closes an empty element, and XML's
1194
+ // EmptyElemTag is `S? '/>'` — the space may come BEFORE the slash and not
1195
+ // after it, so the slash has to sit against the bracket.
1196
+ return i + 1 === tagEnd;
1197
+ }
1198
+ // An unterminated quote means the tag never really ended either.
1199
+ return quote === "";
1200
+ }
1201
+
1202
+ function _svgRootFollowsPrologue(text) {
1203
+ var i = 0;
1204
+ for (;;) {
1205
+ i = markupTokenizer.skipMarkupSpace(text, i);
1206
+
1207
+ // Comments and processing instructions, through the same definitions the
1208
+ // two DOCTYPE scanners use. The XML declaration is a processing instruction
1209
+ // as far as finding its end goes.
1210
+ var aside = _skipCommentOrPi(text, i);
1211
+ if (aside === -1) return false;
1212
+ if (aside !== 0) { i = aside; continue; }
1213
+
1214
+ if (text.startsWith("<!DOCTYPE", i) || text.startsWith("<!doctype", i)) {
1215
+ var endDoctype = _endOfDoctype(text, i);
1216
+ if (endDoctype === -1) return false;
1217
+ i = endDoctype;
1218
+ continue;
1219
+ }
1220
+ break;
1221
+ }
1222
+
1223
+ if (text.charAt(i) !== "<") return false;
1224
+
1225
+ // Read the whole root name and compare its LOCAL part.
1226
+ //
1227
+ // Two things this gets right that a `startsWith("<svg")` does not. `<svgfoo`
1228
+ // and `<svg.foo` are different elements — and asking instead "does a name
1229
+ // character follow?" is only as good as the character list, since XML's name
1230
+ // grammar runs well past the ASCII set any such list holds, `.` alone being
1231
+ // enough to let `<svg.foo` through. And an SVG may bind its own namespace to
1232
+ // a prefix and write the root `<svg:svg>`, which is the same element.
1233
+ //
1234
+ // What is deliberately NOT done is resolving the prefix to a namespace URI. A
1235
+ // logo that omits `xmlns` altogether is common and was accepted before, so
1236
+ // requiring the binding here would reject real marks; this step is best-effort
1237
+ // detection deciding which leaf to hand back, not validation.
1238
+ var nameEnd = i + 1;
1239
+ while (nameEnd < text.length && !_endsTagName(text, nameEnd)) nameEnd += 1;
1240
+ if (nameEnd >= text.length) return false; // the name never ends: truncated
1241
+ // A qualified name is `prefix:local`, one colon, two parts.
1242
+ // The start tag has to CLOSE. A tag name ends at the whitespace before the
1243
+ // attributes, so a document whose bytes stop mid-tag still has a complete
1244
+ // name — and, if it got as far as a namespace declaration, a perfectly good
1245
+ // binding. That is a truncated document rather than a logo. scanToTagEnd
1246
+ // reports the end of the input when it finds no `>`.
1247
+ var tagEnd = markupTokenizer.scanToTagEnd(text, nameEnd, text.length);
1248
+ if (tagEnd >= text.length) return false;
1249
+ if (!_tagBodyIsWellFormed(text, nameEnd, tagEnd)) return false;
1250
+
1251
+ var parts = text.slice(i + 1, nameEnd).split(":");
1252
+ if (parts.length > 2 || parts[parts.length - 1] !== "svg") return false;
1253
+ // A prefix is an NCName, so a digit or a punctuation character cannot begin
1254
+ // one. Without this a malformed name that declares a matching attribute —
1255
+ // `<0ns:svg xmlns:0ns="...">` — would satisfy the binding check below, since
1256
+ // attribute parsing is as permissive about names as this scan is.
1257
+ if (parts.length === 2 && !_isNcName(parts[0])) return false;
1258
+ // An unprefixed root is accepted without an `xmlns`: logos omit it, and the
1259
+ // scanner this replaces accepted them.
1260
+ if (parts.length === 1) return true;
1261
+
1262
+ // A prefix, though, means nothing until the root binds it. `<x:svg>` where
1263
+ // `x` is another vocabulary is not an SVG, and one declared nowhere names
1264
+ // nothing — so requiring the binding is what separates a namespaced root from
1265
+ // arbitrary XML whose local name happens to read `svg`. It also settles the
1266
+ // malformed names for free: `<a::svg` has too many parts, and `<a<:svg` and
1267
+ // `<0ns:svg` declare no matching `xmlns:` attribute.
1268
+ var attrs = markupTokenizer.parseAttrs(text.slice(nameEnd, tagEnd));
1269
+ var wanted = "xmlns:" + parts[0];
1270
+ for (var a = 0; a < attrs.length; a += 1) {
1271
+ // The value is compared for what it DENOTES: an XML processor resolves
1272
+ // `&#x73;vg` and `svg` to the same namespace, so comparing the lexical form
1273
+ // would reject a root whose namespace is the SVG one.
1274
+ if (attrs[a].name === wanted) {
1275
+ return markupTokenizer.decodeCharRefs(attrs[a].value) === SVG_NAMESPACE;
1276
+ }
1277
+ }
1278
+ return false;
1279
+ }
1280
+
1005
1281
  function _scanForEmbeddedSvg(node, depthBudget) {
1006
1282
  if (!node) return null;
1007
1283
  if (depthBudget < 0) return null;
1008
1284
 
1009
1285
  if (!node.constructed) {
1010
1286
  if (!node.value || node.value.length < 4) return null;
1011
- var prefix = node.value.slice(0, Math.min(node.value.length, 64)).toString("utf8"); /* display truncation length, not bytes */
1012
- if (prefix.indexOf("<svg") !== -1 || /<\?xml[\s\S]*<svg/.test(prefix)) {
1013
- return node.value.toString("utf8");
1014
- }
1015
- return null;
1287
+ var text = node.value.toString("utf8");
1288
+ return _svgRootFollowsPrologue(text) ? text : null;
1016
1289
  }
1017
1290
 
1018
1291
  var children;
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
  }