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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "7.3.0"
2
+ ".": "7.3.1"
3
3
  }
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.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, '').toLowerCase())
831
+ .map(name => trimChars(name, ' \t\r\n').toLowerCase())
831
832
  .filter(name => name);
832
833
 
833
834
  module.exports = { DkimVerifier };
@@ -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].replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, '');
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).replace(/[ \t\r\n]+$/, '');
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.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, '');
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 && address.slice(address.lastIndexOf('@') + 1).replace(/^\s*\.?\s*$/, '')) {
166
- malformed = true;
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.replace(/[ \t]+$/, '');
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).replace(/^[ \t]+|[ \t]+$/g, '');
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 FWS_EDGES = /^[ \t\r\n]+|[ \t\r\n]+$/g;
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.replace(FWS_EDGES, '');
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).replace(FWS_EDGES, '');
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 = row =>
156
- // only the start of the row decides this, and a fold can push the colon no further
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(row.length > 1 ? `${row[0]}\r\n${row[1]}` : row[0]);
159
+ FIELD_START.test(line) || (next !== undefined && FIELD_START.test(`${line}\r\n${next}`));
159
160
 
160
161
  const parseHeaders = buf => {
161
- let rows = buf
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 (rows.length === 1 && !rows[0][0]) {
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
- for (let i = rows.length - 1; i > 0; i--) {
173
- if (!startsField(rows[i])) {
174
- rows[i - 1] = rows[i - 1].concat(rows[i]);
175
- rows.splice(i, 1);
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.replace(/^[ \t]+|[ \t]+$/g, '');
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.replace(/^[ \t]+|[ \t]+$/g, '')))
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.replace(/^[ \t]+|[ \t]+$/g, '');
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).replace(/^[ \t]+|[ \t]+$/g, '') + ':')
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.replace(/\.+$/, '');
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailauth",
3
- "version": "7.3.0",
3
+ "version": "7.3.1",
4
4
  "description": "Email authentication library for Node.js",
5
5
  "main": "lib/mailauth.js",
6
6
  "types": "index.d.ts",