mailauth 7.3.0 → 7.3.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/lib/dkim/dkim-verifier.js +2 -1
- package/lib/dkim2/fields.js +4 -3
- package/lib/dmarc/author-domain.js +6 -2
- package/lib/mta-sts.js +3 -2
- package/lib/parse-dkim-headers.js +4 -3
- package/lib/tools.js +19 -18
- package/lib/trim.js +27 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [7.3.1](https://github.com/postalsys/mailauth/compare/v7.3.0...v7.3.1) (2026-10-18)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* parse header blocks and trim field names in linear time ([78580bf](https://github.com/postalsys/mailauth/commit/78580bf379fc7d54c7997b0f4c2241e33f838aa5))
|
|
9
|
+
|
|
3
10
|
## [7.3.0](https://github.com/postalsys/mailauth/compare/v7.2.0...v7.3.0) (2026-10-10)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
isSupportedAlgorithm,
|
|
19
19
|
createKeyCache
|
|
20
20
|
} = require('../../lib/tools');
|
|
21
|
+
const { trimChars } = require('../trim');
|
|
21
22
|
const { DOMAIN_NAME, SELECTOR, LOCAL_PART, TIMESTAMP, BODY_LENGTH } = require('./syntax');
|
|
22
23
|
const { MessageParser } = require('./message-parser');
|
|
23
24
|
const { dkimBody } = require('./body');
|
|
@@ -827,7 +828,7 @@ const normalizeFieldList = value =>
|
|
|
827
828
|
(value || '')
|
|
828
829
|
.toString()
|
|
829
830
|
.split(':')
|
|
830
|
-
.map(name => name
|
|
831
|
+
.map(name => trimChars(name, ' \t\r\n').toLowerCase())
|
|
831
832
|
.filter(name => name);
|
|
832
833
|
|
|
833
834
|
module.exports = { DkimVerifier };
|
package/lib/dkim2/fields.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const { Buffer } = require('node:buffer');
|
|
7
7
|
const { formatRelaxedLine, toLineBytes, isValidDnsName, isSameOrSubdomain } = require('../tools');
|
|
8
|
+
const { trimChars, trimEndChars } = require('../trim');
|
|
8
9
|
const { parseRecipe } = require('./recipe');
|
|
9
10
|
|
|
10
11
|
const MI_KEY = 'message-instance';
|
|
@@ -72,7 +73,7 @@ const parseTags = value => {
|
|
|
72
73
|
let segments = value.split(';');
|
|
73
74
|
|
|
74
75
|
for (let i = 0; i < segments.length; i++) {
|
|
75
|
-
let segment = segments[i]
|
|
76
|
+
let segment = trimChars(segments[i], ' \t\r\n');
|
|
76
77
|
if (!segment) {
|
|
77
78
|
// the tag-list ends every tag with a semicolon, and a final one can be left out
|
|
78
79
|
if (i < segments.length - 1) {
|
|
@@ -86,7 +87,7 @@ const parseTags = value => {
|
|
|
86
87
|
return { tags, error: 'tag without a value' };
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
let name = segment.substring(0, eqPos)
|
|
90
|
+
let name = trimEndChars(segment.substring(0, eqPos), ' \t\r\n');
|
|
90
91
|
if (!TAG_NAME.test(name)) {
|
|
91
92
|
return { tags, error: 'invalid tag name' };
|
|
92
93
|
}
|
|
@@ -291,7 +292,7 @@ const parseSignature = line => {
|
|
|
291
292
|
|
|
292
293
|
if (tags.has('f')) {
|
|
293
294
|
for (let flag of tags.get('f').split(',')) {
|
|
294
|
-
flag = flag
|
|
295
|
+
flag = trimChars(flag, ' \t\r\n');
|
|
295
296
|
if (!TEXTSTRING.test(flag)) {
|
|
296
297
|
return fail('syntax error');
|
|
297
298
|
}
|
|
@@ -162,8 +162,12 @@ const getAuthorDomain = (headerFrom, fromFields, opts) => {
|
|
|
162
162
|
// a mailbox without a usable domain is not skipped, the domain a reader sees for it
|
|
163
163
|
// is unknown, so the Author Domain can not be determined
|
|
164
164
|
invalid = true;
|
|
165
|
-
if (typeof address === 'string' && address.indexOf('@') >= 0
|
|
166
|
-
|
|
165
|
+
if (typeof address === 'string' && address.indexOf('@') >= 0) {
|
|
166
|
+
// a domain of whitespace, or of a lone dot, is missing rather than malformed
|
|
167
|
+
let domain = address.slice(address.lastIndexOf('@') + 1).trim();
|
|
168
|
+
if (domain && domain !== '.') {
|
|
169
|
+
malformed = true;
|
|
170
|
+
}
|
|
167
171
|
}
|
|
168
172
|
}
|
|
169
173
|
for (let entry of entries) {
|
package/lib/mta-sts.js
CHANGED
|
@@ -6,6 +6,7 @@ const https = require('node:https');
|
|
|
6
6
|
const tls = require('node:tls');
|
|
7
7
|
const { X509Certificate } = require('node:crypto');
|
|
8
8
|
const { toALabel, createError } = require('./tools');
|
|
9
|
+
const { trimChars, trimEndChars } = require('./trim');
|
|
9
10
|
|
|
10
11
|
// socket idle timeout for the policy request
|
|
11
12
|
const HTTP_IDLE_TIMEOUT = 15 * 1000;
|
|
@@ -65,7 +66,7 @@ const parseTxtRecordStrict = record => {
|
|
|
65
66
|
// WSP around a delimiter belongs to the delimiter
|
|
66
67
|
let field = parts[i].replace(/^[ \t]+/, '');
|
|
67
68
|
if (!isLast) {
|
|
68
|
-
field = field
|
|
69
|
+
field = trimEndChars(field, ' \t');
|
|
69
70
|
} else if (!field) {
|
|
70
71
|
// optional trailing delimiter
|
|
71
72
|
continue;
|
|
@@ -217,7 +218,7 @@ const parsePolicyFile = (file, strict, warnings) => {
|
|
|
217
218
|
if (!EXT_NAME.test(key)) {
|
|
218
219
|
continue;
|
|
219
220
|
}
|
|
220
|
-
value = line.substr(colonPos + 1)
|
|
221
|
+
value = trimChars(line.substr(colonPos + 1), ' \t');
|
|
221
222
|
} else {
|
|
222
223
|
key = line.substr(0, colonPos).toLowerCase().trim();
|
|
223
224
|
value = line.substr(colonPos + 1).trim();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { DANGEROUS_KEYS } = require('./safe-keys');
|
|
4
|
+
const { trimChars } = require('./trim');
|
|
4
5
|
|
|
5
6
|
// Keys the headerParser result pre-seeds: "header" holds the header name and "value" the
|
|
6
7
|
// authserv-id picked from the first key-only part. A crafted part must not overwrite them,
|
|
@@ -24,7 +25,7 @@ const NUMERIC_TAGS = new Set(['l', 'v', 't', 'x']);
|
|
|
24
25
|
// RFC 6376 section 3.2: tag-name = ALPHA *ALNUMPUNC
|
|
25
26
|
const TAG_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
26
27
|
// FWS around a tag-spec, only SP, HTAB and the CRLF of a fold
|
|
27
|
-
const
|
|
28
|
+
const FWS_CHARS = ' \t\r\n';
|
|
28
29
|
|
|
29
30
|
const normalizeString = value => value.replace(/\s+/g, ' ').trim();
|
|
30
31
|
|
|
@@ -104,7 +105,7 @@ const tagListParser = (line, headerKey, strict) => {
|
|
|
104
105
|
|
|
105
106
|
for (let i = 0; i < segments.length; i++) {
|
|
106
107
|
let segment = segments[i];
|
|
107
|
-
let trimmed = segment
|
|
108
|
+
let trimmed = trimChars(segment, FWS_CHARS);
|
|
108
109
|
|
|
109
110
|
if (!trimmed) {
|
|
110
111
|
// one trailing semicolon is allowed, an empty tag-spec anywhere else is not
|
|
@@ -127,7 +128,7 @@ const tagListParser = (line, headerKey, strict) => {
|
|
|
127
128
|
continue;
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
let name = segment.substring(0, eqPos)
|
|
131
|
+
let name = trimChars(segment.substring(0, eqPos), FWS_CHARS);
|
|
131
132
|
let value = normalizeString(segment.substring(eqPos + 1));
|
|
132
133
|
|
|
133
134
|
if (!TAG_NAME.test(name)) {
|
package/lib/tools.js
CHANGED
|
@@ -10,6 +10,7 @@ const crypto = require('node:crypto');
|
|
|
10
10
|
const https = require('node:https');
|
|
11
11
|
const packageData = require('../package');
|
|
12
12
|
const parseDkimHeaders = require('./parse-dkim-headers');
|
|
13
|
+
const { trimChars, trimEndChars } = require('./trim');
|
|
13
14
|
const { parseTagList } = parseDkimHeaders;
|
|
14
15
|
const tldts = require('tldts');
|
|
15
16
|
const Joi = require('joi');
|
|
@@ -152,27 +153,27 @@ const FIELD_START = /^[\x21-\x39\x3b-\x7e]+[ \t\r\n]*:/;
|
|
|
152
153
|
// only add bytes to a signed value, so it can only ever turn a pass into a fail; reading
|
|
153
154
|
// the line the other way round is what cannot be undone. formatRelaxedLine is not lenient
|
|
154
155
|
// in the same way, what a signature covers is RFC 6376 section 3.4.2 alone
|
|
155
|
-
const startsField =
|
|
156
|
-
// only the start of
|
|
156
|
+
const startsField = (line, next) =>
|
|
157
|
+
// only the start of a line decides this, and a fold can push the colon no further
|
|
157
158
|
// than onto the line after the name
|
|
158
|
-
FIELD_START.test(
|
|
159
|
+
FIELD_START.test(line) || (next !== undefined && FIELD_START.test(`${line}\r\n${next}`));
|
|
159
160
|
|
|
160
161
|
const parseHeaders = buf => {
|
|
161
|
-
let
|
|
162
|
-
.toString('binary')
|
|
163
|
-
.replace(/[\r\n]+$/, '')
|
|
164
|
-
.split(/\r?\n/)
|
|
165
|
-
.map(row => [row]);
|
|
162
|
+
let lines = trimEndChars(buf.toString('binary'), '\r\n').split(/\r?\n/);
|
|
166
163
|
|
|
167
|
-
if (
|
|
164
|
+
if (lines.length === 1 && !lines[0]) {
|
|
168
165
|
// nothing to parse, rather than one row that is not a header
|
|
169
166
|
return { parsed: [], original: buf };
|
|
170
167
|
}
|
|
171
168
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
169
|
+
// the first line always opens a row, every later line either opens one or continues
|
|
170
|
+
// the row before it
|
|
171
|
+
let rows = [[lines[0]]];
|
|
172
|
+
for (let i = 1; i < lines.length; i++) {
|
|
173
|
+
if (startsField(lines[i], lines[i + 1])) {
|
|
174
|
+
rows.push([lines[i]]);
|
|
175
|
+
} else {
|
|
176
|
+
rows[rows.length - 1].push(lines[i]);
|
|
176
177
|
}
|
|
177
178
|
}
|
|
178
179
|
|
|
@@ -188,7 +189,7 @@ const parseHeaders = buf => {
|
|
|
188
189
|
if (casedKey.indexOf('\n') >= 0) {
|
|
189
190
|
casedKey = casedKey.replace(/\r?\n/g, '');
|
|
190
191
|
}
|
|
191
|
-
casedKey = casedKey
|
|
192
|
+
casedKey = trimChars(casedKey, ' \t');
|
|
192
193
|
key = lowerCaseASCII(casedKey);
|
|
193
194
|
}
|
|
194
195
|
|
|
@@ -203,7 +204,7 @@ const parseHeaders = buf => {
|
|
|
203
204
|
const normalizeFieldNames = fieldNames =>
|
|
204
205
|
fieldNames
|
|
205
206
|
.split(':')
|
|
206
|
-
.map(key => lowerCaseASCII(key
|
|
207
|
+
.map(key => lowerCaseASCII(trimChars(key, ' \t')))
|
|
207
208
|
.filter(key => key);
|
|
208
209
|
|
|
209
210
|
const defaultDKIMFieldNamesNormalized = normalizeFieldNames(defaultDKIMFieldNames);
|
|
@@ -219,7 +220,7 @@ const OVERSIGN_FIELD_NAME = /^[\x21-\x39\x3c-\x7e]+$/;
|
|
|
219
220
|
const getOversignedNames = (fieldNames, signingList) => {
|
|
220
221
|
let listed = new Map();
|
|
221
222
|
for (let casedKey of fieldNames.split(':')) {
|
|
222
|
-
casedKey = casedKey
|
|
223
|
+
casedKey = trimChars(casedKey, ' \t');
|
|
223
224
|
let key = lowerCaseASCII(casedKey);
|
|
224
225
|
if (!key) {
|
|
225
226
|
continue;
|
|
@@ -987,7 +988,7 @@ const formatRelaxedLine = (line, suffix) => {
|
|
|
987
988
|
// unfold
|
|
988
989
|
.replace(/\r?\n/g, '')
|
|
989
990
|
// key to lowercase, trim around :
|
|
990
|
-
.replace(/^([^:]*):[ \t]*/, (m, k) => lowerCaseASCII(k)
|
|
991
|
+
.replace(/^([^:]*):[ \t]*/, (m, k) => trimChars(lowerCaseASCII(k), ' \t') + ':')
|
|
991
992
|
// single WSP
|
|
992
993
|
.replace(/[ \t]+/g, ' ')
|
|
993
994
|
// no WSP around the value
|
|
@@ -1034,7 +1035,7 @@ const formatDomain = domain => {
|
|
|
1034
1035
|
// signer writes and the verifier reports for the same name
|
|
1035
1036
|
domain = toALabel(domain.toLowerCase().trim()).toLowerCase().trim();
|
|
1036
1037
|
// the root label is not part of the name, so "example.com." and "example.com" are the same domain
|
|
1037
|
-
return domain
|
|
1038
|
+
return trimEndChars(domain, '.');
|
|
1038
1039
|
};
|
|
1039
1040
|
|
|
1040
1041
|
// A domain as getAlignment() compares it, after formatDomain(): labels of letters, digits, "-"
|
package/lib/trim.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Trims the characters of `chars` off the end of a string with an index scan. A regex such
|
|
4
|
+
// as /[ \t]+$/ retries the trailing run from every position inside it whenever the run is
|
|
5
|
+
// not at the end of the string, quadratic in the length of a run of those characters
|
|
6
|
+
const trimEndChars = (str, chars) => {
|
|
7
|
+
let end = str.length;
|
|
8
|
+
while (end > 0 && chars.includes(str[end - 1])) {
|
|
9
|
+
end--;
|
|
10
|
+
}
|
|
11
|
+
return str.slice(0, end);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Trims the characters of `chars` off both ends of a string, see trimEndChars
|
|
15
|
+
const trimChars = (str, chars) => {
|
|
16
|
+
let start = 0;
|
|
17
|
+
let end = str.length;
|
|
18
|
+
while (start < end && chars.includes(str[start])) {
|
|
19
|
+
start++;
|
|
20
|
+
}
|
|
21
|
+
while (end > start && chars.includes(str[end - 1])) {
|
|
22
|
+
end--;
|
|
23
|
+
}
|
|
24
|
+
return str.slice(start, end);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
module.exports = { trimChars, trimEndChars };
|