mailauth 5.0.3 → 7.0.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +23 -0
- package/README.md +117 -41
- package/bin/mailauth.js +14 -6
- package/cli.md +8 -3
- package/docs/arc.md +126 -43
- package/docs/bimi.md +54 -37
- package/docs/dkim.md +79 -40
- package/docs/dmarc.md +82 -31
- package/docs/mta-sts.md +140 -42
- package/docs/spf.md +69 -4
- package/index.d.ts +499 -45
- package/lib/arc/index.d.ts +10 -8
- package/lib/arc/index.js +505 -124
- package/lib/bimi/index.js +162 -81
- package/lib/commands/report.js +2 -1
- package/lib/commands/seal.js +35 -5
- package/lib/commands/sign.js +8 -1
- package/lib/commands/spf.js +8 -0
- package/lib/dkim/body/relaxed.js +6 -5
- package/lib/dkim/body/simple.js +1 -3
- package/lib/dkim/dkim-signer.js +219 -33
- package/lib/dkim/dkim-verifier.js +574 -231
- package/lib/dkim/header/relaxed.js +6 -43
- package/lib/dkim/header/signature-opts.js +87 -0
- package/lib/dkim/header/simple.js +6 -43
- package/lib/dkim/message-parser.js +18 -4
- package/lib/dkim/sign.d.ts +13 -7
- package/lib/dkim/sign.js +18 -2
- package/lib/dkim/syntax.js +31 -0
- package/lib/dkim/verify.js +1 -0
- package/lib/dmarc/author-domain.js +181 -0
- package/lib/dmarc/get-dmarc-record.js +267 -53
- package/lib/dmarc/index.d.ts +29 -2
- package/lib/dmarc/index.js +2 -1
- package/lib/dmarc/verify.js +205 -53
- package/lib/mailauth.js +86 -40
- package/lib/mta-sts.d.ts +77 -11
- package/lib/mta-sts.js +497 -182
- package/lib/parse-dkim-headers.js +376 -191
- package/lib/parse-received.js +104 -32
- package/lib/spf/index.d.ts +37 -1
- package/lib/spf/index.js +260 -28
- package/lib/spf/macro.js +50 -13
- package/lib/spf/spf-verify.js +440 -204
- package/lib/spf/syntax.js +366 -0
- package/lib/tools.js +524 -118
- package/package.json +12 -12
package/docs/dmarc.md
CHANGED
|
@@ -6,6 +6,8 @@ This document describes the result object returned by DMARC verification.
|
|
|
6
6
|
|
|
7
7
|
DMARC (Domain-based Message Authentication, Reporting, and Conformance) is verified during the authentication process. The DMARC result depends on both SPF and DKIM verification results.
|
|
8
8
|
|
|
9
|
+
Policy discovery and Organizational Domains follow the DNS Tree Walk of RFC 9989 section 4.10, not the Public Suffix List. The policy is taken from the record of the author domain, or else from the record of its Organizational Domain, or else from a record published with `psd=y` above it. Relaxed alignment compares the Organizational Domains of the author domain and of each authenticated identifier, which can take further walks. A walk makes at most eight queries, lookups are shared within a message, only identifiers at or below the author's Organizational Domain are walked, and at most ten identifiers per message are walked. An identifier past that limit is treated like one whose walk failed: it gives `temperror` unless another identifier aligns, never a definite `fail`.
|
|
10
|
+
|
|
9
11
|
```javascript
|
|
10
12
|
const { authenticate } = require('mailauth');
|
|
11
13
|
|
|
@@ -16,35 +18,70 @@ const { dmarc } = await authenticate(message, {
|
|
|
16
18
|
});
|
|
17
19
|
```
|
|
18
20
|
|
|
21
|
+
The author domain is the domain of the addr-spec in the From header (RFC 9989 5.3.1, RFC 5322 3.4.1). An "@" inside a quoted local-part (`"ceo@x"@bank.example`) or an obsolete source route (`<@relay.example:ceo@bank.example>`) does not change it. It is lower-cased, converted to an A-label, and a trailing dot is ignored. Several From addresses are evaluated when they all have the same domain. When there is no domain, or more than one, or a mailbox has no usable domain, or the message has more than one From header field, DMARC validation is not possible: `dmarc()` returns `false` and `authenticate()` returns `dmarc: false`, as for a message without a From address. The lower level `evaluateDmarc()` from `mailauth/lib/dmarc` also returns `reason` (`"no-author-domain"`, `"multiple-author-domains"`, `"invalid-author-domain"` or `"multiple-from-fields"`) and `authorDomains` in that case. RFC 9989 11.5 warns that such messages are often abusive, so a receiver should handle them by local policy instead of treating them as mail without DMARC.
|
|
22
|
+
|
|
23
|
+
```javascript
|
|
24
|
+
const { dmarc } = require('mailauth/lib/dmarc');
|
|
25
|
+
|
|
26
|
+
const result = await dmarc({
|
|
27
|
+
headerFrom: 'user@example.com', // or a list of From addresses
|
|
28
|
+
spfDomains: ['example.com'], // the MAIL FROM domain, if SPF passed
|
|
29
|
+
dkimDomains: [{ domain: 'example.com' }], // signing domains of passing DKIM signatures
|
|
30
|
+
strict: false, // true for the RFC tag-list rules and no header.d, see below
|
|
31
|
+
resolver // optional, defaults to dns.promises.resolve
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Strict mode
|
|
36
|
+
|
|
37
|
+
With `strict: true`, which `authenticate(message, { strict: true })` passes on:
|
|
38
|
+
|
|
39
|
+
- Records are parsed by the tag-list rules of RFC 6376 3.2 that DMARC uses (RFC 9989 4.7). Tag names are case sensitive, so `P=reject` is an unknown tag (only the version tag may be `V`, which the DMARC ABNF allows), and a record with a duplicated tag is invalid and ignored, as if it was not published. By default tag names are case-folded and the last of duplicated tags wins, and the result lists `"tag-case"` or `"duplicate-tag"` in `warnings`.
|
|
40
|
+
- `status.header.d` is not set and `header.d` is left out of the Authentication-Results entry, because it is not a registered property for `dmarc` (RFC 9989 9.1). `policyDomain` still has the domain.
|
|
41
|
+
|
|
19
42
|
## Result Object Fields
|
|
20
43
|
|
|
21
|
-
| Field
|
|
22
|
-
|
|
|
23
|
-
| `status`
|
|
24
|
-
| `domain`
|
|
25
|
-
| `policy`
|
|
26
|
-
| `p`
|
|
27
|
-
| `sp`
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
44
|
+
| Field | Type | Presence | Description |
|
|
45
|
+
| -------------- | ---------- | ------------ | -------------------------------------------------------------------------------- |
|
|
46
|
+
| `status` | `object` | Always | Verification status object (see below) |
|
|
47
|
+
| `domain` | `string` | Always | Organizational Domain of the author domain (Tree Walk) |
|
|
48
|
+
| `policy` | `string` | Record found | Effective policy (`"reject"`, `"quarantine"`, or `"none"`), see below |
|
|
49
|
+
| `p` | `string` | Record found | Policy from `p=` tag |
|
|
50
|
+
| `sp` | `string` | Record found | Subdomain policy from `sp=` tag (defaults to `p` value) |
|
|
51
|
+
| `np` | `string` | Record found | Non-existent subdomain policy from `np=` tag, if published |
|
|
52
|
+
| `testMode` | `boolean` | Record found | `true` when the record has `t=y` |
|
|
53
|
+
| `rr` | `string` | Record found | Raw DMARC DNS TXT record |
|
|
54
|
+
| `policyDomain` | `string` | Record found | The DMARC Policy Domain (RFC 9989 3.2.5), where the applied record was published |
|
|
55
|
+
| `alignment` | `object` | Record found | SPF and DKIM alignment details (see below) |
|
|
56
|
+
| `error` | `string` | On temperror | Error message |
|
|
57
|
+
| `warnings` | `string[]` | Lax parsing | What the default mode accepted that `strict` would not (see Strict mode) |
|
|
58
|
+
| `info` | `string` | Always | Formatted Authentication-Results header value |
|
|
59
|
+
|
|
60
|
+
On a `temperror` that happens after a record was found, `policy`, `p`, `sp`, `rr` and `alignment` are still set. That is the case when a Tree Walk needed for relaxed alignment failed or was over the limit and no other identifier aligned, or when the existence query that chooses between `sp` and `np` failed and nothing aligned.
|
|
61
|
+
|
|
62
|
+
`policyDomain` is the domain an RFC 9990 aggregate report lists as the domain of the published policy. By default it is also in `status.header.d`. `domain` is the Organizational Domain, which is a different domain when the policy came from the author domain's own record below it, or from a PSD above it.
|
|
63
|
+
|
|
64
|
+
`policy` is `p` for the author domain's own record. For a record inherited from the Organizational Domain or a PSD it is `np` when the author domain does not exist (NXDOMAIN, RFC 9989 A.4), otherwise `sp`, both falling back to `p`. The existence query is only made when the record has `np`. When it fails and `np` differs from the other policy, a message that passes still passes, `policy` is the weaker of the two and `policy.dmarc` is left out, and a message that does not pass gets `temperror`. With `t=y` the policy is one level below that, so `reject` becomes `quarantine` and `quarantine` becomes `none`, while `p`, `sp` and `np` stay as published.
|
|
65
|
+
|
|
66
|
+
A record without a valid `p`, or with an invalid `sp` or `np`, is applied as `p=none` when its `rua` lists a syntactically valid URI (RFC 3986, so `mailto:` counts and a bare address does not), and otherwise gets no DMARC processing (result `none`). `pct` is historic in RFC 9989 and is ignored.
|
|
33
67
|
|
|
34
68
|
## status Object
|
|
35
69
|
|
|
36
|
-
| Field | Type | Description
|
|
37
|
-
| --------- | -------- |
|
|
38
|
-
| `result` | `string` | DMARC result code (see below)
|
|
39
|
-
| `header` | `object` | Header information
|
|
40
|
-
| `
|
|
70
|
+
| Field | Type | Description |
|
|
71
|
+
| --------- | -------- | ------------------------------------------------------- |
|
|
72
|
+
| `result` | `string` | DMARC result code (see below) |
|
|
73
|
+
| `header` | `object` | Header information |
|
|
74
|
+
| `policy` | `object` | `{ dmarc }`, the evaluated policy, on `pass` and `fail` |
|
|
75
|
+
| `comment` | `string` | Policy and ARC information |
|
|
76
|
+
|
|
77
|
+
`policy.dmarc` (RFC 9989 9.1) is the `policy` value, so after `sp`, `np` and `t=y` were applied. It is only set when the result is `pass` or `fail` and the policy is known.
|
|
41
78
|
|
|
42
79
|
### status.header Object
|
|
43
80
|
|
|
44
|
-
| Field | Type | Description
|
|
45
|
-
| ------ | -------- |
|
|
46
|
-
| `from` | `string` | Domain of the From header address
|
|
47
|
-
| `d` | `string` | Domain where DMARC record was found |
|
|
81
|
+
| Field | Type | Description |
|
|
82
|
+
| ------ | -------- | --------------------------------------------------------------------------------------------------------------- |
|
|
83
|
+
| `from` | `string` | Domain of the From header address |
|
|
84
|
+
| `d` | `string` | Domain where the applied DMARC record was found (author, Organizational Domain, or PSD). Not set in strict mode |
|
|
48
85
|
|
|
49
86
|
## alignment Object
|
|
50
87
|
|
|
@@ -72,12 +109,12 @@ const { dmarc } = await authenticate(message, {
|
|
|
72
109
|
|
|
73
110
|
## Result Values
|
|
74
111
|
|
|
75
|
-
| Result | Description
|
|
76
|
-
| ----------- |
|
|
77
|
-
| `pass` | Message passed DMARC (SPF or DKIM aligned and passed)
|
|
78
|
-
| `fail` | Message failed DMARC (neither SPF nor DKIM aligned)
|
|
79
|
-
| `none` | No DMARC record found
|
|
80
|
-
| `temperror` | Temporary error during DNS lookup
|
|
112
|
+
| Result | Description |
|
|
113
|
+
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
114
|
+
| `pass` | Message passed DMARC (SPF or DKIM aligned and passed) |
|
|
115
|
+
| `fail` | Message failed DMARC (neither SPF nor DKIM aligned) |
|
|
116
|
+
| `none` | No DMARC record found |
|
|
117
|
+
| `temperror` | Temporary error during a DNS lookup that the result depends on, or an identifier that could not be checked because of the Tree Walk limit |
|
|
81
118
|
|
|
82
119
|
## Policy Values
|
|
83
120
|
|
|
@@ -109,12 +146,17 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
|
|
|
109
146
|
"header": {
|
|
110
147
|
"from": "example.com",
|
|
111
148
|
"d": "example.com"
|
|
149
|
+
},
|
|
150
|
+
"policy": {
|
|
151
|
+
"dmarc": "reject"
|
|
112
152
|
}
|
|
113
153
|
},
|
|
114
154
|
"domain": "example.com",
|
|
155
|
+
"policyDomain": "example.com",
|
|
115
156
|
"policy": "reject",
|
|
116
157
|
"p": "reject",
|
|
117
158
|
"sp": "reject",
|
|
159
|
+
"testMode": false,
|
|
118
160
|
"rr": "v=DMARC1; p=reject; rua=mailto:dmarc@example.com",
|
|
119
161
|
"alignment": {
|
|
120
162
|
"spf": {
|
|
@@ -125,7 +167,7 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
|
|
|
125
167
|
"strict": false
|
|
126
168
|
}
|
|
127
169
|
},
|
|
128
|
-
"info": "dmarc=pass (p=REJECT) header.from=example.com header.d=example.com"
|
|
170
|
+
"info": "dmarc=pass (p=REJECT) policy.dmarc=reject header.from=example.com header.d=example.com"
|
|
129
171
|
}
|
|
130
172
|
```
|
|
131
173
|
|
|
@@ -139,12 +181,17 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
|
|
|
139
181
|
"header": {
|
|
140
182
|
"from": "example.com",
|
|
141
183
|
"d": "example.com"
|
|
184
|
+
},
|
|
185
|
+
"policy": {
|
|
186
|
+
"dmarc": "reject"
|
|
142
187
|
}
|
|
143
188
|
},
|
|
144
189
|
"domain": "example.com",
|
|
190
|
+
"policyDomain": "example.com",
|
|
145
191
|
"policy": "reject",
|
|
146
192
|
"p": "reject",
|
|
147
193
|
"sp": "reject",
|
|
194
|
+
"testMode": false,
|
|
148
195
|
"rr": "v=DMARC1; p=reject; rua=mailto:dmarc@example.com",
|
|
149
196
|
"alignment": {
|
|
150
197
|
"spf": {
|
|
@@ -154,7 +201,7 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
|
|
|
154
201
|
"strict": false
|
|
155
202
|
}
|
|
156
203
|
},
|
|
157
|
-
"info": "dmarc=fail (p=REJECT) header.from=example.com header.d=example.com"
|
|
204
|
+
"info": "dmarc=fail (p=REJECT) policy.dmarc=reject header.from=example.com header.d=example.com"
|
|
158
205
|
}
|
|
159
206
|
```
|
|
160
207
|
|
|
@@ -168,7 +215,7 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
|
|
|
168
215
|
"from": "no-dmarc.example.com"
|
|
169
216
|
}
|
|
170
217
|
},
|
|
171
|
-
"domain": "example.com",
|
|
218
|
+
"domain": "no-dmarc.example.com",
|
|
172
219
|
"info": "dmarc=none header.from=no-dmarc.example.com"
|
|
173
220
|
}
|
|
174
221
|
```
|
|
@@ -183,9 +230,13 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
|
|
|
183
230
|
"header": {
|
|
184
231
|
"from": "sub.example.com",
|
|
185
232
|
"d": "example.com"
|
|
233
|
+
},
|
|
234
|
+
"policy": {
|
|
235
|
+
"dmarc": "quarantine"
|
|
186
236
|
}
|
|
187
237
|
},
|
|
188
238
|
"domain": "example.com",
|
|
239
|
+
"policyDomain": "example.com",
|
|
189
240
|
"policy": "quarantine",
|
|
190
241
|
"p": "none",
|
|
191
242
|
"sp": "quarantine",
|
|
@@ -199,7 +250,7 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
|
|
|
199
250
|
"strict": false
|
|
200
251
|
}
|
|
201
252
|
},
|
|
202
|
-
"info": "dmarc=pass (p=NONE sp=QUARANTINE) header.from=sub.example.com header.d=example.com"
|
|
253
|
+
"info": "dmarc=pass (p=NONE sp=QUARANTINE) policy.dmarc=quarantine header.from=sub.example.com header.d=example.com"
|
|
203
254
|
}
|
|
204
255
|
```
|
|
205
256
|
|
package/docs/mta-sts.md
CHANGED
|
@@ -10,41 +10,53 @@ MTA-STS allows domain owners to declare that their mail servers support TLS and
|
|
|
10
10
|
const { getPolicy, validateMx } = require('mailauth/lib/mta-sts');
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
## Options
|
|
14
|
+
|
|
15
|
+
`getPolicy`, `fetchPolicy` and `resolvePolicy` accept an options object as their last argument. `parsePolicy` and `validateMx` accept `{ strict }`.
|
|
16
|
+
|
|
17
|
+
| Option | Type | Default | Description |
|
|
18
|
+
| --------------- | ---------- | ---------------------- | ------------------------------------------------------------------------------------------ |
|
|
19
|
+
| `resolver` | `function` | `dns.promises.resolve` | Async DNS resolver with the `dns.promises.resolve` signature |
|
|
20
|
+
| `strict` | `boolean` | `false` | Apply the RFC 8461 rules exactly, see [Strict Mode](#strict-mode) |
|
|
21
|
+
| `timeout` | `number` | `60000` | Overall time limit for the HTTPS policy request in milliseconds (the idle timeout is 15 s) |
|
|
22
|
+
| `maxPolicySize` | `number` | `65536` | Maximum policy file size in bytes, larger responses fail with the `policy_too_large` error |
|
|
23
|
+
|
|
13
24
|
## getPolicy Result
|
|
14
25
|
|
|
15
|
-
The `getPolicy` function fetches and returns the MTA-STS policy for a domain.
|
|
26
|
+
The `getPolicy` function fetches and returns the MTA-STS policy for a domain. The domain may also be given as an email address.
|
|
16
27
|
|
|
17
28
|
```javascript
|
|
18
|
-
const { policy, status } = await getPolicy('example.com', knownPolicy);
|
|
29
|
+
const { policy, status, warnings } = await getPolicy('example.com', knownPolicy, { strict: false });
|
|
19
30
|
```
|
|
20
31
|
|
|
21
32
|
### Result Object Fields
|
|
22
33
|
|
|
23
|
-
| Field
|
|
24
|
-
|
|
|
25
|
-
| `policy`
|
|
26
|
-
| `status`
|
|
34
|
+
| Field | Type | Description |
|
|
35
|
+
| ---------- | ---------- | ------------------------------------------------------------------------------------------------- |
|
|
36
|
+
| `policy` | `object` | The MTA-STS policy object to apply and to store in your cache (see below) |
|
|
37
|
+
| `status` | `string` | Policy retrieval status (see below) |
|
|
38
|
+
| `warnings` | `string[]` | Only present when lax mode accepted something strict mode would reject, see [Warnings](#warnings) |
|
|
27
39
|
|
|
28
40
|
### Status Values
|
|
29
41
|
|
|
30
|
-
| Status | Description
|
|
31
|
-
| ----------- |
|
|
32
|
-
| `found` |
|
|
33
|
-
| `not_found` | No MTA-STS policy exists for the domain
|
|
34
|
-
| `renewed` |
|
|
35
|
-
| `errored` |
|
|
42
|
+
| Status | Description |
|
|
43
|
+
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
44
|
+
| `found` | A new or updated policy was fetched successfully |
|
|
45
|
+
| `not_found` | No MTA-STS policy exists for the domain, and there is no valid cached policy |
|
|
46
|
+
| `renewed` | The cached policy has the same ID as the DNS record and has not expired. It is returned unchanged, without fetching the policy file |
|
|
47
|
+
| `errored` | No live policy could be discovered or fetched. `policy.error` holds the reason. `policy` is the valid cached policy if there is one, otherwise a policy with mode `none` (see below) |
|
|
36
48
|
|
|
37
49
|
### Policy Object Fields
|
|
38
50
|
|
|
39
|
-
| Field | Type | Presence
|
|
40
|
-
| --------- | --------------- |
|
|
41
|
-
| `id` | `string\|false` | Always
|
|
42
|
-
| `version` | `string` | Policy found
|
|
43
|
-
| `mode` | `string` | Always
|
|
44
|
-
| `mx` | `string[]` |
|
|
45
|
-
| `maxAge` | `number` | Policy found
|
|
46
|
-
| `expires` | `string` | Policy found
|
|
47
|
-
| `error` | `Error` |
|
|
51
|
+
| Field | Type | Presence | Description |
|
|
52
|
+
| --------- | --------------- | ---------------- | ------------------------------------------------------------------------- |
|
|
53
|
+
| `id` | `string\|false` | Always | Policy ID from DNS TXT record, or `false` if not found |
|
|
54
|
+
| `version` | `string` | Policy found | Always `"STSv1"` for valid policies |
|
|
55
|
+
| `mode` | `string` | Always | Policy mode (see below) |
|
|
56
|
+
| `mx` | `string[]` | Listed in policy | Array of allowed MX hostnames (may include wildcards) |
|
|
57
|
+
| `maxAge` | `number` | Policy found | Policy validity period in seconds |
|
|
58
|
+
| `expires` | `string` | Policy found | ISO 8601 expiration timestamp |
|
|
59
|
+
| `error` | `Error` | Status errored | Error object explaining why no live policy could be discovered or fetched |
|
|
48
60
|
|
|
49
61
|
### Mode Values
|
|
50
62
|
|
|
@@ -54,6 +66,57 @@ const { policy, status } = await getPolicy('example.com', knownPolicy);
|
|
|
54
66
|
| `enforce` | Strictly enforce TLS and MX restrictions |
|
|
55
67
|
| `none` | No policy in effect |
|
|
56
68
|
|
|
69
|
+
## Caching
|
|
70
|
+
|
|
71
|
+
Pass the `policy` value of the previous result as `knownPolicy`, and store the `policy` value of every result, whatever the status. `getPolicy` implements the RFC 8461 3.3 and 5.1 cache rules itself:
|
|
72
|
+
|
|
73
|
+
```javascript
|
|
74
|
+
const knownPolicy = await cache.get(domain); // undefined if not cached
|
|
75
|
+
const { policy } = await getPolicy(domain, knownPolicy);
|
|
76
|
+
await cache.set(domain, policy); // keep the entry at least until policy.expires
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- **Valid cached policy, same ID in DNS:** the cached policy is returned as `renewed` without an HTTPS request. Its `expires` value is not extended, so the policy file is fetched again once it expires.
|
|
80
|
+
- **Valid cached policy, no live policy:** if the TXT lookup fails, the TXT record is missing, unusable or duplicated, the policy host has no address, or the new policy can not be fetched or is invalid, the cached policy is returned with `status: "errored"` and the reason in `policy.error`. A valid cached policy is never replaced by `mode: "none"` because of a discovery failure. An exception is a cached policy that already has mode `none`, which may be replaced by the retry placeholder described below.
|
|
81
|
+
- **Expired cached policy:** the policy file is fetched again. If the fetch fails, the default lax mode keeps returning the expired `enforce` or `testing` policy (with the `expired-cache` warning), while strict mode treats the domain as having no policy.
|
|
82
|
+
- **No usable cached policy and the fetch fails:** the result is a placeholder with the new policy ID, mode `none`, and `expires` set one hour ahead. Storing it makes the next calls return it as `renewed` without HTTPS requests, so the policy host is not retried for an hour. A changed policy ID is fetched right away.
|
|
83
|
+
|
|
84
|
+
## Strict Mode
|
|
85
|
+
|
|
86
|
+
With `strict: true` the RFC 8461 rules are applied exactly. The default lax mode accepts the following, and reports each acceptance in the `warnings` array of the `getPolicy` result:
|
|
87
|
+
|
|
88
|
+
| Rule | Warning |
|
|
89
|
+
| ------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
|
|
90
|
+
| The TXT record must match the RFC 8461 3.1 syntax: case-sensitive `v=STSv1` at the start, `id` of 1 to 32 letters and digits | `txt-syntax` |
|
|
91
|
+
| Only HTTP status 200 is accepted (lax accepts any 2xx) | `http-status` |
|
|
92
|
+
| The policy must be served with the `text/plain` media type | `content-type` |
|
|
93
|
+
| Policy field names and the `mode` value are case-sensitive, `max_age` is 1 to 10 digits, `mx` values are `["*."] Domain` | `policy-syntax` |
|
|
94
|
+
| The Policy Host certificate must match through a DNS-ID subjectAltName, with no CN fallback and no partial-label wildcards (`mta-*`) | `cert-identity` |
|
|
95
|
+
| An expired cached policy is not applied when the policy can not be fetched | `expired-cache` |
|
|
96
|
+
|
|
97
|
+
A strict TXT record syntax error makes the record unusable, so it is handled like a missing record. The other strict failures are fetch errors, so a valid cached policy is kept.
|
|
98
|
+
|
|
99
|
+
These rules apply in both modes:
|
|
100
|
+
|
|
101
|
+
- Only records that begin with `v=STSv1;` are counted when checking for multiple TXT records. Two or more remaining records fail with `multi_sts_records`.
|
|
102
|
+
- If a TXT or policy field other than `mx` is repeated, the first value is used.
|
|
103
|
+
- TXT extension fields may contain any value allowed by RFC 8461, including `:`, `(` and quotes.
|
|
104
|
+
- A policy without a `mode` field is invalid.
|
|
105
|
+
- Redirects are not followed, and only 2xx responses (200 in strict mode) are accepted.
|
|
106
|
+
- The policy file size is limited by `maxPolicySize` and the whole request by `timeout`.
|
|
107
|
+
- Internationalized domain names are converted to A-labels for DNS queries, TLS SNI, the Host header and MX matching.
|
|
108
|
+
|
|
109
|
+
## Warnings
|
|
110
|
+
|
|
111
|
+
| Warning | Meaning |
|
|
112
|
+
| --------------- | ----------------------------------------------------------------------------- |
|
|
113
|
+
| `txt-syntax` | The TXT record does not match the RFC 8461 3.1 syntax |
|
|
114
|
+
| `http-status` | The policy was served with a 2xx status other than 200 |
|
|
115
|
+
| `content-type` | The policy was not served as `text/plain` |
|
|
116
|
+
| `policy-syntax` | The policy file does not match the RFC 8461 3.2 syntax |
|
|
117
|
+
| `cert-identity` | The Policy Host certificate matched only through the CN or a partial wildcard |
|
|
118
|
+
| `expired-cache` | An expired cached policy was returned because the policy could not be fetched |
|
|
119
|
+
|
|
57
120
|
## validateMx Result
|
|
58
121
|
|
|
59
122
|
The `validateMx` function checks if an MX hostname is valid according to the MTA-STS policy.
|
|
@@ -62,14 +125,16 @@ The `validateMx` function checks if an MX hostname is valid according to the MTA
|
|
|
62
125
|
const result = validateMx('alt1.mx.example.com', policy);
|
|
63
126
|
```
|
|
64
127
|
|
|
128
|
+
MX names are matched case-insensitively, a trailing dot is ignored, and U-label names are converted to A-labels. A wildcard pattern such as `*.example.com` matches exactly one complete left-most label: `mail.example.com` matches, but `example.com` and `foo.bar.example.com` do not. An `enforce` or `testing` policy without an `mx` list matches nothing. `validateMx` does not look at `policy.expires`; use `getPolicy` to refresh the policy.
|
|
129
|
+
|
|
65
130
|
### Result Object Fields
|
|
66
131
|
|
|
67
|
-
| Field | Type | Presence | Description
|
|
68
|
-
| --------- | --------- | ----------- |
|
|
69
|
-
| `valid` | `boolean` | Always | Whether the MX hostname is allowed
|
|
70
|
-
| `mode` | `string` | Always | Policy mode (`"testing"`, `"enforce"`, or `"none"`)
|
|
71
|
-
| `match` | `string` | Valid match | The
|
|
72
|
-
| `testing` | `boolean` | Always | Whether policy is in testing mode
|
|
132
|
+
| Field | Type | Presence | Description |
|
|
133
|
+
| --------- | --------- | ----------- | ------------------------------------------------------------------------------------- |
|
|
134
|
+
| `valid` | `boolean` | Always | Whether the MX hostname is allowed |
|
|
135
|
+
| `mode` | `string` | Always | Policy mode (`"testing"`, `"enforce"`, or `"none"`) |
|
|
136
|
+
| `match` | `string` | Valid match | The matching hostname, or for a wildcard the suffix without `*` (e.g. `.example.com`) |
|
|
137
|
+
| `testing` | `boolean` | Always | Whether policy is in testing mode |
|
|
73
138
|
|
|
74
139
|
## Example Output
|
|
75
140
|
|
|
@@ -101,7 +166,9 @@ const result = validateMx('alt1.mx.example.com', policy);
|
|
|
101
166
|
}
|
|
102
167
|
```
|
|
103
168
|
|
|
104
|
-
### Policy Renewed
|
|
169
|
+
### Policy Renewed
|
|
170
|
+
|
|
171
|
+
The cached policy is returned unchanged.
|
|
105
172
|
|
|
106
173
|
```json
|
|
107
174
|
{
|
|
@@ -117,7 +184,9 @@ const result = validateMx('alt1.mx.example.com', policy);
|
|
|
117
184
|
}
|
|
118
185
|
```
|
|
119
186
|
|
|
120
|
-
### Policy Error
|
|
187
|
+
### Policy Error Without a Cached Policy
|
|
188
|
+
|
|
189
|
+
The policy ID is kept, and `expires` is one hour ahead, so the policy host is not retried before that.
|
|
121
190
|
|
|
122
191
|
```json
|
|
123
192
|
{
|
|
@@ -134,6 +203,26 @@ const result = validateMx('alt1.mx.example.com', policy);
|
|
|
134
203
|
}
|
|
135
204
|
```
|
|
136
205
|
|
|
206
|
+
### Policy Error With a Valid Cached Policy
|
|
207
|
+
|
|
208
|
+
```json
|
|
209
|
+
{
|
|
210
|
+
"policy": {
|
|
211
|
+
"id": "20240115T120000",
|
|
212
|
+
"version": "STSv1",
|
|
213
|
+
"mode": "enforce",
|
|
214
|
+
"mx": ["mx1.example.com", "mx2.example.com"],
|
|
215
|
+
"maxAge": 86400,
|
|
216
|
+
"expires": "2024-01-16T12:00:00.000Z",
|
|
217
|
+
"error": {
|
|
218
|
+
"message": "No usable MTA-STS TXT record found for example.com",
|
|
219
|
+
"code": "sts_record_not_found"
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
"status": "errored"
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
137
226
|
### MX Validation - Valid Match
|
|
138
227
|
|
|
139
228
|
```json
|
|
@@ -191,25 +280,34 @@ const result = validateMx('alt1.mx.example.com', policy);
|
|
|
191
280
|
|
|
192
281
|
Errors that may appear in `policy.error`:
|
|
193
282
|
|
|
194
|
-
| Code
|
|
195
|
-
|
|
|
196
|
-
| `multi_sts_records`
|
|
197
|
-
| `
|
|
198
|
-
| `
|
|
199
|
-
| `
|
|
200
|
-
| `
|
|
201
|
-
| `
|
|
202
|
-
| `
|
|
203
|
-
| `
|
|
204
|
-
| `
|
|
283
|
+
| Code | Description |
|
|
284
|
+
| ------------------------------ | -------------------------------------------------------------------------------------------------------------- |
|
|
285
|
+
| `multi_sts_records` | More than one TXT record beginning with `v=STSv1;` found for `_mta-sts.{domain}` |
|
|
286
|
+
| `sts_record_not_found` | No MTA-STS TXT record (only reported when a valid cached policy is returned) |
|
|
287
|
+
| `invalid_sts_record` | The TXT record has no usable `id`, or a syntax error in strict mode (only reported with a valid cached policy) |
|
|
288
|
+
| `policy_host_not_found` | `mta-sts.{domain}` has no A or AAAA record (only reported with a valid cached policy) |
|
|
289
|
+
| `invalid_sts_version` | Policy file has invalid or missing version field |
|
|
290
|
+
| `invalid_sts_mode` | Policy file has invalid or missing mode field |
|
|
291
|
+
| `invalid_sts_max_age` | Policy file has invalid max_age value |
|
|
292
|
+
| `invalid_sts_mx` | Policy file missing mx field in enforce/testing mode, or invalid mx value (strict) |
|
|
293
|
+
| `invalid_content_type` | Policy was not served as `text/plain` (strict mode only) |
|
|
294
|
+
| `policy_too_large` | Policy file is larger than `maxPolicySize` |
|
|
295
|
+
| `HTTP_SOCKET_TIMEOUT` | The HTTPS connection was idle for too long |
|
|
296
|
+
| `HTTP_REQUEST_TIMEOUT` | The HTTPS request took longer than `timeout` |
|
|
297
|
+
| `http_incomplete_response` | The HTTPS response ended before the complete body was received |
|
|
298
|
+
| `http_status_{code}` | HTTP request returned a non-2xx status (any status other than 200 in strict mode) |
|
|
299
|
+
| `ERR_TLS_CERT_ALTNAME_INVALID` | The Policy Host certificate is not valid for `mta-sts.{domain}` |
|
|
300
|
+
| `ESERVFAIL`, `ETIMEOUT`, ... | DNS lookup failed |
|
|
205
301
|
|
|
206
302
|
## Usage Example
|
|
207
303
|
|
|
208
304
|
```javascript
|
|
209
305
|
const { getPolicy, validateMx } = require('mailauth/lib/mta-sts');
|
|
210
306
|
|
|
211
|
-
// Fetch policy
|
|
212
|
-
const
|
|
307
|
+
// Fetch policy, using and updating the cached policy
|
|
308
|
+
const knownPolicy = await cache.get('gmail.com');
|
|
309
|
+
const { policy, status } = await getPolicy('gmail.com', knownPolicy);
|
|
310
|
+
await cache.set('gmail.com', policy);
|
|
213
311
|
|
|
214
312
|
console.log(`Policy status: ${status}`);
|
|
215
313
|
console.log(`Policy mode: ${policy.mode}`);
|
package/docs/spf.md
CHANGED
|
@@ -17,6 +17,22 @@ const result = await spf({
|
|
|
17
17
|
});
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
## Options
|
|
21
|
+
|
|
22
|
+
| Option | Type | Default | Description |
|
|
23
|
+
| ----------------- | ---------- | ----------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| `sender` | `string` | `postmaster@helo` | MAIL FROM address. An empty value means a null reverse-path, so the HELO identity is checked |
|
|
25
|
+
| `ip` | `string` | Required | SMTP client IP address. IPv4-mapped IPv6 addresses (`::ffff:192.0.2.1`, `::ffff:c000:201`) are checked as IPv4 |
|
|
26
|
+
| `helo` | `string` | | EHLO/HELO hostname |
|
|
27
|
+
| `mta` | `string` | `os.hostname()` | Hostname of the MTA performing the check |
|
|
28
|
+
| `resolver` | `function` | `dns.resolve` | Custom DNS resolver |
|
|
29
|
+
| `maxResolveCount` | `number` | `10` | Maximum DNS lookups allowed |
|
|
30
|
+
| `maxVoidCount` | `number` | `2` | Maximum void (empty) DNS lookups allowed |
|
|
31
|
+
| `maxElapsedTime` | `number` | No limit | Maximum time in milliseconds for the whole evaluation. If exceeded, the result is `temperror` (RFC 7208 4.6.4) |
|
|
32
|
+
| `strict` | `boolean` | `false` | Follow RFC 7208 exactly instead of the lenient default, see [Strict Mode](#strict-mode) |
|
|
33
|
+
|
|
34
|
+
A missing or invalid `ip` gives a `temperror` result with the comment `missing or invalid client IP address`.
|
|
35
|
+
|
|
20
36
|
## Result Object Fields
|
|
21
37
|
|
|
22
38
|
| Field | Type | Presence | Description |
|
|
@@ -30,6 +46,8 @@ const result = await spf({
|
|
|
30
46
|
| `info` | `string` | Always | Formatted Authentication-Results header value |
|
|
31
47
|
| `rr` | `string` | Record found | Raw SPF DNS TXT record |
|
|
32
48
|
| `lookups` | `object` | Always | DNS lookup statistics (see below) |
|
|
49
|
+
| `explanation` | `string` | Fail only | Explanation string from the `exp` modifier (see below) |
|
|
50
|
+
| `warnings` | `array` | Default mode | Leniencies that strict mode would have rejected (see below) |
|
|
33
51
|
|
|
34
52
|
## status Object
|
|
35
53
|
|
|
@@ -46,6 +64,26 @@ const result = await spf({
|
|
|
46
64
|
| `mailfrom` | `string` | The MAIL FROM address |
|
|
47
65
|
| `helo` | `string` | The HELO/EHLO hostname |
|
|
48
66
|
|
|
67
|
+
In strict mode `status.smtp` holds only the identity that was checked (RFC 8601 section 2.7.2): `mailfrom` for a MAIL FROM check, or `helo` for a null reverse-path. The `mailfrom` value is then only the domain, unless the evaluated policy uses the `%{l}` or `%{s}` macro.
|
|
68
|
+
|
|
69
|
+
## explanation
|
|
70
|
+
|
|
71
|
+
When the result is `fail` because of a mechanism match and the record has an `exp` modifier, the explanation string is fetched and expanded as described in RFC 7208 section 6.2. Any problem with it (DNS error, no or several TXT records, syntax error, non-ASCII text) means that there is no explanation. The explanation lookup does not count toward the DNS lookup limits. The explanation of an included record is never used, and after a redirect only the explanation of the redirect target is used.
|
|
72
|
+
|
|
73
|
+
The explanation is text from the domain owner. It is not added to the generated headers. If you show it to an SMTP client, make clear that it comes from a third party, for example by prepending `"<domain> explains: "`.
|
|
74
|
+
|
|
75
|
+
## warnings
|
|
76
|
+
|
|
77
|
+
In the default mode some input is accepted that strict mode rejects. When that happens, the result gets a `warnings` array. It is never rendered into the headers. The values are:
|
|
78
|
+
|
|
79
|
+
| Warning | Meaning |
|
|
80
|
+
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
81
|
+
| `syntax-error` | An evaluated record has a syntax error (for example after the matching term, a `c`, `r` or `t` macro outside of explanation text, or a duplicate `exp`), or leading space |
|
|
82
|
+
| `void-lookup-limit` | The void lookup limit would have been exceeded if single-stack hosts were counted as void lookups, as RFC 7208 does |
|
|
83
|
+
| `non-ascii-local-part` | A `%{l}` or `%{s}` term was evaluated with a non-ASCII local-part (RFC 8616 section 4 says such terms do not match) |
|
|
84
|
+
|
|
85
|
+
`warnings` is not set for a `permerror` result.
|
|
86
|
+
|
|
49
87
|
## lookups Object
|
|
50
88
|
|
|
51
89
|
| Field | Type | Description |
|
|
@@ -81,6 +119,33 @@ The `status.comment` field follows this format based on the result:
|
|
|
81
119
|
| `permerror` | `"{mta}: permanent error in processing during lookup of {sender}: {text}"` |
|
|
82
120
|
| `temperror` | `"{mta}: error in processing during lookup of {sender}: {text}"` |
|
|
83
121
|
|
|
122
|
+
## Received-SPF Header
|
|
123
|
+
|
|
124
|
+
The `header` field holds a `Received-SPF` header (RFC 7208 section 9.1) with the `client-ip`, `envelope-from` and `helo` keys. `envelope-from` is omitted for a null reverse-path, and `client-ip` if no valid IP was given. Values that are not a dot-atom, such as IPv6 addresses and email addresses, are quoted.
|
|
125
|
+
|
|
126
|
+
## Strict Mode
|
|
127
|
+
|
|
128
|
+
By default the SPF check is lenient where the leniency does not change which domain is authenticated. With `strict: true` it follows RFC 7208 exactly:
|
|
129
|
+
|
|
130
|
+
- The whole record is validated before evaluation (RFC 7208 section 4.6). A syntax error anywhere in the record gives `permerror`, even after the matching term. By default records are evaluated lazily, so an error after the matching term is not noticed.
|
|
131
|
+
- The `c`, `r` and `t` macros are only allowed in explanation text, a macro number must be nonzero, and transformers must follow the ABNF (`%{d0}`, `%{d2x}` and `%{dr2}` are errors).
|
|
132
|
+
- `exp` may appear only once, and modifier names must follow the `name` rule.
|
|
133
|
+
- `ip4` and `ip6` networks and prefix lengths are checked against the ABNF (`ip4:2001:db8::1`, `ip6:192.0.2.1`, `ip6:2001:db8::/32//64` and `ip4:192.0.2.0/00` are errors).
|
|
134
|
+
- A record must start with exactly `v=spf1` followed by a space or the end of the record. Records with leading whitespace are ignored.
|
|
135
|
+
- A void lookup is counted for the query of the client's address type alone. By default an `a` or `mx` host that has only addresses of the other type is not counted as void.
|
|
136
|
+
- A term that uses `%{l}` or `%{s}` does not match if the local-part is not ASCII (RFC 8616 section 4).
|
|
137
|
+
- A target name that is not a valid domain name after macro expansion makes the `a`, `mx`, `exists` or `ptr` mechanism not match. By default it is a `permerror`. For `include` and `redirect` it is a `permerror` in both modes.
|
|
138
|
+
- Authentication-Results reports only the checked identity, see [status.smtp Object](#statussmtp-object).
|
|
139
|
+
|
|
140
|
+
These rules apply in both modes:
|
|
141
|
+
|
|
142
|
+
- A DNS error (other than NXDOMAIN or an empty answer) in any lookup, including the targets of `include` and `redirect`, gives `temperror`.
|
|
143
|
+
- Every `ptr` mechanism and every `%{p}` expansion counts toward the DNS lookup limit. A DNS error in the PTR lookup makes the `ptr` mechanism not match.
|
|
144
|
+
- `%{p}` expands to a validated PTR name or `unknown`.
|
|
145
|
+
- Trailing dots in domain-specs are accepted, names longer than 253 characters are truncated from the left, and uppercase macros are URL escaped.
|
|
146
|
+
- An internationalized MAIL FROM domain is converted to A-labels.
|
|
147
|
+
- Address lookups of MX hosts are not counted as void lookups.
|
|
148
|
+
|
|
84
149
|
## Example Output
|
|
85
150
|
|
|
86
151
|
### SPF Pass
|
|
@@ -99,7 +164,7 @@ The `status.comment` field follows this format based on the result:
|
|
|
99
164
|
"helo": "mail.example.com"
|
|
100
165
|
}
|
|
101
166
|
},
|
|
102
|
-
"header": "Received-SPF: pass (mx.receiver.com: domain of user@example.com designates 192.0.2.1 as permitted sender) client-ip=192.0.2.1;",
|
|
167
|
+
"header": "Received-SPF: pass (mx.receiver.com: domain of user@example.com designates 192.0.2.1 as permitted sender) client-ip=192.0.2.1;\r\n envelope-from=\"user@example.com\"; helo=mail.example.com;",
|
|
103
168
|
"info": "spf=pass (mx.receiver.com: domain of user@example.com designates 192.0.2.1 as permitted sender) smtp.mailfrom=user@example.com smtp.helo=mail.example.com",
|
|
104
169
|
"rr": "v=spf1 ip4:192.0.2.0/24 -all",
|
|
105
170
|
"lookups": {
|
|
@@ -127,7 +192,7 @@ The `status.comment` field follows this format based on the result:
|
|
|
127
192
|
"helo": "attacker.example.net"
|
|
128
193
|
}
|
|
129
194
|
},
|
|
130
|
-
"header": "Received-SPF: fail (mx.receiver.com: domain of user@example.com does not designate 203.0.113.1 as permitted sender) client-ip=203.0.113.1;",
|
|
195
|
+
"header": "Received-SPF: fail (mx.receiver.com: domain of user@example.com does not designate 203.0.113.1 as permitted sender) client-ip=203.0.113.1;\r\n envelope-from=\"user@example.com\"; helo=attacker.example.net;",
|
|
131
196
|
"info": "spf=fail (mx.receiver.com: domain of user@example.com does not designate 203.0.113.1 as permitted sender) smtp.mailfrom=user@example.com smtp.helo=attacker.example.net",
|
|
132
197
|
"rr": "v=spf1 ip4:192.0.2.0/24 -all",
|
|
133
198
|
"lookups": {
|
|
@@ -155,7 +220,7 @@ The `status.comment` field follows this format based on the result:
|
|
|
155
220
|
"helo": "mail.no-spf.example.com"
|
|
156
221
|
}
|
|
157
222
|
},
|
|
158
|
-
"header": "Received-SPF: none (mx.receiver.com: no-spf.example.com does not designate permitted sender hosts) client-ip=192.0.2.1;",
|
|
223
|
+
"header": "Received-SPF: none (mx.receiver.com: no-spf.example.com does not designate permitted sender hosts) client-ip=192.0.2.1;\r\n envelope-from=\"user@no-spf.example.com\"; helo=mail.no-spf.example.com;",
|
|
159
224
|
"info": "spf=none (mx.receiver.com: no-spf.example.com does not designate permitted sender hosts) smtp.mailfrom=user@no-spf.example.com smtp.helo=mail.no-spf.example.com",
|
|
160
225
|
"lookups": {
|
|
161
226
|
"limit": 10,
|
|
@@ -180,7 +245,7 @@ The `status.comment` field follows this format based on the result:
|
|
|
180
245
|
"mailfrom": "user@complex.example.com"
|
|
181
246
|
}
|
|
182
247
|
},
|
|
183
|
-
"header": "Received-SPF: permerror (mx.receiver.com: permanent error in processing during lookup of user@complex.example.com: Too many DNS requests) client-ip=192.0.2.1;",
|
|
248
|
+
"header": "Received-SPF: permerror (mx.receiver.com: permanent error in processing during lookup of user@complex.example.com: Too many DNS requests) client-ip=192.0.2.1;\r\n envelope-from=\"user@complex.example.com\";",
|
|
184
249
|
"info": "spf=permerror (mx.receiver.com: permanent error in processing during lookup of user@complex.example.com: Too many DNS requests) smtp.mailfrom=user@complex.example.com",
|
|
185
250
|
"lookups": {
|
|
186
251
|
"limit": 10,
|