mailauth 5.0.3 → 6.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 +11 -0
- package/README.md +6 -2
- package/docs/dmarc.md +17 -13
- package/index.d.ts +4 -2
- package/lib/dmarc/get-dmarc-record.js +159 -38
- package/lib/dmarc/index.js +2 -1
- package/lib/dmarc/verify.js +108 -32
- package/lib/mailauth.js +14 -7
- package/lib/tools.js +2 -2
- package/package.json +12 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [6.0.0](https://github.com/postalsys/mailauth/compare/v5.0.3...v6.0.0) (2026-09-26)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* **dmarc:** DMARC verdicts can change. A subdomain whose parent publishes no record is now its own Organizational Domain, so a signature or SPF domain of the parent no longer aligns with it. Subdomains of names the Public Suffix List lists as suffixes but that publish a DMARC record (for example blogspot.com) now inherit that policy. The result's domain is the Tree Walk Organizational Domain, and is the author domain when no record is found.
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* **dmarc:** find policies and organizational domains with the RFC 9989 DNS Tree Walk ([8ab1da2](https://github.com/postalsys/mailauth/commit/8ab1da24aa5f1be7939393bd79a9834802aa911d))
|
|
13
|
+
|
|
3
14
|
## [5.0.3](https://github.com/postalsys/mailauth/compare/v5.0.2...v5.0.3) (2026-09-03)
|
|
4
15
|
|
|
5
16
|
|
package/README.md
CHANGED
|
@@ -433,7 +433,7 @@ See [DMARC Result Reference](docs/dmarc.md) for details on the result object str
|
|
|
433
433
|
|
|
434
434
|
##### `getDmarcRecord(domain [, resolver])`
|
|
435
435
|
|
|
436
|
-
Fetches and parses the DMARC
|
|
436
|
+
Fetches and parses the DMARC record that applies to a domain, found with the DNS Tree Walk of RFC 9989 section 4.10. That is the domain's own record, or else the record of its Organizational Domain, or else a record published with `psd=y` above it. Returns `false` if no record applies.
|
|
437
437
|
|
|
438
438
|
###### Syntax
|
|
439
439
|
|
|
@@ -469,10 +469,14 @@ console.log(dmarcRecord);
|
|
|
469
469
|
"sp": "none",
|
|
470
470
|
"aspf": "r",
|
|
471
471
|
"rr": "v=DMARC1; p=none; pct=100; rua=mailto:re+joqy8fpatm3@dmarc.postmarkapp.com; sp=none; aspf=r;",
|
|
472
|
-
"isOrgRecord": false
|
|
472
|
+
"isOrgRecord": false,
|
|
473
|
+
"recordDomain": "ethereal.email",
|
|
474
|
+
"orgDomain": "ethereal.email"
|
|
473
475
|
}
|
|
474
476
|
```
|
|
475
477
|
|
|
478
|
+
`isOrgRecord` is `true` when the record was inherited from the Organizational Domain or a PSD, and `recordDomain` is where it was published. `orgDomain` is `null` when the record was found at the domain itself but the rest of the Tree Walk failed.
|
|
479
|
+
|
|
476
480
|
### BIMI
|
|
477
481
|
|
|
478
482
|
Brand Indicators for Message Identification (BIMI) support is based on [draft-blank-ietf-bimi-02](https://tools.ietf.org/html/draft-blank-ietf-bimi-02). BIMI information is resolved during the authentication step, provided the message passes DMARC validation with a policy other than "none".
|
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.
|
|
10
|
+
|
|
9
11
|
```javascript
|
|
10
12
|
const { authenticate } = require('mailauth');
|
|
11
13
|
|
|
@@ -21,7 +23,7 @@ const { dmarc } = await authenticate(message, {
|
|
|
21
23
|
| Field | Type | Presence | Description |
|
|
22
24
|
| ----------- | -------- | ------------ | ---------------------------------------------------------- |
|
|
23
25
|
| `status` | `object` | Always | Verification status object (see below) |
|
|
24
|
-
| `domain` | `string` | Always | Organizational
|
|
26
|
+
| `domain` | `string` | Always | Organizational Domain of the author domain (Tree Walk) |
|
|
25
27
|
| `policy` | `string` | Record found | Effective policy (`"reject"`, `"quarantine"`, or `"none"`) |
|
|
26
28
|
| `p` | `string` | Record found | Policy from `p=` tag |
|
|
27
29
|
| `sp` | `string` | Record found | Subdomain policy from `sp=` tag (defaults to `p` value) |
|
|
@@ -29,7 +31,9 @@ const { dmarc } = await authenticate(message, {
|
|
|
29
31
|
| `rr` | `string` | Record found | Raw DMARC DNS TXT record |
|
|
30
32
|
| `alignment` | `object` | Record found | SPF and DKIM alignment details (see below) |
|
|
31
33
|
| `error` | `string` | On temperror | Error message |
|
|
32
|
-
|
|
34
|
+
|
|
35
|
+
On a `temperror` that happens after a record was found, because a Tree Walk needed for relaxed alignment failed and no other identifier aligned, `policy`, `p`, `sp`, `rr` and `alignment` are still set.
|
|
36
|
+
| `info` | `string` | Always | Formatted Authentication-Results header value |
|
|
33
37
|
|
|
34
38
|
## status Object
|
|
35
39
|
|
|
@@ -41,10 +45,10 @@ const { dmarc } = await authenticate(message, {
|
|
|
41
45
|
|
|
42
46
|
### status.header Object
|
|
43
47
|
|
|
44
|
-
| Field | Type | Description
|
|
45
|
-
| ------ | -------- |
|
|
46
|
-
| `from` | `string` | Domain of the From header address
|
|
47
|
-
| `d` | `string` | Domain where DMARC record was found |
|
|
48
|
+
| Field | Type | Description |
|
|
49
|
+
| ------ | -------- | --------------------------------------------------------------------------------------- |
|
|
50
|
+
| `from` | `string` | Domain of the From header address |
|
|
51
|
+
| `d` | `string` | Domain where the applied DMARC record was found (author, Organizational Domain, or PSD) |
|
|
48
52
|
|
|
49
53
|
## alignment Object
|
|
50
54
|
|
|
@@ -72,12 +76,12 @@ const { dmarc } = await authenticate(message, {
|
|
|
72
76
|
|
|
73
77
|
## Result Values
|
|
74
78
|
|
|
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
|
|
79
|
+
| Result | Description |
|
|
80
|
+
| ----------- | -------------------------------------------------------------- |
|
|
81
|
+
| `pass` | Message passed DMARC (SPF or DKIM aligned and passed) |
|
|
82
|
+
| `fail` | Message failed DMARC (neither SPF nor DKIM aligned) |
|
|
83
|
+
| `none` | No DMARC record found |
|
|
84
|
+
| `temperror` | Temporary error during a DNS lookup that the result depends on |
|
|
81
85
|
|
|
82
86
|
## Policy Values
|
|
83
87
|
|
|
@@ -168,7 +172,7 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
|
|
|
168
172
|
"from": "no-dmarc.example.com"
|
|
169
173
|
}
|
|
170
174
|
},
|
|
171
|
-
"domain": "example.com",
|
|
175
|
+
"domain": "no-dmarc.example.com",
|
|
172
176
|
"info": "dmarc=none header.from=no-dmarc.example.com"
|
|
173
177
|
}
|
|
174
178
|
```
|
package/index.d.ts
CHANGED
|
@@ -180,7 +180,9 @@ export interface DKIMResult {
|
|
|
180
180
|
*/
|
|
181
181
|
status: AuthStatus & {
|
|
182
182
|
/**
|
|
183
|
-
* Signing domain if it aligns with the From domain, false otherwise
|
|
183
|
+
* Signing domain if it aligns with the From domain, false otherwise. For a passing
|
|
184
|
+
* signature this follows the DMARC verdict once DMARC found a record, before that it
|
|
185
|
+
* only guesses relaxed alignment from the Public Suffix List.
|
|
184
186
|
*/
|
|
185
187
|
aligned?: string | false;
|
|
186
188
|
|
|
@@ -366,7 +368,7 @@ export interface ARCResult {
|
|
|
366
368
|
*/
|
|
367
369
|
export interface DMARCResult {
|
|
368
370
|
/**
|
|
369
|
-
*
|
|
371
|
+
* Organizational Domain of the author domain, found with the DNS Tree Walk (RFC 9989 4.10)
|
|
370
372
|
*/
|
|
371
373
|
domain: string;
|
|
372
374
|
|
|
@@ -1,58 +1,38 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const dns = require('node:dns').promises;
|
|
4
|
-
const
|
|
5
|
-
const {
|
|
4
|
+
const net = require('node:net');
|
|
5
|
+
const { formatDomain } = require('../tools');
|
|
6
6
|
|
|
7
7
|
// Tags whose values are ABNF literals in RFC 7489 6.4 / RFC 9989 4.7 and are therefore
|
|
8
8
|
// case insensitive (RFC 5234 2.3). Values of other tags keep their case: "v" is defined
|
|
9
9
|
// with explicit hex literals and is case sensitive, "rua"/"ruf" are URIs.
|
|
10
10
|
const CASE_INSENSITIVE_TAGS = new Set(['p', 'sp', 'np', 'adkim', 'aspf', 'fo', 'rf', 't', 'psd']);
|
|
11
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
12
|
// The version tag must be the first tag and its value "DMARC1" must match precisely
|
|
16
13
|
// (RFC 9989 4.7 "v"), while the ABNF allows *WSP around "=" and a case insensitive
|
|
17
14
|
// tag name, hence [vV] instead of an /i flag that would also relax the value
|
|
18
15
|
const DMARC_VERSION_RE = /^[vV][ \t]*=[ \t]*DMARC1[ \t]*(?:;|$)/;
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return (txt || []).map(row => row.join('').trim()).filter(row => DMARC_VERSION_RE.test(row));
|
|
24
|
-
} catch (err) {
|
|
25
|
-
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
|
|
26
|
-
return [];
|
|
27
|
-
}
|
|
28
|
-
throw err;
|
|
29
|
-
}
|
|
30
|
-
};
|
|
17
|
+
// Resolver errors that mean the name has no record, as opposed to a transient failure.
|
|
18
|
+
// EBADNAME is a name that can not exist in the DNS at all.
|
|
19
|
+
const NO_RECORD_CODES = new Set(['ENOTFOUND', 'ENODATA', 'EBADNAME']);
|
|
31
20
|
|
|
32
|
-
|
|
33
|
-
|
|
21
|
+
// A Tree Walk shortens an author domain of eight or more labels straight to seven (RFC 9989 4.10 step 4)
|
|
22
|
+
const WALK_MAX_LABELS = 7;
|
|
34
23
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (!records.length) {
|
|
39
|
-
let orgDomain = tldts.getDomain(domain, TLDTS_OPTS);
|
|
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) {
|
|
43
|
-
// try org domain as well
|
|
44
|
-
records = await resolveTxt(orgDomain, resolver);
|
|
45
|
-
isOrgRecord = true;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (records.length !== 1) {
|
|
50
|
-
// no record, or a multi record set that terminates policy discovery
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
24
|
+
// A domain literal or an IP address is not a DNS name and can not publish a DMARC record
|
|
25
|
+
const isAddress = domain => domain.startsWith('[') || net.isIP(domain) !== 0;
|
|
53
26
|
|
|
54
|
-
|
|
27
|
+
// Whether "_dmarc.<name>" can exist in the DNS: no empty labels, labels of at most 63 octets
|
|
28
|
+
// and a name of at most 253 octets (RFC 1035 2.3.4). Anything else would only produce a
|
|
29
|
+
// resolver error that looks like a transient failure.
|
|
30
|
+
const isQueryable = name => {
|
|
31
|
+
let fqdn = `_dmarc.${name}`;
|
|
32
|
+
return fqdn.length <= 253 && fqdn.split('.').every(label => label.length && label.length <= 63);
|
|
33
|
+
};
|
|
55
34
|
|
|
35
|
+
const parseDmarcRecord = txt => {
|
|
56
36
|
let parsed = Object.fromEntries(
|
|
57
37
|
txt
|
|
58
38
|
.split(';')
|
|
@@ -78,9 +58,150 @@ const getDmarcRecord = async (domain, resolver) => {
|
|
|
78
58
|
);
|
|
79
59
|
|
|
80
60
|
parsed.rr = txt;
|
|
81
|
-
parsed.isOrgRecord = isOrgRecord;
|
|
82
61
|
|
|
83
62
|
return parsed;
|
|
84
63
|
};
|
|
85
64
|
|
|
65
|
+
// Returns the DMARC Policy Record published at "_dmarc.<name>", or false. A set of several
|
|
66
|
+
// records counts as no record, it is discarded (RFC 9989 4.10 steps 2 and 6).
|
|
67
|
+
const fetchRecord = async (name, resolver) => {
|
|
68
|
+
if (!isQueryable(name)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let records;
|
|
73
|
+
try {
|
|
74
|
+
let txt = await resolver(`_dmarc.${name}`, 'TXT');
|
|
75
|
+
records = (txt || []).map(row => row.join('').trim()).filter(row => DMARC_VERSION_RE.test(row));
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (NO_RECORD_CODES.has(err.code)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return records.length === 1 ? parseDmarcRecord(records[0]) : false;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Tree Walks for one message share most of their names, so each name is queried only once
|
|
87
|
+
const createRecordLookup = resolver => {
|
|
88
|
+
resolver = resolver || dns.resolve;
|
|
89
|
+
let cache = new Map();
|
|
90
|
+
return name => {
|
|
91
|
+
if (!cache.has(name)) {
|
|
92
|
+
cache.set(name, fetchRecord(name, resolver));
|
|
93
|
+
}
|
|
94
|
+
return cache.get(name);
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// DNS Tree Walk (RFC 9989 4.10). Returns the records found, longest name first. The walk
|
|
99
|
+
// stops at a record with a psd=y or psd=n tag, or when no labels are left, so it makes
|
|
100
|
+
// at most eight queries. A DNS error ends the walk and is returned next to the records
|
|
101
|
+
// found before it, because a record found at the start domain is usable without the rest.
|
|
102
|
+
const treeWalk = async (domain, lookup) => {
|
|
103
|
+
let found = [];
|
|
104
|
+
let labels = domain.split('.');
|
|
105
|
+
|
|
106
|
+
while (labels.length) {
|
|
107
|
+
let name = labels.join('.');
|
|
108
|
+
let record;
|
|
109
|
+
try {
|
|
110
|
+
record = await lookup(name);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return { found, error: err };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (record) {
|
|
116
|
+
found.push({ domain: name, record });
|
|
117
|
+
if (record.psd === 'y' || record.psd === 'n') {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
labels = labels.length > WALK_MAX_LABELS ? labels.slice(-WALK_MAX_LABELS) : labels.slice(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { found };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Organizational Domain selection for a completed Tree Walk (RFC 9989 4.10.2)
|
|
129
|
+
const selectOrgDomain = (domain, found) => {
|
|
130
|
+
for (let { domain: recordDomain, record } of found) {
|
|
131
|
+
if (record.psd === 'n') {
|
|
132
|
+
return recordDomain;
|
|
133
|
+
}
|
|
134
|
+
if (record.psd === 'y' && recordDomain !== domain) {
|
|
135
|
+
// the domain one label below the PSD
|
|
136
|
+
let labelCount = recordDomain.split('.').length + 1;
|
|
137
|
+
return domain.split('.').slice(-labelCount).join('.');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// the record with the fewest labels, or the start domain itself when nothing was found
|
|
142
|
+
return found.length ? found[found.length - 1].domain : domain;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Returns the Organizational Domain of a normalized domain name. Throws on DNS errors.
|
|
146
|
+
const getOrgDomain = async (domain, lookup) => {
|
|
147
|
+
if (isAddress(domain)) {
|
|
148
|
+
return domain;
|
|
149
|
+
}
|
|
150
|
+
let { found, error } = await treeWalk(domain, lookup);
|
|
151
|
+
if (error) {
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
return selectOrgDomain(domain, found);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// DMARC Policy Discovery (RFC 9989 4.10.1) for a normalized author domain. Returns
|
|
158
|
+
// { record, recordDomain, orgDomain, error }, where "record" is false when no policy applies.
|
|
159
|
+
// When the Tree Walk fails after a record was found at the author domain, "orgDomain" is null
|
|
160
|
+
// and "error" says why. Throws on DNS errors that leave the policy unknown.
|
|
161
|
+
const discoverPolicy = async (domain, lookup) => {
|
|
162
|
+
if (isAddress(domain)) {
|
|
163
|
+
return { record: false, recordDomain: null, orgDomain: domain };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let { found, error } = await treeWalk(domain, lookup);
|
|
167
|
+
|
|
168
|
+
if (found[0]?.domain === domain) {
|
|
169
|
+
// the author domain's own record applies regardless of what is above it
|
|
170
|
+
return { record: found[0].record, recordDomain: domain, orgDomain: error ? null : selectOrgDomain(domain, found), error };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (error) {
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let orgDomain = selectOrgDomain(domain, found);
|
|
178
|
+
|
|
179
|
+
// otherwise the record of the Organizational Domain, and failing that the record of the PSD.
|
|
180
|
+
// A walk only stops at a psd tag, so a psd=y record can only be the last one found.
|
|
181
|
+
let last = found[found.length - 1];
|
|
182
|
+
let entry = found.find(e => e.domain === orgDomain) || (last?.record.psd === 'y' ? last : null);
|
|
183
|
+
|
|
184
|
+
return { record: entry ? entry.record : false, recordDomain: entry ? entry.domain : null, orgDomain };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Fetches the DMARC Policy Record that applies to a domain. Returns the parsed record or false.
|
|
188
|
+
const getDmarcRecord = async (domain, resolver) => {
|
|
189
|
+
domain = formatDomain(domain || '');
|
|
190
|
+
|
|
191
|
+
let { record, recordDomain, orgDomain } = await discoverPolicy(domain, createRecordLookup(resolver));
|
|
192
|
+
if (!record) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return Object.assign(record, {
|
|
197
|
+
// the record is inherited from the Organizational Domain or the PSD
|
|
198
|
+
isOrgRecord: recordDomain !== domain,
|
|
199
|
+
recordDomain,
|
|
200
|
+
orgDomain
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
|
|
86
204
|
module.exports = getDmarcRecord;
|
|
205
|
+
module.exports.createRecordLookup = createRecordLookup;
|
|
206
|
+
module.exports.discoverPolicy = discoverPolicy;
|
|
207
|
+
module.exports.getOrgDomain = getOrgDomain;
|
package/lib/dmarc/index.js
CHANGED
package/lib/dmarc/verify.js
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const dns = require('node:dns').promises;
|
|
4
3
|
const punycode = require('punycode.js');
|
|
5
|
-
const
|
|
6
|
-
const {
|
|
7
|
-
const getDmarcRecord = require('./get-dmarc-record');
|
|
4
|
+
const { formatAuthHeaderRow, formatDomain } = require('../tools');
|
|
5
|
+
const { createRecordLookup, discoverPolicy, getOrgDomain } = require('./get-dmarc-record');
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
// Every Tree Walk is capped at eight queries, but the number of walks is not (RFC 9989 4.10.2),
|
|
8
|
+
// so a message can only make this many identifiers walk for relaxed alignment. The rest do not align.
|
|
9
|
+
const MAX_ALIGNMENT_WALKS = 10;
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
const evaluateDmarc = async opts => {
|
|
12
|
+
let { headerFrom, spfDomains, dkimDomains, resolver, arcResult } = opts;
|
|
13
13
|
|
|
14
14
|
if (Array.isArray(headerFrom)) {
|
|
15
15
|
if (headerFrom.length === 1) {
|
|
16
16
|
headerFrom = headerFrom[0];
|
|
17
17
|
} else {
|
|
18
18
|
// invalid number of FROM addresses found
|
|
19
|
-
return false;
|
|
19
|
+
return { response: false };
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -31,6 +31,9 @@ const verifyDmarc = async opts => {
|
|
|
31
31
|
// ignore punycode conversion errors
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// the name to query and compare, "example.com." and "example.com" are the same domain
|
|
35
|
+
const authorDomain = formatDomain(domain);
|
|
36
|
+
|
|
34
37
|
let formatResponse = response => {
|
|
35
38
|
response.info = formatAuthHeaderRow('dmarc', response.status);
|
|
36
39
|
|
|
@@ -41,8 +44,6 @@ const verifyDmarc = async opts => {
|
|
|
41
44
|
return response;
|
|
42
45
|
};
|
|
43
46
|
|
|
44
|
-
let orgDomain = tldts.getDomain(domain, TLDTS_OPTS);
|
|
45
|
-
|
|
46
47
|
let status = {
|
|
47
48
|
result: 'neutral',
|
|
48
49
|
comment: false,
|
|
@@ -54,24 +55,28 @@ const verifyDmarc = async opts => {
|
|
|
54
55
|
}
|
|
55
56
|
};
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
const lookup = createRecordLookup(resolver);
|
|
59
|
+
|
|
60
|
+
let policyDiscovery;
|
|
58
61
|
try {
|
|
59
|
-
|
|
62
|
+
policyDiscovery = await discoverPolicy(authorDomain, lookup);
|
|
60
63
|
} catch (err) {
|
|
61
64
|
// temperror?
|
|
62
65
|
status.result = 'temperror';
|
|
63
|
-
return formatResponse({ status, domain:
|
|
66
|
+
return { response: formatResponse({ status, domain: authorDomain, error: err.message }) };
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
let { record: dmarcRecord, recordDomain, orgDomain } = policyDiscovery;
|
|
70
|
+
|
|
66
71
|
if (!dmarcRecord) {
|
|
67
72
|
// nothing to do here
|
|
68
73
|
// none
|
|
69
74
|
status.result = 'none';
|
|
70
|
-
return formatResponse({ status, domain: orgDomain
|
|
75
|
+
return { response: formatResponse({ status, domain: orgDomain }) };
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
// the domain the applied policy record was published at
|
|
74
|
-
status.header.d =
|
|
79
|
+
status.header.d = recordDomain;
|
|
75
80
|
|
|
76
81
|
status.comment = []
|
|
77
82
|
.concat(dmarcRecord.p ? `p=${dmarcRecord.p.toUpperCase()}` : [])
|
|
@@ -79,31 +84,84 @@ const verifyDmarc = async opts => {
|
|
|
79
84
|
.concat(arcResult?.status?.result ? `arc=${arcResult?.status?.result}` : [])
|
|
80
85
|
.join(' ');
|
|
81
86
|
|
|
82
|
-
// use "sp" if
|
|
83
|
-
const policy =
|
|
87
|
+
// use "sp" if the record was inherited by a subdomain and "sp" is set, otherwise use "p"
|
|
88
|
+
const policy = recordDomain !== authorDomain && dmarcRecord.sp ? dmarcRecord.sp : dmarcRecord.p;
|
|
84
89
|
|
|
85
90
|
const adkimStrict = dmarcRecord.adkim === 's';
|
|
86
91
|
const aspfStrict = dmarcRecord.aspf === 's';
|
|
87
92
|
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
// Relaxed alignment compares Organizational Domains found by Tree Walks (RFC 9989 4.10.2).
|
|
94
|
+
// An identifier's Organizational Domain is always the identifier itself or one of its
|
|
95
|
+
// parents, so only identifiers at or below the author's Organizational Domain need a walk.
|
|
96
|
+
let orgDomains = new Map();
|
|
97
|
+
// throws when a Tree Walk the answer depends on failed
|
|
98
|
+
const alignsRelaxed = async entryDomain => {
|
|
99
|
+
if (entryDomain === authorDomain) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
if (!orgDomain) {
|
|
103
|
+
// the author's own Tree Walk failed, so there is nothing to compare against
|
|
104
|
+
throw policyDiscovery.error;
|
|
105
|
+
}
|
|
106
|
+
if (entryDomain !== orgDomain && !entryDomain.endsWith(`.${orgDomain}`)) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (!orgDomains.has(entryDomain)) {
|
|
110
|
+
if (orgDomains.size >= MAX_ALIGNMENT_WALKS) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
orgDomains.set(entryDomain, getOrgDomain(entryDomain, lookup));
|
|
114
|
+
}
|
|
115
|
+
return (await orgDomains.get(entryDomain)) === orgDomain;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Returns every entry that aligns, most preferred first, and the first walk error seen.
|
|
119
|
+
// Signatures with an l= tag come last, so that a signature covering the whole body is
|
|
120
|
+
// reported when several align.
|
|
121
|
+
const findAligned = async (entries, strict) => {
|
|
122
|
+
let list = []
|
|
123
|
+
.concat(entries || [])
|
|
124
|
+
.map(entry => (typeof entry === 'string' ? { domain: entry } : entry))
|
|
125
|
+
.filter(entry => entry && typeof entry.domain === 'string')
|
|
126
|
+
.sort((a, b) => (a.underSized || 0) - (b.underSized || 0));
|
|
127
|
+
|
|
128
|
+
let aligned = [];
|
|
129
|
+
let error = null;
|
|
130
|
+
for (let entry of list) {
|
|
131
|
+
let entryDomain = formatDomain(entry.domain);
|
|
132
|
+
if (!authorDomain || !entryDomain) {
|
|
133
|
+
// a bare root label normalizes to an empty string, which must not align with anything
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
if (strict ? entryDomain === authorDomain : await alignsRelaxed(entryDomain)) {
|
|
138
|
+
aligned.push(entry);
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
error = error || err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { aligned, error };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// SPF first, so a flood of DKIM signatures can not use up the walks it needs
|
|
149
|
+
const spf = await findAligned(spfDomains, aspfStrict);
|
|
150
|
+
// every signature is checked, so that each one's own alignment can be reported
|
|
151
|
+
const dkim = await findAligned(dkimDomains, adkimStrict);
|
|
152
|
+
const spfAlignment = spf.aligned[0];
|
|
153
|
+
const dkimAlignment = dkim.aligned[0];
|
|
154
|
+
const walkError = spf.error || dkim.error;
|
|
90
155
|
|
|
91
156
|
// "underSized" warns that a passing DKIM signature covers only part of the body (l= tag).
|
|
92
157
|
// 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
|
-
|
|
95
|
-
|
|
96
|
-
if (dkimAlignment || spfAlignment) {
|
|
97
|
-
// pass
|
|
98
|
-
status.result = 'pass';
|
|
99
|
-
} else {
|
|
100
|
-
// fail
|
|
101
|
-
status.result = 'fail';
|
|
102
|
-
}
|
|
158
|
+
// strict alignment rejected a signature that aligns at the org level. A walk that fails
|
|
159
|
+
// for this warning alone does not affect the result.
|
|
160
|
+
const underSized = adkimStrict && !dkimAlignment ? (await findAligned(dkimDomains, false)).aligned[0]?.underSized : dkimAlignment?.underSized;
|
|
103
161
|
|
|
104
|
-
|
|
162
|
+
let response = {
|
|
105
163
|
status,
|
|
106
|
-
domain: orgDomain ||
|
|
164
|
+
domain: orgDomain || authorDomain,
|
|
107
165
|
policy,
|
|
108
166
|
p: dmarcRecord.p,
|
|
109
167
|
sp: dmarcRecord.sp || dmarcRecord.p,
|
|
@@ -114,7 +172,25 @@ const verifyDmarc = async opts => {
|
|
|
114
172
|
spf: { result: spfAlignment?.domain, strict: aspfStrict },
|
|
115
173
|
dkim: { result: dkimAlignment?.domain, strict: adkimStrict, underSized }
|
|
116
174
|
}
|
|
117
|
-
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (dkimAlignment || spfAlignment) {
|
|
178
|
+
// pass
|
|
179
|
+
status.result = 'pass';
|
|
180
|
+
} else if (walkError) {
|
|
181
|
+
// an identifier might have aligned if its Tree Walk had not failed
|
|
182
|
+
status.result = 'temperror';
|
|
183
|
+
response.error = walkError.message;
|
|
184
|
+
} else {
|
|
185
|
+
// fail
|
|
186
|
+
status.result = 'fail';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { response: formatResponse(response), dkimAligned: new Set(dkim.aligned.map(entry => formatDomain(entry.domain))) };
|
|
118
190
|
};
|
|
119
191
|
|
|
192
|
+
const verifyDmarc = async opts => (await evaluateDmarc(opts)).response;
|
|
193
|
+
|
|
120
194
|
module.exports = verifyDmarc;
|
|
195
|
+
// also returns the signing domains that aligned, for the per signature flags of authenticate()
|
|
196
|
+
module.exports.evaluateDmarc = evaluateDmarc;
|
package/lib/mailauth.js
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
const { dkimSign, DkimSignStream } = require('./dkim/sign');
|
|
4
4
|
const { dkimVerify } = require('./dkim/verify');
|
|
5
5
|
const { spf } = require('./spf');
|
|
6
|
-
const { dmarc } = require('./dmarc');
|
|
6
|
+
const { dmarc, evaluateDmarc } = require('./dmarc');
|
|
7
7
|
const { arc, createSeal, sealMessage, getARChain, verifyASChain } = require('./arc');
|
|
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
|
+
const { formatDomain, getAlignment } = require('./tools');
|
|
12
12
|
const libmime = require('libmime');
|
|
13
13
|
const os = require('node:os');
|
|
14
14
|
const { isIP } = require('net');
|
|
@@ -117,7 +117,8 @@ const authenticate = async (input, opts) => {
|
|
|
117
117
|
|
|
118
118
|
let dmarcResult;
|
|
119
119
|
if (!opts.disableDmarc && dkimResult?.headerFrom) {
|
|
120
|
-
|
|
120
|
+
let dkimAligned;
|
|
121
|
+
({ response: dmarcResult, dkimAligned } = await evaluateDmarc({
|
|
121
122
|
headerFrom: dkimResult.headerFrom,
|
|
122
123
|
spfDomains: [].concat((spfResult && spfResult.status.result === 'pass' && spfResult.domain) || []),
|
|
123
124
|
dkimDomains: (dkimResult.results || [])
|
|
@@ -130,16 +131,22 @@ const authenticate = async (input, opts) => {
|
|
|
130
131
|
})),
|
|
131
132
|
arcResult,
|
|
132
133
|
resolver: opts.resolver
|
|
133
|
-
});
|
|
134
|
+
}));
|
|
134
135
|
if (dmarcResult.info) {
|
|
135
136
|
arHeader.push(`${libmime.foldLines(dmarcResult.info, 160)}`);
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
if (
|
|
139
|
-
//
|
|
139
|
+
if (dkimAligned) {
|
|
140
|
+
// The DKIM verifier runs before the DMARC record and the Tree Walks are known, so its
|
|
141
|
+
// per signature flag only guesses relaxed alignment from the Public Suffix List.
|
|
142
|
+
// Passing signatures take the DMARC verdict, failing ones the record's alignment mode.
|
|
140
143
|
let fromDomain = dmarcResult.status.header.from;
|
|
141
144
|
for (let row of dkimResult.results || []) {
|
|
142
|
-
|
|
145
|
+
if (row.status.result === 'pass') {
|
|
146
|
+
row.status.aligned = dkimAligned.has(formatDomain(row.signingDomain)) ? row.signingDomain : false;
|
|
147
|
+
} else if (dmarcResult.alignment.dkim.strict) {
|
|
148
|
+
row.status.aligned = (row.status.aligned && getAlignment(fromDomain, [row.signingDomain], true)?.domain) || false;
|
|
149
|
+
}
|
|
143
150
|
}
|
|
144
151
|
}
|
|
145
152
|
}
|
package/lib/tools.js
CHANGED
|
@@ -91,8 +91,8 @@ const FIELD_START = /^[\x21-\x39\x3b-\x7e]+[ \t\r\n]*:/;
|
|
|
91
91
|
// Whether a row opens a header of its own. Anything else is appended to the row above,
|
|
92
92
|
// which is wider than the CRLF-then-WSP fold of RFC 5322 section 2.2.3 and deliberately
|
|
93
93
|
// so: a line that is not a well formed field is read differently by different parsers,
|
|
94
|
-
//
|
|
95
|
-
//
|
|
94
|
+
// some mail readers append whitespace-looking lines to the value above them. Asking what
|
|
95
|
+
// a field looks like instead of what whitespace looks like
|
|
96
96
|
// covers every spelling of it, including the multi-byte ones a leading byte test misses,
|
|
97
97
|
// such as UTF-8 NBSP (C2 A0) and the ideographic space (E3 80 80). Appending a line can
|
|
98
98
|
// only add bytes to a signed value, so it can only ever turn a pass into a fail; reading
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mailauth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "Email authentication library for Node.js",
|
|
5
5
|
"main": "lib/mailauth.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -35,27 +35,27 @@
|
|
|
35
35
|
"homepage": "https://github.com/postalsys/mailauth",
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"chai": "4.4.1",
|
|
38
|
-
"eslint": "10.
|
|
38
|
+
"eslint": "10.11.0",
|
|
39
39
|
"eslint-config-nodemailer": "1.2.0",
|
|
40
40
|
"eslint-config-prettier": "10.1.8",
|
|
41
|
-
"js-yaml": "5.2
|
|
41
|
+
"js-yaml": "5.4.2",
|
|
42
42
|
"license-report": "6.8.5",
|
|
43
43
|
"mbox-reader": "1.2.0",
|
|
44
|
-
"mocha": "
|
|
45
|
-
"prettier": "3.9.
|
|
46
|
-
"resedit": "3.0
|
|
44
|
+
"mocha": "12.0.2",
|
|
45
|
+
"prettier": "3.9.9",
|
|
46
|
+
"resedit": "3.1.0"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@postalsys/vmc": "1.1.5",
|
|
50
50
|
"commander": "15.0.0",
|
|
51
|
-
"fast-xml-parser": "5.
|
|
51
|
+
"fast-xml-parser": "5.11.1",
|
|
52
52
|
"ipaddr.js": "2.5.0",
|
|
53
|
-
"joi": "18.2.
|
|
54
|
-
"libmime": "5.4.
|
|
55
|
-
"nodemailer": "
|
|
53
|
+
"joi": "18.2.9",
|
|
54
|
+
"libmime": "5.4.4",
|
|
55
|
+
"nodemailer": "10.0.10",
|
|
56
56
|
"punycode.js": "2.3.1",
|
|
57
|
-
"tldts": "7.4.
|
|
58
|
-
"undici": "8.
|
|
57
|
+
"tldts": "7.4.15",
|
|
58
|
+
"undici": "8.11.2"
|
|
59
59
|
},
|
|
60
60
|
"engines": {
|
|
61
61
|
"node": ">=22.19.0"
|