@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.
- package/CHANGELOG.md +82 -0
- package/NOTICE +1 -1
- package/README.md +1 -1
- package/lib/ai-input.js +25 -3
- package/lib/db-query.js +32 -4
- package/lib/gate-contract.js +11 -2
- package/lib/guard-filename.js +6 -2
- package/lib/guard-jwt.js +2 -2
- package/lib/json-path.js +54 -4
- package/lib/json-schema.js +212 -10
- package/lib/mail-bimi.js +279 -6
- package/lib/mail-scan.js +21 -1
- package/lib/mail-server-submission.js +8 -1
- package/lib/mail-sieve.js +61 -19
- package/lib/markup-tokenizer.js +88 -0
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +835 -41
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/json-schema.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 —
|
|
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
|
|
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
|
-
|
|
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
|
+
// `svg` 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
|
|
1012
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
}
|