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/.ncurc.js +1 -2
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/bin/mailauth.js +261 -401
- package/cli.md +14 -0
- package/docs/dmarc.md +28 -33
- package/index.d.ts +26 -7
- package/lib/arc/index.js +7 -4
- package/lib/commands/seal.js +68 -21
- package/lib/dmarc/get-dmarc-record.js +29 -19
- package/lib/dmarc/verify.js +17 -7
- package/lib/mailauth.js +9 -0
- package/lib/parse-dkim-headers.js +28 -8
- package/lib/parse-received.js +6 -0
- package/lib/safe-keys.js +10 -0
- package/lib/tools.js +27 -10
- package/package.json +22 -22
- package/CLAUDE.md +0 -66
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` |
|
|
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
|
|
59
|
-
| -------- |
|
|
60
|
-
| `result` | `string\|
|
|
61
|
-
| `strict` | `boolean`
|
|
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
|
|
66
|
-
| ------------ |
|
|
67
|
-
| `result` | `string\|
|
|
68
|
-
| `strict` | `boolean`
|
|
69
|
-
| `underSized` | `number`
|
|
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
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
+
/**
|
|
410
|
+
* Aligned signing domain, or undefined if no domain aligned
|
|
411
|
+
*/
|
|
412
|
+
result?: string;
|
|
400
413
|
strict: boolean;
|
|
401
|
-
|
|
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?:
|
|
972
|
-
underSized?:
|
|
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
|
|
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
|
-
|
|
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
|
|
448
|
+
if (typeof result.authenticationResults.dmarc?.header?.from === 'string') {
|
|
449
|
+
comment.push(`fromdomain=${result.authenticationResults.dmarc.header.from}`);
|
|
447
450
|
}
|
|
448
451
|
}
|
|
449
452
|
|
package/lib/commands/seal.js
CHANGED
|
@@ -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.
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
35
|
+
let records = await resolveTxt(domain, resolver);
|
|
34
36
|
let isOrgRecord = false;
|
|
35
37
|
|
|
36
|
-
if (!
|
|
38
|
+
if (!records.length) {
|
|
37
39
|
let orgDomain = tldts.getDomain(domain, TLDTS_OPTS);
|
|
38
|
-
|
|
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
|
-
|
|
44
|
+
records = await resolveTxt(orgDomain, resolver);
|
|
41
45
|
isOrgRecord = true;
|
|
42
46
|
}
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
if (
|
|
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
|
})
|
package/lib/dmarc/verify.js
CHANGED
|
@@ -48,7 +48,9 @@ const verifyDmarc = async opts => {
|
|
|
48
48
|
comment: false,
|
|
49
49
|
// ptype properties
|
|
50
50
|
header: {
|
|
51
|
-
|
|
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
|
-
|
|
72
|
-
status.header.d = domain
|
|
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
|
|
84
|
-
const
|
|
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:
|
|
105
|
-
dkim: { result: dkimAlignment?.domain, strict:
|
|
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 (
|
|
283
|
-
if ([
|
|
284
|
-
|
|
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
|
|
package/lib/parse-received.js
CHANGED
|
@@ -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 }) };
|
package/lib/safe-keys.js
ADDED
|
@@ -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 };
|