mailauth 7.1.0 → 7.2.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/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 { formatAuthHeaderRow, parseDkimHeaders, parseTagList, formatDomain, getAlignment } = require('../tools');
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. Tag names are case-folded and the last duplicate wins, as before.
51
- const parseAssertionRecord = record => {
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
- for (let tag of parseTagList(record).tags) {
54
- let name = tag.name.trim().toLowerCase();
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
- return tags;
113
+ if (syntaxErrors.length && !strict) {
114
+ warnings.push('record-syntax');
115
+ }
116
+ return { tags, syntaxErrors };
60
117
  };
61
118
 
62
- // Counts the addresses in the From header fields, including members of a group
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
- let list = addressparser((splitterPos >= 0 ? value.substr(splitterPos + 1) : value).trim());
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 bimiSelectorHeader;
171
- for (let row of headerRows) {
172
- if (['bimi-selector'].includes(row.key)) {
173
- if (bimiSelectorHeader) {
174
- // already found one
175
- return finish('fail', 'multiple BIMI-Selector headers');
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?.trim() || 'default';
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
- let recordData = parseAssertionRecord(record);
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
- let locationValidation = httpsSchema.validate(location);
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
- let authorityValidation = httpsSchema.validate(authority);
256
- if (authorityValidation.error) {
257
- return finish('fail', 'invalid authority value in dns record');
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
- response.status.result = 'pass';
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.p) {
273
- response.preference = recordData.p;
394
+ if (AVATAR_PREFERENCES.has(recordData.avp)) {
395
+ response.preference = recordData.avp;
274
396
  }
275
397
 
276
- response.info = formatAuthHeaderRow('bimi', response.status, { strict });
277
- return response;
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 downloadPromise = async (url, cachedFile) => {
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 res = await fetchCmd(url, {
290
- headers: {
291
- // Comment: AKAMAI does some strange UA based filtering that messes up the request
292
- // 'User-Agent': `mailauth/${packageData.version} (+${packageData.homepage}`
293
- },
294
- dispatcher: fetchAgent
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
- if (!res.ok) {
298
- let error = new Error(`Request failed with status ${res.status}`);
299
- error.code = 'HTTP_REQUEST_FAILED';
300
- throw error;
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
- const arrayBufferValue = await res.arrayBuffer();
304
- return Buffer.from(arrayBufferValue);
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
- promises.push(downloadPromise(bimiData.location, bimiData.locationPath));
319
- promises.push(downloadPromise(bimiData.authority, bimiData.authorityPath));
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
- result.location.logoFile = locationValue.toString('base64');
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
- let error = new Error('VMC does not contain a log file');
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
- let error = new Error('Invalid media type for the logo file');
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
- let error = new Error('VMC hash does not match logo file');
389
- error.details = {
390
- hashAlgo: vmcData.hashAlgo,
391
- hashValue: vmcData.hashValue,
392
- logoFile: vmcData.logoFile
393
- };
394
- error.code = 'INVALID_LOGO_HASH';
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
- try {
400
- validateSvg(Buffer.from(vmcData.logoFile, 'base64'));
401
- } catch (err) {
402
- let error = new Error('VMC logo SVG validation failed');
403
- error.details = Object.assign(
404
- {
405
- message: err.message
406
- },
407
- error.details || {},
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
- let error = new Error('Domain can not be verified');
439
- error.details = {
440
- subjectAltName: vmcData?.certificate?.subjectAltName,
441
- selector,
442
- d
443
- };
444
- error.code = 'VMC_DOMAIN_MISMATCH';
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
- // Generate headers when validation succeeds
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 || (result.authority.success && result.authority.hashMatch !== false));
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: `BIMI-Location: v=BIMI1; l=${bimiData.location}`
718
+ location: locationHeader
493
719
  };
494
720
 
495
- if (bimiData.preference) {
496
- result.headers.preference = `BIMI-Logo-Preference: ${bimiData.preference}`;
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;