@zone-eu/mailsplit 5.4.27 → 5.4.29

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.
@@ -20,9 +20,17 @@ const MAX_PENDING_LINE_SIZE = 64 * 1024;
20
20
  // how many separate writes the pending line may be kept in before it is compacted
21
21
  const MAX_PENDING_LINE_CHUNKS = 1024;
22
22
 
23
+ // The most SP and HTAB a delimiter line may carry between the boundary value, or its closing
24
+ // "--", and the line ending. RFC 2046 5.1.1 allows the whitespace, and Python and postal-mime
25
+ // accept any amount of it, so a line that ends in a few spaces delimits the same parts here
26
+ // as there. The line still has to fit a header line (RFC 5322 2.1.1), so the pending line
27
+ // kept as a possible delimiter stays bounded
28
+ const BOUNDARY_LINE_TRAILING_WSP = 998;
29
+
23
30
  // what a delimiter line may carry after the boundary value: the "--" prefix, an optional
24
- // "--" suffix and the line terminator. This is the bound compareBoundary() accepts.
25
- const BOUNDARY_LINE_SUFFIX = 2 /* "--" prefix */ + 2 /* "--" suffix */ + 2; /* trailing <CR><LF> */
31
+ // "--" suffix, the trailing whitespace and the line terminator. This is the bound
32
+ // compareBoundary() accepts.
33
+ const BOUNDARY_LINE_SUFFIX = 2 /* "--" prefix */ + 2 /* "--" suffix */ + BOUNDARY_LINE_TRAILING_WSP + 2; /* trailing <CR><LF> */
26
34
 
27
35
  // checkBoundary() additionally allows a line ending in front of the delimiter, so this is
28
36
  // the longest a delimiter line can ever be once the boundary value is subtracted
