mailauth 4.13.3 → 5.0.1

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/cli.md CHANGED
@@ -177,6 +177,20 @@ mailauth seal [options] [email]
177
177
  - `--header-fields "field1:field2"`, `-h "field1:field2"`: Colon-separated list of header fields to include in the seal (`h=` tag).
178
178
  - `--headers-only`, `-o`: Outputs only the ARC seal headers without the entire message.
179
179
 
180
+ **Seal-Only Options:**
181
+
182
+ By default, `seal` authenticates the message (SPF, DKIM, DMARC, ARC) and embeds the computed results in the `ARC-Authentication-Results` header. If the message was already authenticated elsewhere (for example at an edge MTA) and modified afterwards, you can instead provide the original `Authentication-Results` value yourself. When one of the following options is set, no authentication checks or DNS lookups are performed:
183
+
184
+ - `--auth-results "authserv-id; spf=pass ..."`: `Authentication-Results` value to embed in the `ARC-Authentication-Results` header (the part after `i=N;`). Used as is.
185
+ - `--auth-results-file /path/to/value.txt`: Same as `--auth-results`, but the value is read from a file. Useful for long or multi-line values. Cannot be combined with `--auth-results`.
186
+ - `--cv status`: Chain validation status for the `ARC-Seal` header (`cv=` tag): `none`, `pass`, or `fail`. Defaults to `none`.
187
+ - `--instance number`: ARC instance number (`i=` tag). Defaults to the next instance number based on the existing ARC chain of the message, or `1`.
188
+
189
+ ```bash
190
+ mailauth seal message.eml -d example.com -s s1 -k private.key \
191
+ --cv pass --auth-results-file auth-results.txt --headers-only
192
+ ```
193
+
180
194
  **Authentication Options (from `report` command):**
181
195
 
182
196
  - `--client-ip x.x.x.x`, `-i x.x.x.x`: IP address of the remote client that sent the email.
