mailauth 7.1.1 → 7.3.0
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/.c8rc.json +11 -0
- package/.prettierignore +3 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +68 -0
- package/README.md +131 -31
- package/bin/mailauth.js +62 -0
- package/cli.md +150 -8
- package/docs/arc.md +62 -54
- package/docs/bimi.md +153 -54
- package/docs/dkim.md +37 -36
- package/docs/dkim2.md +154 -0
- package/docs/dmarc.md +4 -1
- package/docs/mta-sts.md +12 -9
- package/docs/spf.md +14 -13
- package/index.d.ts +583 -42
- package/lib/arc/index.d.ts +6 -2
- package/lib/arc/index.js +45 -15
- package/lib/bimi/index.js +362 -102
- package/lib/bimi/validate-svg.js +493 -60
- package/lib/commands/dkim2-hash.js +25 -0
- package/lib/commands/dkim2-sign.js +77 -0
- package/lib/commands/dkim2-verify.js +59 -0
- package/lib/commands/report.js +10 -35
- package/lib/commands/resolver.js +51 -0
- package/lib/commands/seal.js +10 -38
- package/lib/commands/spf.js +4 -35
- package/lib/dkim/dkim-signer.js +50 -30
- package/lib/dkim/dkim-verifier.js +79 -27
- package/lib/dkim/header/simple.js +2 -2
- package/lib/dkim/sign.js +7 -2
- package/lib/dkim/syntax.js +11 -0
- package/lib/dkim/verify.js +1 -0
- package/lib/dkim2/dkim2-parser.js +52 -0
- package/lib/dkim2/dkim2-signer.js +334 -0
- package/lib/dkim2/dkim2-verifier.js +572 -0
- package/lib/dkim2/fields.js +570 -0
- package/lib/dkim2/hash.d.ts +3 -0
- package/lib/dkim2/hash.js +51 -0
- package/lib/dkim2/key.js +203 -0
- package/lib/dkim2/message-state.js +181 -0
- package/lib/dkim2/recipe.js +134 -0
- package/lib/dkim2/sign.d.ts +3 -0
- package/lib/dkim2/sign.js +38 -0
- package/lib/dkim2/verify.d.ts +3 -0
- package/lib/dkim2/verify.js +19 -0
- package/lib/dmarc/author-domain.js +270 -5
- package/lib/dmarc/get-dmarc-record.js +24 -11
- package/lib/dmarc/verify.js +7 -3
- package/lib/mailauth.js +57 -14
- package/lib/mta-sts.js +37 -11
- package/lib/parse-received.js +214 -25
- package/lib/spf/index.js +21 -4
- package/lib/spf/macro.js +5 -3
- package/lib/spf/spf-verify.js +14 -5
- package/lib/spf/syntax.js +5 -0
- package/lib/tools.js +160 -21
- package/package.json +15 -6
- package/stryker.config.json +16 -0
package/lib/bimi/index.js
CHANGED
|
@@ -3,18 +3,26 @@
|
|
|
3
3
|
const { Buffer } = require('node:buffer');
|
|
4
4
|
const crypto = require('node:crypto');
|
|
5
5
|
const dns = require('node:dns');
|
|
6
|
-
const
|
|
6
|
+
const net = require('node:net');
|
|
7
|
+
const zlib = require('node:zlib');
|
|
8
|
+
const { formatAuthHeaderRow, parseTagList, formatDomain, getAlignment, createError } = require('../tools');
|
|
7
9
|
const Joi = require('joi');
|
|
8
10
|
const libmime = require('libmime');
|
|
9
|
-
const addressparser = require('nodemailer/lib/addressparser');
|
|
10
11
|
const getDmarcRecord = require('../dmarc/get-dmarc-record');
|
|
11
12
|
const { parseDmarcRecord } = getDmarcRecord;
|
|
13
|
+
const { parseFromHeader } = require('../dmarc/author-domain');
|
|
12
14
|
//const packageData = require('../../package.json');
|
|
13
15
|
const httpsSchema = Joi.string().uri({
|
|
14
16
|
scheme: ['https']
|
|
15
17
|
});
|
|
16
18
|
|
|
17
19
|
const FETCH_TIMEOUT = 5 * 1000;
|
|
20
|
+
// overall time limit for a download, including redirects and the response body
|
|
21
|
+
const DOWNLOAD_TIMEOUT = 30 * 1000;
|
|
22
|
+
const MAX_REDIRECTS = 3;
|
|
23
|
+
// SVG Tiny PS 2.4: an Indicator SHOULD NOT exceed 32 kilobytes, uncompressed
|
|
24
|
+
const MAX_LOGO_SIZE = 64 * 1024;
|
|
25
|
+
const MAX_EVIDENCE_SIZE = 256 * 1024;
|
|
18
26
|
|
|
19
27
|
const { fetch: fetchCmd, Agent } = require('undici');
|
|
20
28
|
const fetchAgent = new Agent({
|
|
@@ -23,12 +31,50 @@ const fetchAgent = new Agent({
|
|
|
23
31
|
|
|
24
32
|
const { vmc } = require('@postalsys/vmc');
|
|
25
33
|
const { validateSvg } = require('./validate-svg');
|
|
34
|
+
const { SELECTOR } = require('../dkim/syntax');
|
|
26
35
|
|
|
27
36
|
// Records that do not start with a "v=" tag for the current version are discarded (7.2 steps 4 and 7)
|
|
28
37
|
const BIMI_VERSION_RE = /^v[ \t]*=[ \t]*BIMI1[ \t]*(?:;|$)/i;
|
|
38
|
+
// 4.3: the value of v= MUST match precisely
|
|
39
|
+
const BIMI_VERSION_STRICT_RE = /^v[ \t]*=[ \t]*BIMI1[ \t]*(?:;|$)/;
|
|
40
|
+
|
|
41
|
+
// Tags of the Assertion Record (4.3), used to report capitalization errors
|
|
42
|
+
const KNOWN_TAGS = new Set(['v', 'l', 'a', 'lps', 'avp']);
|
|
43
|
+
|
|
44
|
+
// 4.3.2: only SVG and SVGZ are acceptable, a URI ending with another image format suffix is an error
|
|
45
|
+
const OTHER_IMAGE_SUFFIX_RE = /\.(?:png|apng|jpe?g|jfif|gif|bmp|webp|ico|tiff?|heic|heif|avif|jxl)$/i;
|
|
29
46
|
|
|
30
47
|
const POLICY_VALUES = new Set(['none', 'quarantine', 'reject']);
|
|
31
48
|
|
|
49
|
+
// 4.3: avp= values, any other value is ignored and not added to headers
|
|
50
|
+
const AVATAR_PREFERENCES = new Set(['personal', 'brand']);
|
|
51
|
+
|
|
52
|
+
// Checks that a URL is an HTTPS URL with a fully qualified domain name (4.3): IP literals,
|
|
53
|
+
// single-label names and localhost are not allowed
|
|
54
|
+
const isValidHttpsUrl = value => {
|
|
55
|
+
if (httpsSchema.validate(value).error) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
let url;
|
|
59
|
+
try {
|
|
60
|
+
url = new URL(value);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
let hostname = url.hostname
|
|
65
|
+
.replace(/^\[|\]$/g, '')
|
|
66
|
+
.replace(/\.$/, '')
|
|
67
|
+
.toLowerCase();
|
|
68
|
+
if (url.protocol !== 'https:' || !hostname || net.isIP(hostname) || !hostname.includes('.')) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
// URL parsing already turns numeric hosts like "https://2130706433/" into IPv4 addresses
|
|
72
|
+
if (/^[0-9.]+$/.test(hostname) || hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
};
|
|
77
|
+
|
|
32
78
|
// Returns the reason a DMARC record does not meet the policy requirements of
|
|
33
79
|
// draft-brand-indicators-for-message-identification 7.1 steps 7 to 9, or false if it does
|
|
34
80
|
const checkDmarcRecord = record => {
|
|
@@ -47,19 +93,77 @@ const checkDmarcRecord = record => {
|
|
|
47
93
|
|
|
48
94
|
// Parses a BIMI Assertion Record, which uses the DKIM tag-list syntax (4.3, RFC 6376 3.2). The
|
|
49
95
|
// values are the strings of the tag list: the parsed map of parseTagList would turn an empty "l="
|
|
50
|
-
// into the number 0.
|
|
51
|
-
|
|
96
|
+
// into the number 0. By default tag names are case-folded and the last duplicate wins. In strict
|
|
97
|
+
// mode tag names are case-sensitive and syntax errors such as duplicate tags are reported.
|
|
98
|
+
const parseAssertionRecord = (record, strict, warnings) => {
|
|
52
99
|
let tags = {};
|
|
53
|
-
|
|
54
|
-
|
|
100
|
+
let { tags: tagList, syntaxErrors } = parseTagList(record);
|
|
101
|
+
for (let tag of tagList) {
|
|
102
|
+
let name = tag.name.trim();
|
|
103
|
+
if (!strict) {
|
|
104
|
+
if (name !== name.toLowerCase() && KNOWN_TAGS.has(name.toLowerCase())) {
|
|
105
|
+
warnings.push('record-tag-case');
|
|
106
|
+
}
|
|
107
|
+
name = name.toLowerCase();
|
|
108
|
+
}
|
|
55
109
|
if (name) {
|
|
56
110
|
tags[name] = tag.value;
|
|
57
111
|
}
|
|
58
112
|
}
|
|
59
|
-
|
|
113
|
+
if (syntaxErrors.length && !strict) {
|
|
114
|
+
warnings.push('record-syntax');
|
|
115
|
+
}
|
|
116
|
+
return { tags, syntaxErrors };
|
|
60
117
|
};
|
|
61
118
|
|
|
62
|
-
//
|
|
119
|
+
// 4.4: selector = sub-domain *( "." sub-domain ), sub-domain from RFC 5321, the same grammar as
|
|
120
|
+
// the DKIM selector. The lax variant allows labels with underscores, as DKIM selectors sometimes have
|
|
121
|
+
const LAX_LABEL = '[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?';
|
|
122
|
+
const LAX_SELECTOR_RE = new RegExp(`^${LAX_LABEL}(?:\\.${LAX_LABEL})*$`);
|
|
123
|
+
|
|
124
|
+
// Returns the selector of a BIMI-Selector header (5.1), or false if the header has to be ignored
|
|
125
|
+
const parseSelectorHeader = (line, strict, warnings) => {
|
|
126
|
+
// the header field value, with or without the field name in front of it
|
|
127
|
+
let value = (line || '').toString().replace(/^\s*bimi-selector\s*:/i, '');
|
|
128
|
+
let { tags, syntaxErrors } = parseTagList(value);
|
|
129
|
+
if (syntaxErrors.length) {
|
|
130
|
+
if (strict) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
warnings.push('selector-syntax');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let values = {};
|
|
137
|
+
for (let tag of tags) {
|
|
138
|
+
let name = strict ? tag.name : tag.name.toLowerCase();
|
|
139
|
+
values[name] = tag.value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// the version MUST match precisely and be the first tag
|
|
143
|
+
let version = values.v;
|
|
144
|
+
if (strict ? tags[0]?.name !== 'v' || version !== 'BIMI1' : version?.toLowerCase() !== 'bimi1') {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
if (version !== 'BIMI1') {
|
|
148
|
+
warnings.push('selector-version-case');
|
|
149
|
+
}
|
|
150
|
+
if (tags[0]?.name.toLowerCase() !== 'v') {
|
|
151
|
+
warnings.push('selector-syntax');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let selector = (values.s || '').trim();
|
|
155
|
+
if (SELECTOR.test(selector)) {
|
|
156
|
+
return selector;
|
|
157
|
+
}
|
|
158
|
+
if (!strict && LAX_SELECTOR_RE.test(selector)) {
|
|
159
|
+
warnings.push('selector-syntax');
|
|
160
|
+
return selector;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Counts the addresses in the From header fields, including members of a group. The From header
|
|
166
|
+
// field is parsed the same way as for the DMARC check
|
|
63
167
|
const countFromAddresses = headerRows => {
|
|
64
168
|
let fromRows = headerRows.filter(row => row.key === 'from');
|
|
65
169
|
if (fromRows.length > 1) {
|
|
@@ -69,10 +173,7 @@ const countFromAddresses = headerRows => {
|
|
|
69
173
|
for (let row of fromRows) {
|
|
70
174
|
let value = (row.line || '').toString();
|
|
71
175
|
let splitterPos = value.indexOf(':');
|
|
72
|
-
|
|
73
|
-
for (let entry of list) {
|
|
74
|
-
count += Array.isArray(entry.group) ? entry.group.length : 1;
|
|
75
|
-
}
|
|
176
|
+
count += parseFromHeader(splitterPos >= 0 ? value.substr(splitterPos + 1) : value).addresses.length;
|
|
76
177
|
}
|
|
77
178
|
return count;
|
|
78
179
|
};
|
|
@@ -89,6 +190,8 @@ const lookup = async data => {
|
|
|
89
190
|
}
|
|
90
191
|
|
|
91
192
|
let response = { status: { header: {} } };
|
|
193
|
+
// syntax deviations that were accepted because strict is not set
|
|
194
|
+
let warnings = [];
|
|
92
195
|
|
|
93
196
|
const finish = (result, comment) => {
|
|
94
197
|
response.status.result = result;
|
|
@@ -96,6 +199,9 @@ const lookup = async data => {
|
|
|
96
199
|
response.status.comment = comment;
|
|
97
200
|
}
|
|
98
201
|
response.info = formatAuthHeaderRow('bimi', response.status, { strict });
|
|
202
|
+
if (warnings.length) {
|
|
203
|
+
response.warnings = Array.from(new Set(warnings));
|
|
204
|
+
}
|
|
99
205
|
return response;
|
|
100
206
|
};
|
|
101
207
|
|
|
@@ -167,24 +273,16 @@ const lookup = async data => {
|
|
|
167
273
|
|
|
168
274
|
let selector;
|
|
169
275
|
|
|
170
|
-
let
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
bimiSelectorHeader = parseDkimHeaders(row.line);
|
|
179
|
-
if (bimiSelectorHeader?.parsed?.v?.value?.toLowerCase() !== 'bimi1') {
|
|
180
|
-
return finish('fail', 'missing bimi version in selector header');
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
selector = bimiSelectorHeader?.parsed?.s?.value;
|
|
184
|
-
}
|
|
276
|
+
let selectorRows = headerRows.filter(row => row.key === 'bimi-selector');
|
|
277
|
+
if (selectorRows.length > 1) {
|
|
278
|
+
return finish('fail', 'multiple BIMI-Selector headers');
|
|
279
|
+
}
|
|
280
|
+
if (selectorRows.length) {
|
|
281
|
+
// 5.1 and 7.2 step 2: a header without a valid version or selector is ignored
|
|
282
|
+
selector = parseSelectorHeader(selectorRows[0].line, strict, warnings);
|
|
185
283
|
}
|
|
186
284
|
|
|
187
|
-
selector = selector
|
|
285
|
+
selector = selector || 'default';
|
|
188
286
|
|
|
189
287
|
// 7.2 steps 3 and 6: the selector at the Author Domain, then the same selector at the
|
|
190
288
|
// Organizational Domain. A custom selector does not fall back to "default".
|
|
@@ -210,7 +308,7 @@ const lookup = async data => {
|
|
|
210
308
|
let records = []
|
|
211
309
|
.concat(txt || [])
|
|
212
310
|
.map(row => (Array.isArray(row) ? row.join('') : String(row)).trim())
|
|
213
|
-
.filter(row => BIMI_VERSION_RE.test(row));
|
|
311
|
+
.filter(row => (strict ? BIMI_VERSION_STRICT_RE : BIMI_VERSION_RE).test(row));
|
|
214
312
|
|
|
215
313
|
if (records.length === 1) {
|
|
216
314
|
record = records[0];
|
|
@@ -228,11 +326,20 @@ const lookup = async data => {
|
|
|
228
326
|
return finish('none');
|
|
229
327
|
}
|
|
230
328
|
|
|
231
|
-
|
|
329
|
+
if (!BIMI_VERSION_STRICT_RE.test(record)) {
|
|
330
|
+
warnings.push('record-version-case');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let { tags: recordData, syntaxErrors } = parseAssertionRecord(record, strict, warnings);
|
|
232
334
|
if (recordData.v?.toLowerCase() !== 'bimi1') {
|
|
233
335
|
return finish('fail', 'missing bimi version in dns record');
|
|
234
336
|
}
|
|
235
337
|
|
|
338
|
+
// 4.3: Assertion Records are defined precisely, receivers MUST NOT fix syntax or capitalization errors
|
|
339
|
+
if (strict && syntaxErrors.length) {
|
|
340
|
+
return finish('fail', 'invalid syntax in dns record');
|
|
341
|
+
}
|
|
342
|
+
|
|
236
343
|
let location = recordData.l;
|
|
237
344
|
let authority = recordData.a;
|
|
238
345
|
|
|
@@ -246,19 +353,34 @@ const lookup = async data => {
|
|
|
246
353
|
return finish('fail', 'missing location value in dns record');
|
|
247
354
|
}
|
|
248
355
|
|
|
249
|
-
|
|
250
|
-
if (locationValidation.error) {
|
|
356
|
+
if (!isValidHttpsUrl(location)) {
|
|
251
357
|
return finish('fail', 'invalid location value in dns record');
|
|
252
358
|
}
|
|
253
359
|
|
|
254
|
-
if (authority) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
360
|
+
if (authority && !isValidHttpsUrl(authority)) {
|
|
361
|
+
return finish('fail', 'invalid authority value in dns record');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 4.3: a single URI, commas within a URI MUST be encoded
|
|
365
|
+
for (let [value, comment] of [
|
|
366
|
+
[location, 'invalid location value in dns record'],
|
|
367
|
+
[authority, 'invalid authority value in dns record']
|
|
368
|
+
]) {
|
|
369
|
+
if (value && value.includes(',')) {
|
|
370
|
+
if (strict) {
|
|
371
|
+
return finish('fail', comment);
|
|
372
|
+
}
|
|
373
|
+
warnings.push('uri-comma');
|
|
258
374
|
}
|
|
259
375
|
}
|
|
260
376
|
|
|
261
|
-
|
|
377
|
+
// 4.3.2: an l= URI that ends with another image format suffix is a permanent error
|
|
378
|
+
if (OTHER_IMAGE_SUFFIX_RE.test(new URL(location).pathname)) {
|
|
379
|
+
if (strict) {
|
|
380
|
+
return finish('fail', 'unsupported image format in location value');
|
|
381
|
+
}
|
|
382
|
+
warnings.push('location-format');
|
|
383
|
+
}
|
|
262
384
|
|
|
263
385
|
response.location = location;
|
|
264
386
|
|
|
@@ -269,16 +391,43 @@ const lookup = async data => {
|
|
|
269
391
|
response.status.policy = { authority: 'none', 'authority-uri': authority }; // VMC has not been actually checked here yet, so authority is none
|
|
270
392
|
}
|
|
271
393
|
|
|
272
|
-
if (recordData.
|
|
273
|
-
response.preference = recordData.
|
|
394
|
+
if (AVATAR_PREFERENCES.has(recordData.avp)) {
|
|
395
|
+
response.preference = recordData.avp;
|
|
274
396
|
}
|
|
275
397
|
|
|
276
|
-
|
|
277
|
-
|
|
398
|
+
return finish('pass');
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const tooLarge = maxSize => createError(`File is larger than ${maxSize} bytes`, 'FILE_TOO_LARGE');
|
|
402
|
+
|
|
403
|
+
// Reads a response body, stops at maxSize bytes (7.6 step 1 allows a retrieval limit)
|
|
404
|
+
const readBody = async (res, maxSize) => {
|
|
405
|
+
if (Number(res.headers.get('content-length')) > maxSize) {
|
|
406
|
+
await res.body?.cancel().catch(() => false);
|
|
407
|
+
throw tooLarge(maxSize);
|
|
408
|
+
}
|
|
409
|
+
let chunks = [];
|
|
410
|
+
let size = 0;
|
|
411
|
+
if (res.body) {
|
|
412
|
+
// leaving the loop early cancels the stream
|
|
413
|
+
for await (let chunk of res.body) {
|
|
414
|
+
size += chunk.length;
|
|
415
|
+
if (size > maxSize) {
|
|
416
|
+
throw tooLarge(maxSize);
|
|
417
|
+
}
|
|
418
|
+
chunks.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return Buffer.concat(chunks, size);
|
|
278
422
|
};
|
|
279
423
|
|
|
280
|
-
const
|
|
424
|
+
const getSizeOption = (value, defaultValue) => (typeof value === 'number' && value > 0 ? value : defaultValue);
|
|
425
|
+
|
|
426
|
+
const downloadPromise = async (url, cachedFile, maxSize, opts) => {
|
|
281
427
|
if (cachedFile) {
|
|
428
|
+
if (cachedFile.length > maxSize) {
|
|
429
|
+
throw tooLarge(maxSize);
|
|
430
|
+
}
|
|
282
431
|
return cachedFile;
|
|
283
432
|
}
|
|
284
433
|
|
|
@@ -286,22 +435,74 @@ const downloadPromise = async (url, cachedFile) => {
|
|
|
286
435
|
return false;
|
|
287
436
|
}
|
|
288
437
|
|
|
289
|
-
let
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
438
|
+
let timeout = getSizeOption(opts.timeout, DOWNLOAD_TIMEOUT);
|
|
439
|
+
let signal = AbortSignal.timeout(timeout);
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
// Redirects are followed manually, each target has to be a valid HTTPS URL (7.5 step 1)
|
|
443
|
+
for (let redirects = 0; ; redirects++) {
|
|
444
|
+
if (!isValidHttpsUrl(url)) {
|
|
445
|
+
throw createError('Download URL must be an HTTPS URL with a domain name', 'INVALID_URL', { details: { url } });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
let res = await fetchCmd(url, {
|
|
449
|
+
headers: {
|
|
450
|
+
// Comment: AKAMAI does some strange UA based filtering that messes up the request
|
|
451
|
+
// 'User-Agent': `mailauth/${packageData.version} (+${packageData.homepage}`
|
|
452
|
+
},
|
|
453
|
+
redirect: 'manual',
|
|
454
|
+
signal,
|
|
455
|
+
dispatcher: opts.dispatcher || fetchAgent
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
|
|
459
|
+
await res.body?.cancel().catch(() => false);
|
|
460
|
+
let target;
|
|
461
|
+
try {
|
|
462
|
+
target = new URL(res.headers.get('location'), url).href;
|
|
463
|
+
} catch (err) {
|
|
464
|
+
target = res.headers.get('location');
|
|
465
|
+
}
|
|
466
|
+
if (redirects >= MAX_REDIRECTS || !isValidHttpsUrl(target)) {
|
|
467
|
+
throw createError(
|
|
468
|
+
redirects >= MAX_REDIRECTS ? 'Too many redirects' : 'Redirect target must be an HTTPS URL with a domain name',
|
|
469
|
+
'HTTP_REDIRECT_NOT_ALLOWED',
|
|
470
|
+
{ redirect: target }
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
url = target;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
296
476
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
477
|
+
if (!res.ok) {
|
|
478
|
+
await res.body?.cancel().catch(() => false);
|
|
479
|
+
throw createError(`Request failed with status ${res.status}`, 'HTTP_REQUEST_FAILED');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return await readBody(res, maxSize);
|
|
483
|
+
}
|
|
484
|
+
} catch (err) {
|
|
485
|
+
// DOMException codes are numbers
|
|
486
|
+
if (signal.aborted && typeof err.code !== 'string') {
|
|
487
|
+
throw createError(`Request timed out after ${timeout} ms`, 'HTTP_REQUEST_TIMEOUT');
|
|
488
|
+
}
|
|
489
|
+
throw err;
|
|
301
490
|
}
|
|
491
|
+
};
|
|
302
492
|
|
|
303
|
-
|
|
304
|
-
|
|
493
|
+
// Returns the SVG document of a downloaded Indicator. An SVGZ file is uncompressed (5.3, 7.10).
|
|
494
|
+
const getIndicatorSvg = (data, maxSize) => {
|
|
495
|
+
if (data.length >= 2 && data[0] === 0x1f && data[1] === 0x8b) {
|
|
496
|
+
try {
|
|
497
|
+
return zlib.gunzipSync(data, { maxOutputLength: maxSize });
|
|
498
|
+
} catch (err) {
|
|
499
|
+
if (err.code === 'ERR_BUFFER_TOO_LARGE') {
|
|
500
|
+
throw tooLarge(maxSize);
|
|
501
|
+
}
|
|
502
|
+
throw createError('Invalid SVGZ file', 'INVALID_SVGZ_FILE');
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return data;
|
|
305
506
|
};
|
|
306
507
|
|
|
307
508
|
const validateVMC = async (bimiData, opts) => {
|
|
@@ -315,8 +516,11 @@ const validateVMC = async (bimiData, opts) => {
|
|
|
315
516
|
|
|
316
517
|
let promises = [];
|
|
317
518
|
|
|
318
|
-
|
|
319
|
-
|
|
519
|
+
let maxLogoSize = getSizeOption(opts.maxLogoSize, MAX_LOGO_SIZE);
|
|
520
|
+
let maxEvidenceSize = getSizeOption(opts.maxEvidenceSize, MAX_EVIDENCE_SIZE);
|
|
521
|
+
|
|
522
|
+
promises.push(downloadPromise(bimiData.location, bimiData.locationPath, maxLogoSize, opts));
|
|
523
|
+
promises.push(downloadPromise(bimiData.authority, bimiData.authorityPath, maxEvidenceSize, opts));
|
|
320
524
|
|
|
321
525
|
if (!promises.length) {
|
|
322
526
|
return false;
|
|
@@ -326,6 +530,11 @@ const validateVMC = async (bimiData, opts) => {
|
|
|
326
530
|
await Promise.allSettled(promises);
|
|
327
531
|
|
|
328
532
|
let result = {};
|
|
533
|
+
// profile deviations of the logo files that were accepted because strict is not set
|
|
534
|
+
let warnings = [];
|
|
535
|
+
let svgOpts = { strict: !!opts.strict, warnings };
|
|
536
|
+
// the uncompressed l= logo, once it passed validation
|
|
537
|
+
let validatedLogo = null;
|
|
329
538
|
if (locationValue || locationError) {
|
|
330
539
|
result.location = {
|
|
331
540
|
url: bimiData.location,
|
|
@@ -344,7 +553,25 @@ const validateVMC = async (bimiData, opts) => {
|
|
|
344
553
|
}
|
|
345
554
|
|
|
346
555
|
if (result.location.success) {
|
|
347
|
-
|
|
556
|
+
// 7.6: the Indicator is validated whether or not an evidence document vouches for it
|
|
557
|
+
try {
|
|
558
|
+
let svg = getIndicatorSvg(locationValue, maxLogoSize);
|
|
559
|
+
validateSvg(svg, svgOpts);
|
|
560
|
+
validatedLogo = svg;
|
|
561
|
+
result.location.logoFile = svg.toString('base64');
|
|
562
|
+
} catch (err) {
|
|
563
|
+
result.location.success = false;
|
|
564
|
+
if (err.code === 'FILE_TOO_LARGE') {
|
|
565
|
+
// an SVGZ file that is too large when uncompressed
|
|
566
|
+
result.location.error = { message: err.message, code: err.code };
|
|
567
|
+
} else {
|
|
568
|
+
result.location.error = {
|
|
569
|
+
message: 'Logo SVG validation failed',
|
|
570
|
+
details: Object.assign({ message: err.message }, err.details ? { details: err.details } : {}, err.code ? { code: err.code } : {}),
|
|
571
|
+
code: 'SVG_VALIDATION_FAILED'
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
}
|
|
348
575
|
}
|
|
349
576
|
}
|
|
350
577
|
|
|
@@ -370,45 +597,33 @@ const validateVMC = async (bimiData, opts) => {
|
|
|
370
597
|
let vmcData = await vmc(authorityValue, opts);
|
|
371
598
|
|
|
372
599
|
if (!vmcData.logoFile) {
|
|
373
|
-
|
|
374
|
-
error.code = 'MISSING_VMC_LOGO';
|
|
375
|
-
throw error;
|
|
600
|
+
throw createError('VMC does not contain a log file', 'MISSING_VMC_LOGO');
|
|
376
601
|
}
|
|
377
602
|
|
|
378
603
|
if (vmcData?.mediaType?.toLowerCase() !== 'image/svg+xml') {
|
|
379
|
-
|
|
380
|
-
error.details = {
|
|
381
|
-
mediaType: vmcData.mediaType
|
|
382
|
-
};
|
|
383
|
-
error.code = 'INVALID_MEDIATYPE';
|
|
384
|
-
throw error;
|
|
604
|
+
throw createError('Invalid media type for the logo file', 'INVALID_MEDIATYPE', { details: { mediaType: vmcData.mediaType } });
|
|
385
605
|
}
|
|
386
606
|
|
|
387
607
|
if (!vmcData.validHash) {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
throw error;
|
|
608
|
+
throw createError('VMC hash does not match logo file', 'INVALID_LOGO_HASH', {
|
|
609
|
+
details: {
|
|
610
|
+
hashAlgo: vmcData.hashAlgo,
|
|
611
|
+
hashValue: vmcData.hashValue,
|
|
612
|
+
logoFile: vmcData.logoFile
|
|
613
|
+
}
|
|
614
|
+
});
|
|
396
615
|
}
|
|
397
616
|
|
|
398
|
-
// throws on invalid logo file
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
{
|
|
405
|
-
message: err.message
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
err.code ? { code: err.code } : {}
|
|
409
|
-
);
|
|
410
|
-
error.code = 'SVG_VALIDATION_FAILED';
|
|
411
|
-
throw error;
|
|
617
|
+
// throws on invalid logo file. The same bytes as the l= logo were validated already
|
|
618
|
+
let vmcLogo = Buffer.from(vmcData.logoFile, 'base64');
|
|
619
|
+
if (!validatedLogo || !vmcLogo.equals(validatedLogo)) {
|
|
620
|
+
try {
|
|
621
|
+
validateSvg(vmcLogo, svgOpts);
|
|
622
|
+
} catch (err) {
|
|
623
|
+
throw createError('VMC logo SVG validation failed', 'SVG_VALIDATION_FAILED', {
|
|
624
|
+
details: Object.assign({ message: err.message }, err.code ? { code: err.code } : {})
|
|
625
|
+
});
|
|
626
|
+
}
|
|
412
627
|
}
|
|
413
628
|
|
|
414
629
|
if (d) {
|
|
@@ -435,14 +650,13 @@ const validateVMC = async (bimiData, opts) => {
|
|
|
435
650
|
}
|
|
436
651
|
|
|
437
652
|
if (!domainVerified) {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
throw error;
|
|
653
|
+
throw createError('Domain can not be verified', 'VMC_DOMAIN_MISMATCH', {
|
|
654
|
+
details: {
|
|
655
|
+
subjectAltName: vmcData?.certificate?.subjectAltName,
|
|
656
|
+
selector,
|
|
657
|
+
d
|
|
658
|
+
}
|
|
659
|
+
});
|
|
446
660
|
} else {
|
|
447
661
|
result.authority.domainVerified = true;
|
|
448
662
|
}
|
|
@@ -482,19 +696,65 @@ const validateVMC = async (bimiData, opts) => {
|
|
|
482
696
|
}
|
|
483
697
|
}
|
|
484
698
|
|
|
485
|
-
//
|
|
699
|
+
// 7.9: with an evidence document, the Indicator from l= must be identical to the one in the document
|
|
700
|
+
let evidenceVerified = !!(result.authority?.success && result.authority.hashMatch === true);
|
|
701
|
+
|
|
702
|
+
// Header values come from the Assertion Record, where a tag value can not contain ";" or whitespace
|
|
703
|
+
const isHeaderSafeUrl = value => isValidHttpsUrl(value) && /^[\x21-\x3a\x3c-\x7e]+$/.test(value);
|
|
704
|
+
|
|
705
|
+
// Generate headers when validation succeeds (7.9 to 7.11)
|
|
486
706
|
let canGenerateHeaders =
|
|
487
|
-
result.location?.success && result.location?.logoFile && (!result.authority ||
|
|
707
|
+
result.location?.success && result.location?.logoFile && (!result.authority || evidenceVerified) && isHeaderSafeUrl(bimiData.location);
|
|
488
708
|
|
|
489
709
|
if (canGenerateHeaders) {
|
|
710
|
+
let locationHeader = `BIMI-Location: v=BIMI1; l=${bimiData.location}`;
|
|
711
|
+
if (evidenceVerified && isHeaderSafeUrl(bimiData.authority)) {
|
|
712
|
+
// 5.2: a= is required when the evidence document was verified
|
|
713
|
+
locationHeader += `; a=${bimiData.authority}`;
|
|
714
|
+
}
|
|
715
|
+
|
|
490
716
|
result.headers = {
|
|
491
717
|
indicator: libmime.foldLines(`BIMI-Indicator: ${result.location.logoFile}`, 160),
|
|
492
|
-
location:
|
|
718
|
+
location: locationHeader
|
|
493
719
|
};
|
|
494
720
|
|
|
495
|
-
|
|
496
|
-
|
|
721
|
+
// 5.4 and 7.11: the header is not added for an invalid value
|
|
722
|
+
if (AVATAR_PREFERENCES.has(bimiData.preference)) {
|
|
723
|
+
result.headers.preference = `BIMI-Logo-Preference: avp=${bimiData.preference}`;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// 7.7: the Authentication-Results entry of bimi() only covers record discovery. Update it with the
|
|
728
|
+
// results of Indicator and evidence validation.
|
|
729
|
+
if (bimiData.status?.result === 'pass') {
|
|
730
|
+
let status = Object.assign({}, bimiData.status, { header: Object.assign({}, bimiData.status.header) });
|
|
731
|
+
delete status.comment;
|
|
732
|
+
|
|
733
|
+
if (result.authority) {
|
|
734
|
+
// policy.authority is "pass" or "fail" once the evidence document was checked
|
|
735
|
+
status.policy = Object.assign({}, status.policy, { authority: evidenceVerified ? 'pass' : 'fail', 'authority-uri': bimiData.authority });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
let comment;
|
|
739
|
+
if (!result.location?.success) {
|
|
740
|
+
comment = result.location?.error?.code === 'SVG_VALIDATION_FAILED' ? 'invalid SVG indicator' : 'failed to retrieve indicator';
|
|
741
|
+
} else if (result.authority && !result.authority.success) {
|
|
742
|
+
comment = 'evidence document validation failed';
|
|
743
|
+
} else if (result.authority && !evidenceVerified) {
|
|
744
|
+
comment = 'indicator does not match evidence document';
|
|
497
745
|
}
|
|
746
|
+
|
|
747
|
+
if (comment) {
|
|
748
|
+
status.result = 'fail';
|
|
749
|
+
status.comment = comment;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
result.status = status;
|
|
753
|
+
result.info = formatAuthHeaderRow('bimi', status, { strict: !!opts.strict });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (warnings.length) {
|
|
757
|
+
result.warnings = warnings;
|
|
498
758
|
}
|
|
499
759
|
|
|
500
760
|
return result;
|