@@ -389,7 +397,8 @@ class MessageSplitter extends Transform {
389
397
  * @returns {1 | 2 | false}
390
398
  */
391
399
  compareBoundary(line, startpos, boundary) {
392
- // --{boundary}\r\n or --{boundary}--\r\n
400
+ // --{boundary}\r\n or --{boundary}--\r\n, with SP and HTAB allowed in front of the
401
+ // line ending (RFC 2046 5.1.1), where Python and postal-mime accept them as well
393
402
  if (line.length < boundary.length + 3 + startpos || line.length > boundary.length + BOUNDARY_LINE_SUFFIX + startpos) {
394
403
  return false;
395
404
  }
@@ -399,34 +408,37 @@ class MessageSplitter extends Transform {
399
408
  }
400
409
  }
401
410
 
402
- let pos = 0;
403
- for (let i = boundary.length + 2 + startpos; i < line.length; i++) {
404
- let c = line[i];
405
- if (pos === 0 && (c === 0x0d || c === 0x0a)) {
406
- // 1: next node
407
- return 1;
408
- }
409
- if (pos === 0 && c !== 0x2d) {
410
- // expecting "-"
411
- return false;
412
- }
413
- if (pos === 1 && c !== 0x2d) {
414
- // expecting "-"
415
- return false;
416
- }
417
- if (pos === 2 && c !== 0x0d && c !== 0x0a) {
418
- // expecting line terminator, either <CR> or <LF>
411
+ let i = boundary.length + 2 + startpos;
412
+ // 1: next node, 2: multipart end
413
+ let result = /** @type {1 | 2} */ (1);
414
+ if (line[i] === 0x2d) {
415
+ if (line[i + 1] !== 0x2d) {
416
+ // expecting "--"
419
417
  return false;
420
418
  }
421
- if (pos === 3 && c !== 0x0a) {
422
- // expecting line terminator <LF>
423
- return false;
424
- }
425
- pos++;
419
+ result = 2;
420
+ i += 2;
421
+ }
422
+
423
+ while (i < line.length && (line[i] === 0x20 || line[i] === 0x09)) {
424
+ i++;
426
425
  }
427
426
 
428
- // 2: multipart end
429
- return 2;
427
+ if (i === line.length) {
428
+ // only the closing delimiter may end the input without a line ending
429
+ return result === 2 ? 2 : false;
430
+ }
431
+
432
+ // expecting the line terminator and nothing after it: <CR><LF>, <CR> or <LF>, where
433
+ // the <CR> may be a run of them, the artifact of a second CR to CRLF conversion,
434
+ // which postal-mime reads as the line ending as well
435
+ while (i < line.length && line[i] === 0x0d) {
436
+ i++;
437
+ }
438
+ if (i < line.length && line[i] === 0x0a) {
439
+ i++;
440
+ }
441
+ return i === line.length ? result : false;
430
442
  }
431
443
 
432
444
  /**
@@ -46,7 +46,7 @@ declare class MimeNode implements MimeNodeShape {
46
46
  /** Decoded filename from Content-Disposition or Content-Type parameters, or `false` when absent. */
47
47
  filename: string | false;
48
48
 
49
- /** Whether this node is `text/*` with `format=flowed`. */
49
+ /** Whether this node is `text/plain` with `format=flowed`. */
50
50
  flowed: boolean;
51
51
 
52
52
  /** Whether flowed text uses `delsp=yes`. */
package/lib/mime-node.js CHANGED
@@ -12,7 +12,7 @@ const pathlib = require('path');
12
12
  /** @typedef {import('..').PartNumberItem} PartNumberItem */
13
13
  /** @typedef {import('..').PartNumber} PartNumber */
14
14
  /** @typedef {import('..').SplitterOptions} SplitterOptions */
15
- /** @typedef {import('libmime').StructuredHeader} StructuredHeader */
15
+ /** @typedef {import('libmime').ParsedHeaderValue} StructuredHeader */
16
16
  /** @typedef {import('stream').Transform | import('stream').PassThrough} ContentStream */
17
17
  /** @typedef {import('..').MimeNode} MimeNodeType */
18
18
 
@@ -27,6 +27,9 @@ const HEADER_LINE_MIN_COST = 64;
27
27
  * `value.replace(/\(.*\)/g, '')`, but in linear time. The regex backtracks quadratically
28
28
  * on a value with many `(` characters and no closing `)`.
29
29
  *
30
+ * Used for the Content-Transfer-Encoding only. The MIME headers with parameters go through
31
+ * libmime's parseHeaderValue with stripComments, which knows where a comment may stand.
32
+ *
30
33
  * @param {string} value
31
34
  * @returns {string}
32
35
  */
@@ -48,59 +51,6 @@ function stripComments(value) {
48
51
  .join('');
49
52
  }
50
53
 
51
- /**
52
- * Lists the parameters a header value gives in RFC 2231 form (`name*=`, `name*0=` or
53
- * `name*0*=`). libmime decodes those while parsing the header, so the result must not
54
- * go through `decodeWords` as well: a value like `filename*=utf-8''a%3D%3Futf-8%3FQ%3F%3D2Eexe%3F%3D`
55
- * would otherwise come out as `a.exe` while a parser that decodes once sees the encoded word.
56
- *
57
- * @param {string} value Raw header value.
58
- * @returns {Set<string>} Lowercased parameter names.
59
- */
60
- function rfc2231ParamNames(value) {
61
- let names = new Set();
62
- let quote = false;
63
- let escaped = false;
64
- let start = 0;
65
-
66
- /**
67
- * @param {number} end End offset of the segment starting at `start`.
68
- * @returns {void}
69
- */
70
- let addSegment = end => {
71
- let segment = value.substring(start, end);
72
- let eq = segment.indexOf('=');
73
- if (eq < 0) {
74
- return;
75
- }
76
- let match = segment
77
- .substring(0, eq)
78
- .trim()
79
- .toLowerCase()
80
- .match(/^(.+?)\*(\d+\*?)?$/);
81
- if (match) {
82
- names.add(match[1]);
83
- }
84
- };
85
-
86
- for (let i = 0; i < value.length; i++) {
87
- let chr = value.charAt(i);
88
- if (escaped) {
89
- escaped = false;
90
- } else if (chr === '\\') {
91
- escaped = true;
92
- } else if (chr === '"') {
93
- quote = !quote;
94
- } else if (chr === ';' && !quote) {
95
- addSegment(i);
96
- start = i + 1;
97
- }
98
- }
99
- addSegment(value.length);
100
-
101
- return names;
102
- }
103
-
104
54
  /**
105
55
  * Parsed MIME node with mutable headers and transfer-encoding helpers.
106
56
  */
@@ -216,8 +166,14 @@ class MimeNode {
216
166
  this._headersLines = [];
217
167
  let headers = this.headers;
218
168
 
169
+ // The comments of the MIME headers are dropped, so that `boundary=b (comment)` names
170
+ // the boundary `b` that postal-mime and Python's email.policy.default split by. Read as
171
+ // content, it matched no delimiter line and the parts both of them find stayed hidden
172
+ // here. Python's legacy compat32 policy, the default of message_from_bytes() without a
173
+ // policy, keeps the comment and splits by `--b (comment)` lines instead: a split
174
+ // against it is the price of agreeing with the two readers a renderer is built on
219
175
  let dispositionHeader = headers.getFirst('Content-Disposition');
220
- this._parsedContentDisposition = this.libmime.parseHeaderValue(dispositionHeader);
176
+ this._parsedContentDisposition = this.libmime.parseHeaderValue(dispositionHeader, { stripComments: true });
221
177
  let parsedContentDisposition = /** @type {StructuredHeader} */ (this._parsedContentDisposition);
222
178
 
223
179
  // if content-type is missing default to plaintext
@@ -240,9 +196,12 @@ class MimeNode {
240
196
  }
241
197
  }
242
198
 
243
- this._parsedContentType = this.libmime.parseHeaderValue(contentHeader);
199
+ this._parsedContentType = this.libmime.parseHeaderValue(contentHeader, { stripComments: true });
244
200
  let parsedContentType = /** @type {StructuredHeader} */ (this._parsedContentType);
245
201
 
202
+ // The encoding is a bare token, and one written in quotes stays quoted, so that
203
+ // "base64" is not decoded, as Python and postal-mime do not decode it. parseHeaderValue
204
+ // would unquote it, so the transfer encoding keeps its own comment stripper
246
205
  this.encoding = stripComments(headers.getFirst('Content-Transfer-Encoding')).toLowerCase().trim();
247
206
  this.contentType = (parsedContentType.value || '').toLowerCase().trim() || false;
248
207
  this.charset = parsedContentType.params.charset || false;
@@ -259,7 +218,11 @@ class MimeNode {
259
218
 
260
219
  this.filename = parsedContentDisposition.params.filename || parsedContentType.params.name || false;
261
220
 
262
- if (parsedContentType.params.format && parsedContentType.params.format.toLowerCase().trim() === 'flowed') {
221
+ // format=flowed is defined for text/plain (RFC 3676), and a browser never unfolds an
222
+ // html part. Flagged on any text part, a text/html part with delsp=yes had its
223
+ // `<scri \r\npt>` joined into a script tag by the consumer that unfolded it, a tag
224
+ // neither Python nor a browser given the raw bytes sees
225
+ if (this.contentType === 'text/plain' && parsedContentType.params.format && parsedContentType.params.format.toLowerCase().trim() === 'flowed') {
263
226
  this.flowed = true;
264
227
  if (parsedContentType.params.delsp && parsedContentType.params.delsp.toLowerCase().trim() === 'yes') {
265
228
  this.delSp = true;
@@ -267,10 +230,15 @@ class MimeNode {
267
230
  }
268
231
 
269
232
  if (this.filename) {
270
- // a parameter given in RFC 2231 form was decoded by libmime already
233
+ // A parameter given in RFC 2231 form was decoded by libmime already, which names
234
+ // it in decodedParams, and must not go through decodeWords as well:
235
+ // filename*=utf-8''a%3D%3Futf-8%3FQ%3F%3D2Eexe%3F%3D would otherwise come out as
236
+ // a.exe while a parser that decodes once sees the encoded word. A second scan of
237
+ // the raw header for the name used to decide this, and read `(c)filename*=` as
238
+ // a parameter named `(c)filename` once the comment was dropped from the real one
271
239
  let decoded = parsedContentDisposition.params.filename
272
- ? rfc2231ParamNames(dispositionHeader).has('filename')
273
- : rfc2231ParamNames(contentHeader).has('name');
240
+ ? parsedContentDisposition.decodedParams.includes('filename')
241
+ : parsedContentType.decodedParams.includes('name');
274
242
  if (!decoded) {
275
243
  try {
276
244
  this.filename = this.libmime.decodeWords(this.filename);
@@ -285,7 +253,16 @@ class MimeNode {
285
253
  this.contentType.substr(0, this.contentType.indexOf('/')) === 'multipart' &&
286
254
  this.contentType.substr(this.contentType.indexOf('/') + 1)) ||
287
255
  false;
288
- this._boundary = (parsedContentType.params.boundary && Buffer.from(parsedContentType.params.boundary)) || false;
256
+ // A boundary is ASCII and does not end in whitespace (RFC 2046 5.1.1, bchars), and
257
+ // Python matches no line against one that is not. One holding a non-ASCII character
258
+ // came out of a header read as latin1 or as UTF-8, and matched under `--ab\xC3\xA9cd`
259
+ // lines parts that a scanner built on Python never saw. Such a boundary counts as
260
+ // none, as a boundary parameter that is missing does. Trailing whitespace is dropped
261
+ // as Python drops it, a delimiter line may end in whitespace anyway
262
+ let boundary = (parsedContentType.params.boundary || '').trimEnd();
263
+ // a non-ASCII character takes more than one byte in UTF-8
264
+ let ascii = Buffer.byteLength(boundary) === boundary.length;
265
+ this._boundary = boundary && ascii ? Buffer.from(boundary) : false;
289
266
 
290
267
  this.rfc822 = this.contentType === 'message/rfc822';
291
268
 
package/lib/types.d.ts CHANGED
@@ -107,7 +107,7 @@ export interface MimeNode {
107
107
  /** Decoded filename from Content-Disposition or Content-Type parameters, or `false` when absent. */
108
108
  filename: string | false;
109
109
 
110
- /** Whether this node is `text/*` with `format=flowed`. */
110
+ /** Whether this node is `text/plain` with `format=flowed`. */
111
111
  flowed: boolean;
112
112
 
113
113
  /** Whether flowed text uses `delsp=yes`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zone-eu/mailsplit",
3
- "version": "5.4.27",
3
+ "version": "5.4.29",
4
4
  "description": "Split email messages into an object stream",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -25,7 +25,7 @@
25
25
  "license": "(MIT OR EUPL-1.1+)",
26
26
  "dependencies": {
27
27
  "libbase64": "1.4.1",
28
- "libmime": "5.5.2",
28
+ "libmime": "5.6.3",
29
29
  "libqp": "3.0.1"
30
30
  },
31
31
  "devDependencies": {