package/docs/dmarc.md CHANGED
@@ -41,10 +41,10 @@ const { dmarc } = await authenticate(message, {
41
41
 
42
42
  ### status.header Object
43
43
 
44
- | Field | Type | Description |
45
- | ------ | -------- | ------------------------------------------ |
46
- | `from` | `string` | Organizational domain from the From header |
47
- | `d` | `string` | Domain where DMARC record was found |
44
+ | Field | Type | Description |
45
+ | ------ | -------- | ----------------------------------- |
46
+ | `from` | `string` | Domain of the From header address |
47
+ | `d` | `string` | Domain where DMARC record was found |
48
48
 
49
49
  ## alignment Object
50
50
 
@@ -55,18 +55,20 @@ const { dmarc } = await authenticate(message, {
55
55
 
56
56
  ### alignment.spf Object
57
57
 
58
- | Field | Type | Description |
59
- | -------- | --------------- | ----------------------------------------------------------- |
60
- | `result` | `string\|false` | Aligned domain if SPF passed and aligned, otherwise `false` |
61
- | `strict` | `boolean` | Whether strict alignment is required (`aspf=s`) |
58
+ | Field | Type | Description |
59
+ | -------- | ------------------- | --------------------------------------------------------------- |
60
+ | `result` | `string\|undefined` | Aligned domain if SPF passed and aligned, otherwise `undefined` |
61
+ | `strict` | `boolean` | Whether strict alignment is required (`aspf=s`) |
62
62
 
63
63
  ### alignment.dkim Object
64
64
 
65
- | Field | Type | Description |
66
- | ------------ | --------------- | ------------------------------------------------------------ |
67
- | `result` | `string\|false` | Aligned domain if DKIM passed and aligned, otherwise `false` |
68
- | `strict` | `boolean` | Whether strict alignment is required (`adkim=s`) |
69
- | `underSized` | `number` | Number of unsigned body bytes (if `l=` tag limited body) |
65
+ | Field | Type | Description |
66
+ | ------------ | ------------------- | ---------------------------------------------------------------- |
67
+ | `result` | `string\|undefined` | Aligned domain if DKIM passed and aligned, otherwise `undefined` |
68
+ | `strict` | `boolean` | Whether strict alignment is required (`adkim=s`) |
69
+ | `underSized` | `number` | Number of unsigned body bytes (if `l=` tag limited body) |
70
+
71
+ `underSized` is reported for a signature that aligns at the organizational domain even when `adkim=s` rejected it, so that it stays a reliable content-integrity warning regardless of the alignment mode the domain publishes.
70
72
 
71
73
  ## Result Values
72
74
 
@@ -103,21 +105,19 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
103
105
  {
104
106
  "status": {
105
107
  "result": "pass",
108
+ "comment": "p=REJECT",
106
109
  "header": {
107
110
  "from": "example.com",
108
111
  "d": "example.com"
109
- },
110
- "comment": "p=REJECT arc=none"
112
+ }
111
113
  },
112
114
  "domain": "example.com",
113
115
  "policy": "reject",
114
116
  "p": "reject",
115
117
  "sp": "reject",
116
- "pct": 100,
117
118
  "rr": "v=DMARC1; p=reject; rua=mailto:dmarc@example.com",
118
119
  "alignment": {
119
120
  "spf": {
120
- "result": false,
121
121
  "strict": false
122
122
  },
123
123
  "dkim": {
@@ -125,7 +125,7 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
125
125
  "strict": false
126
126
  }
127
127
  },
128
- "info": "dmarc=pass (p=REJECT arc=none) header.from=example.com"
128
+ "info": "dmarc=pass (p=REJECT) header.from=example.com header.d=example.com"
129
129
  }
130
130
  ```
131
131
 
@@ -135,29 +135,26 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
135
135
  {
136
136
  "status": {
137
137
  "result": "fail",
138
+ "comment": "p=REJECT",
138
139
  "header": {
139
140
  "from": "example.com",
140
141
  "d": "example.com"
141
- },
142
- "comment": "p=REJECT"
142
+ }
143
143
  },
144
144
  "domain": "example.com",
145
145
  "policy": "reject",
146
146
  "p": "reject",
147
147
  "sp": "reject",
148
- "pct": 100,
149
148
  "rr": "v=DMARC1; p=reject; rua=mailto:dmarc@example.com",
150
149
  "alignment": {
151
150
  "spf": {
152
- "result": false,
153
151
  "strict": false
154
152
  },
155
153
  "dkim": {
156
- "result": false,
157
154
  "strict": false
158
155
  }
159
156
  },
160
- "info": "dmarc=fail (p=REJECT) header.from=example.com"
157
+ "info": "dmarc=fail (p=REJECT) header.from=example.com header.d=example.com"
161
158
  }
162
159
  ```
163
160
 
@@ -171,7 +168,7 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
171
168
  "from": "no-dmarc.example.com"
172
169
  }
173
170
  },
174
- "domain": "no-dmarc.example.com",
171
+ "domain": "example.com",
175
172
  "info": "dmarc=none header.from=no-dmarc.example.com"
176
173
  }
177
174
  ```
@@ -182,29 +179,27 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
182
179
  {
183
180
  "status": {
184
181
  "result": "pass",
182
+ "comment": "p=NONE sp=QUARANTINE",
185
183
  "header": {
186
- "from": "example.com",
187
- "d": "sub.example.com"
188
- },
189
- "comment": "p=NONE sp=QUARANTINE"
184
+ "from": "sub.example.com",
185
+ "d": "example.com"
186
+ }
190
187
  },
191
188
  "domain": "example.com",
192
189
  "policy": "quarantine",
193
190
  "p": "none",
194
191
  "sp": "quarantine",
195
- "pct": 100,
196
192
  "rr": "v=DMARC1; p=none; sp=quarantine; rua=mailto:dmarc@example.com",
197
193
  "alignment": {
198
194
  "spf": {
199
- "result": "example.com",
195
+ "result": "sub.example.com",
200
196
  "strict": false
201
197
  },
202
198
  "dkim": {
203
- "result": false,
204
199
  "strict": false
205
200
  }
206
201
  },
207
- "info": "dmarc=pass (p=NONE sp=QUARANTINE) header.from=example.com"
202
+ "info": "dmarc=pass (p=NONE sp=QUARANTINE) header.from=sub.example.com header.d=example.com"
208
203
  }
209
204
  ```
210
205
 
package/index.d.ts CHANGED
@@ -179,8 +179,15 @@ export interface DKIMResult {
179
179
  * Verification status
180
180
  */
181
181
  status: AuthStatus & {
182
- aligned?: boolean;
183
- underSized?: boolean;
182
+ /**
183
+ * Signing domain if it aligns with the From domain, false otherwise
184
+ */
185
+ aligned?: string | false;
186
+
187
+ /**
188
+ * Number of body bytes left unsigned by an l= tag
189
+ */
190
+ underSized?: number;
184
191
  };
185
192
 
186
193
  /**
@@ -392,13 +399,25 @@ export interface DMARCResult {
392
399
  */
393
400
  alignment: {
394
401
  spf: {
395
- result: string | false;
402
+ /**
403
+ * Aligned SPF domain, or undefined if no domain aligned
404
+ */
405
+ result?: string;
396
406
  strict: boolean;
397
407
  };
398
408
  dkim: {
399
- result: string | false;
409
+ /**
410
+ * Aligned signing domain, or undefined if no domain aligned
411
+ */
412
+ result?: string;
400
413
  strict: boolean;
401
- underSized?: boolean;
414
+
415
+ /**
416
+ * Number of body bytes left unsigned by an l= tag. Reported for a signature that
417
+ * aligns at the organizational domain even if adkim=s rejected it, so that it stays
418
+ * a reliable content-integrity warning regardless of the alignment mode.
419
+ */
420
+ underSized?: number;
402
421
  };
403
422
  };
404
423
 
@@ -968,8 +987,8 @@ export interface DMARCOptions {
968
987
  dkimDomains?: Array<{
969
988
  id?: string;
970
989
  domain: string;
971
- aligned?: boolean;
972
- underSized?: boolean;
990
+ aligned?: string | false;
991
+ underSized?: number;
973
992
  }>;
974
993
 
975
994
  /**
package/lib/arc/index.js CHANGED
@@ -57,6 +57,8 @@ const verifyAS = async (chain, opts) => {
57
57
  publicKey = res?.publicKey;
58
58
  } catch (err) {
59
59
  err.queryDomain = queryDomain;
60
+ // the error code is what the ARC result reports as a comment
61
+ throw err;
60
62
  }
61
63
 
62
64
  let pass = crypto.verify(
@@ -419,7 +421,7 @@ const arc = async (data, opts) => {
419
421
  break;
420
422
 
421
423
  case 'ESHORTKEY':
422
- status.policy['dkim-rules'] = `weak-key`;
424
+ status.policy = { 'dkim-rules': 'weak-key' };
423
425
  if (err.queryDomain) {
424
426
  status.comment = `weak key for ${err.queryDomain}`;
425
427
  }
@@ -434,7 +436,8 @@ const arc = async (data, opts) => {
434
436
  if (result.authenticationResults.dkim && result.authenticationResults.dkim.length) {
435
437
  for (let entry of result.authenticationResults.dkim) {
436
438
  comment.push(`dkim=${entry.result}`);
437
- if (entry?.header?.i) {
439
+ // a crafted dotted key (header.i.x=1) parses into an object, not a string
440
+ if (typeof entry?.header?.i === 'string') {
438
441
  comment.push(`dkdomain=${entry.header.i.replace(/^@/, '')}`);
439
442
  }
440
443
  }
@@ -442,8 +445,8 @@ const arc = async (data, opts) => {
442
445
 
443
446
  if (result.authenticationResults.dmarc) {
444
447
  comment.push(`dmarc=${result.authenticationResults.dmarc.result}`);
445
- if (result.authenticationResults.dmarc?.header?.from) {
446
- comment.push(`fromdomain=${result.authenticationResults.dmarc?.header?.from}`);
448
+ if (typeof result.authenticationResults.dmarc?.header?.from === 'string') {
449
+ comment.push(`fromdomain=${result.authenticationResults.dmarc.header.from}`);
447
450
  }
448
451
  }
449
452
 
@@ -1,10 +1,40 @@
1
1
  'use strict';
2
2
 
3
3
  const { authenticate } = require('../mailauth');
4
+ const { sealMessage } = require('../arc');
4
5
  const fs = require('node:fs');
5
6
  const { GathererStream } = require('../gatherer-stream');
6
7
  const { resolve } = require('node:dns').promises;
7
8
 
9
+ const writeMessageBody = async gatherer =>
10
+ new Promise((resolve, reject) => {
11
+ let msgStream = gatherer.replay();
12
+
13
+ msgStream.pipe(process.stdout, { end: false });
14
+ msgStream.on('end', () => {
15
+ resolve();
16
+ });
17
+
18
+ msgStream.on('error', err => {
19
+ reject(err);
20
+ });
21
+ });
22
+
23
+ const printSignatureInfo = opts => {
24
+ if (opts.signingDomain) {
25
+ console.error(`Signing domain: ${opts.signingDomain}`);
26
+ }
27
+ if (opts.selector) {
28
+ console.error(`Key selector: ${opts.selector}`);
29
+ }
30
+ if (opts.algorithm) {
31
+ console.error(`Hashing algorithm: ${opts.algorithm}`);
32
+ }
33
+ if (opts.signTime) {
34
+ console.error(`Signing time: ${opts.signTime.toISOString()}`);
35
+ }
36
+ };
37
+
8
38
  const cmd = async argv => {
9
39
  let source = argv.email;
10
40
  let useStdin = false;
@@ -30,6 +60,7 @@ const cmd = async argv => {
30
60
  stream.on('error', err => gatherer.emit('error', err));
31
61
 
32
62
  let privateKey = await fs.promises.readFile(argv.privateKey, 'utf-8');
63
+
33
64
  let signatureOpts = {
34
65
  signingDomain: argv.domain,
35
66
  selector: argv.selector,
@@ -39,19 +70,46 @@ const cmd = async argv => {
39
70
  signTime: argv.time ? new Date(argv.time * 1000) : new Date()
40
71
  };
41
72
 
42
- if (argv.verbose) {
43
- if (signatureOpts.signingDomain) {
44
- console.error(`Signing domain: ${signatureOpts.signingDomain}`);
73
+ if (argv.authResults || argv.authResultsFile) {
74
+ // Seal-only mode: embed the caller-provided Authentication-Results value without authenticating the message
75
+ let authResults = argv.authResultsFile ? (await fs.promises.readFile(argv.authResultsFile, 'utf-8')).trim() : argv.authResults;
76
+
77
+ let sealOpts = Object.assign({}, signatureOpts, {
78
+ authResults,
79
+ cv: argv.cv || 'none'
80
+ });
81
+
82
+ if (argv.instance) {
83
+ sealOpts.i = argv.instance;
45
84
  }
46
- if (signatureOpts.selector) {
47
- console.error(`Key selector: ${signatureOpts.selector}`);
85
+
86
+ if (argv.verbose) {
87
+ console.error('Seal-only mode: sealing with provided authentication results, skipping authentication');
88
+ printSignatureInfo(sealOpts);
89
+ console.error(`Chain validation status: ${sealOpts.cv}`);
90
+ if (sealOpts.i) {
91
+ console.error(`ARC instance: ${sealOpts.i}`);
92
+ }
93
+ console.error('--------');
48
94
  }
49
- if (signatureOpts.algorithm) {
50
- console.error(`Hashing algorithm: ${signatureOpts.algorithm}`);
95
+
96
+ let sealHeaders = await sealMessage(gatherer, sealOpts);
97
+
98
+ // createSeal writes the instance it actually used back into the seal options
99
+ if (sealOpts.i > 1 && !argv.cv) {
100
+ console.error(`warning: sealed with i=${sealOpts.i} and cv=none; a chain with i>1 only validates when cv=pass (use --cv pass)`);
51
101
  }
52
- if (signatureOpts.signTime) {
53
- console.error(`Signing time: ${signatureOpts.signTime.toISOString()}`);
102
+
103
+ process.stdout.write(sealHeaders);
104
+ if (!argv.headersOnly) {
105
+ // print full message as well
106
+ await writeMessageBody(gatherer);
54
107
  }
108
+ return;
109
+ }
110
+
111
+ if (argv.verbose) {
112
+ printSignatureInfo(signatureOpts);
55
113
  if (signatureOpts.headerList) {
56
114
  console.error(`Header fields to seal: ${signatureOpts.headerList}`);
57
115
  }
@@ -113,18 +171,7 @@ const cmd = async argv => {
113
171
  process.stdout.write(result.headers);
114
172
  if (!argv.headersOnly) {
115
173
  // print full message as well
116
- await new Promise((resolve, reject) => {
117
- let msgStream = gatherer.replay();
118
-
119
- msgStream.pipe(process.stdout, { end: false });
120
- msgStream.on('end', () => {
121
- resolve();
122
- });
123
-
124
- msgStream.on('error', err => {
125
- reject(err);
126
- });
127
- });
174
+ await writeMessageBody(gatherer);
128
175
  }
129
176
  };
130
177
 
@@ -4,24 +4,26 @@ const dns = require('node:dns').promises;
4
4
  const tldts = require('tldts');
5
5
  const { TLDTS_OPTS } = require('../tools');
6
6
 
7
+ // Tags whose values are ABNF literals in RFC 7489 6.4 / RFC 9989 4.7 and are therefore
8
+ // case insensitive (RFC 5234 2.3). Values of other tags keep their case: "v" is defined
9
+ // with explicit hex literals and is case sensitive, "rua"/"ruf" are URIs.
10
+ const CASE_INSENSITIVE_TAGS = new Set(['p', 'sp', 'np', 'adkim', 'aspf', 'fo', 'rf', 't', 'psd']);
11
+
12
+ // Returns every DMARC record published for a domain. The caller distinguishes an empty set,
13
+ // which continues policy discovery at the organizational domain, from a multi record set,
14
+ // which terminates it (RFC 7489 6.6.3 step 5).
15
+ // The version tag must be the first tag and its value "DMARC1" must match precisely
16
+ // (RFC 9989 4.7 "v"), while the ABNF allows *WSP around "=" and a case insensitive
17
+ // tag name, hence [vV] instead of an /i flag that would also relax the value
18
+ const DMARC_VERSION_RE = /^[vV][ \t]*=[ \t]*DMARC1[ \t]*(?:;|$)/;
19
+
7
20
  const resolveTxt = async (domain, resolver) => {
8
21
  try {
9
22
  let txt = await resolver(`_dmarc.${domain}`, 'TXT');
10
- if (!txt || !txt.length) {
11
- return false;
12
- }
13
-
14
- txt = txt.map(row => row.join('').trim()).filter(row => /^v=DMARC1\b/i.test(row));
15
-
16
- if (txt.length !== 1) {
17
- //no records or multiple records yield in no policy
18
- return false;
19
- }
20
-
21
- return txt[0];
23
+ return (txt || []).map(row => row.join('').trim()).filter(row => DMARC_VERSION_RE.test(row));
22
24
  } catch (err) {
23
25
  if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
24
- return false;
26
+ return [];
25
27
  }
26
28
  throw err;
27
29
  }
@@ -30,22 +32,27 @@ const resolveTxt = async (domain, resolver) => {
30
32
  const getDmarcRecord = async (domain, resolver) => {
31
33
  resolver = resolver || dns.resolve;
32
34
 
33
- let txt = await resolveTxt(domain, resolver);
35
+ let records = await resolveTxt(domain, resolver);
34
36
  let isOrgRecord = false;
35
37
 
36
- if (!txt) {
38
+ if (!records.length) {
37
39
  let orgDomain = tldts.getDomain(domain, TLDTS_OPTS);
38
- if (orgDomain !== domain) {
40
+ // tldts returns null for IPs, single labels and other non-domains, and querying
41
+ // "_dmarc.null" would coerce that into a hostname
42
+ if (orgDomain && orgDomain !== domain) {
39
43
  // try org domain as well
40
- txt = await resolveTxt(orgDomain, resolver);
44
+ records = await resolveTxt(orgDomain, resolver);
41
45
  isOrgRecord = true;
42
46
  }
43
47
  }
44
48
 
45
- if (!txt) {
49
+ if (records.length !== 1) {
50
+ // no record, or a multi record set that terminates policy discovery
46
51
  return false;
47
52
  }
48
53
 
54
+ let txt = records[0];
55
+
49
56
  let parsed = Object.fromEntries(
50
57
  txt
51
58
  .split(';')
@@ -58,10 +65,13 @@ const getDmarcRecord = async (domain, resolver) => {
58
65
  } else if (splitPos === 0) {
59
66
  return [false, e];
60
67
  }
68
+ // whitespace is allowed around "=" (*WSP in the ABNF), so the value must be trimmed
61
69
  let key = e.substr(0, splitPos).toLowerCase().trim();
62
- let val = e.substr(splitPos + 1);
70
+ let val = e.substr(splitPos + 1).trim();
63
71
  if (['pct', 'ri'].includes(key)) {
64
72
  val = parseInt(val, 10) || 0;
73
+ } else if (CASE_INSENSITIVE_TAGS.has(key)) {
74
+ val = val.toLowerCase();
65
75
  }
66
76
  return [key, val];
67
77
  })
@@ -48,7 +48,9 @@ const verifyDmarc = async opts => {
48
48
  comment: false,
49
49
  // ptype properties
50
50
  header: {
51
- from: orgDomain || domain
51
+ // RFC 8601 2.7.2: header.from is the domain of the RFC5322.From address,
52
+ // not the organizational domain the policy may have been inherited from
53
+ from: domain
52
54
  }
53
55
  };
54
56
 
@@ -68,8 +70,8 @@ const verifyDmarc = async opts => {
68
70
  return formatResponse({ status, domain: orgDomain || domain });
69
71
  }
70
72
 
71
- status.header = status.header || {};
72
- status.header.d = domain.split('_dmarc.').pop();
73
+ // the domain the applied policy record was published at
74
+ status.header.d = dmarcRecord.isOrgRecord ? orgDomain : domain;
73
75
 
74
76
  status.comment = []
75
77
  .concat(dmarcRecord.p ? `p=${dmarcRecord.p.toUpperCase()}` : [])
@@ -80,8 +82,16 @@ const verifyDmarc = async opts => {
80
82
  // use "sp" if this is a subdomain of an org domain and "sp" is set, otherwise use "p"
81
83
  const policy = dmarcRecord.isOrgRecord && dmarcRecord.sp ? dmarcRecord.sp : dmarcRecord.p;
82
84
 
83
- const dkimAlignment = getAlignment(domain, dkimDomains, { strict: dmarcRecord.adkim === 's' });
84
- const spfAlignment = getAlignment(domain, spfDomains, { strict: dmarcRecord.aspf === 's' });
85
+ const adkimStrict = dmarcRecord.adkim === 's';
86
+ const aspfStrict = dmarcRecord.aspf === 's';
87
+
88
+ const dkimAlignment = getAlignment(domain, dkimDomains, adkimStrict);
89
+ const spfAlignment = getAlignment(domain, spfDomains, aspfStrict);
90
+
91
+ // "underSized" warns that a passing DKIM signature covers only part of the body (l= tag).
92
+ // It describes the signature rather than the alignment mode, so it is still reported when
93
+ // strict alignment rejected a signature that aligns at the org level.
94
+ const underSized = (adkimStrict && !dkimAlignment ? getAlignment(domain, dkimDomains, false) : dkimAlignment)?.underSized;
85
95
 
86
96
  if (dkimAlignment || spfAlignment) {
87
97
  // pass
@@ -101,8 +111,8 @@ const verifyDmarc = async opts => {
101
111
  rr: dmarcRecord.rr,
102
112
 
103
113
  alignment: {
104
- spf: { result: spfAlignment?.domain, strict: dmarcRecord.aspf === 's' },
105
- dkim: { result: dkimAlignment?.domain, strict: dmarcRecord.adkim === 's', underSized: dkimAlignment?.underSized }
114
+ spf: { result: spfAlignment?.domain, strict: aspfStrict },
115
+ dkim: { result: dkimAlignment?.domain, strict: adkimStrict, underSized }
106
116
  }
107
117
  });
108
118
  };
package/lib/mailauth.js CHANGED
@@ -8,6 +8,7 @@ const { arc, createSeal, sealMessage, getARChain, verifyASChain } = require('./a
8
8
  const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
9
9
  const { validateSvg: validateBimiSvg } = require('./bimi/validate-svg');
10
10
  const { parseReceived } = require('./parse-received');
11
+ const { getAlignment } = require('./tools');
11
12
  const libmime = require('libmime');
12
13
  const os = require('node:os');
13
14
  const { isIP } = require('net');
@@ -133,6 +134,14 @@ const authenticate = async (input, opts) => {
133
134
  if (dmarcResult.info) {
134
135
  arHeader.push(`${libmime.foldLines(dmarcResult.info, 160)}`);
135
136
  }
137
+
138
+ if (dmarcResult.alignment?.dkim?.strict) {
139
+ // the DKIM verifier only knows relaxed alignment, it runs before the DMARC record
140
+ let fromDomain = dmarcResult.status.header.from;
141
+ for (let row of dkimResult.results || []) {
142
+ row.status.aligned = (row.status.aligned && getAlignment(fromDomain, [row.signingDomain], true)?.domain) || false;
143
+ }
144
+ }
136
145
  }
137
146
 
138
147
  let bimiResult;
@@ -1,5 +1,13 @@
1
1
  'use strict';
2
2
 
3
+ const { DANGEROUS_KEYS } = require('./safe-keys');
4
+
5
+ // Keys the headerParser result pre-seeds: "header" holds the header name and "value" the
6
+ // authserv-id picked from the first key-only part. A crafted part must not overwrite them,
7
+ // readers expect strings there, not parsed entry objects. This is a schema fact of this
8
+ // parser (valueParser has its own reservation: "header" is a legitimate ptype there).
9
+ const RESERVED_RESULT_KEYS = new Set(['header', 'value']);
10
+
3
11
  // NB! fails to properly parse nested comments (should be rare enough though)
4
12
 
5
13
  const valueParser = str => {
@@ -97,8 +105,17 @@ const valueParser = str => {
97
105
  parts.slice(1).forEach(part => {
98
106
  if (part.key || part.value) {
99
107
  let path = part.key.split('.');
108
+
109
+ // A propspec is "ptype.property" (RFC 8601 2.2), so at most two segments, and
110
+ // "value" already holds the method result. Anything else would either overwrite
111
+ // that or nest an object where every reader of this shape expects a string.
112
+ if (path.length > 2 || path.some(p => p === 'value' || DANGEROUS_KEYS.has(p))) {
113
+ return;
114
+ }
115
+
100
116
  let curRes = result;
101
117
  let final = path.pop();
118
+
102
119
  for (let p of path) {
103
120
  if (typeof curRes[p] !== 'object' || !curRes[p]) {
104
121
  curRes[p] = {};
@@ -266,6 +283,11 @@ const headerParser = buf => {
266
283
  }
267
284
 
268
285
  parts.forEach(part => {
286
+ if (!part.key || RESERVED_RESULT_KEYS.has(part.key) || DANGEROUS_KEYS.has(part.key)) {
287
+ // nothing would be assigned, so do not parse the part either
288
+ return;
289
+ }
290
+
269
291
  let entry = {
270
292
  value: part.value
271
293
  };
@@ -279,15 +301,13 @@ const headerParser = buf => {
279
301
  entry.comment = part.comment;
280
302
  }
281
303
 
282
- if (part.key && !['__proto__', 'constructor'].includes(part.key)) {
283
- if (['arc-authentication-results', 'authentication-results'].includes(headerKey) && part.key === 'dkim') {
284
- if (!result[part.key]) {
285
- result[part.key] = [];
286
- }
287
- result[part.key].push(entry);
288
- } else {
289
- result[part.key] = entry;
304
+ if (['arc-authentication-results', 'authentication-results'].includes(headerKey) && part.key === 'dkim') {
305
+ if (!result[part.key]) {
306
+ result[part.key] = [];
290
307
  }
308
+ result[part.key].push(entry);
309
+ } else {
310
+ result[part.key] = entry;
291
311
  }
292
312
  });
293
313
 
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { DANGEROUS_KEYS } = require('./safe-keys');
4
+
3
5
  const parseReceived = buf => {
4
6
  let header = (buf || '').toString();
5
7
 
@@ -156,6 +158,10 @@ const parseReceived = buf => {
156
158
  result.tls = { value: '', ...(val.comment && { comment: val.comment }) };
157
159
  val.comment = '';
158
160
  }
161
+ if (DANGEROUS_KEYS.has(key)) {
162
+ // skip the assignment only: the transport security of the hop is worth keeping
163
+ continue;
164
+ }
159
165
  result[key] = { value: val.value, ...(val.comment && { comment: val.comment }) };
160
166
  } else if (!result.tls && /tls|cipher=|Google Transport Security/i.test(val.comment)) {
161
167
  result.tls = { value: val.value, ...(val.comment && { comment: val.comment }) };
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+
3
+ // Property names that must never be used as parsed keys. Assigning "__proto__" replaces the
4
+ // prototype of the parsed object instead of adding a property to it, and assigning any other
5
+ // inherited Object.prototype member shadows that method, so later string coercion of the
6
+ // object (or a hasOwnProperty call on it) throws instead of returning a value.
7
+ // "prototype" is the only name here that is not already an own property of Object.prototype.
8
+ const DANGEROUS_KEYS = new Set(['prototype', ...Object.getOwnPropertyNames(Object.prototype)]);
9
+
10
+ module.exports = { DANGEROUS_KEYS };