imapflow 1.6.4 → 1.6.6

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.
Files changed (46) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/lib/commands/append.js +26 -4
  4. package/lib/commands/compress.js +29 -18
  5. package/lib/commands/copyuid-parser.js +4 -2
  6. package/lib/commands/expunge.js +5 -2
  7. package/lib/commands/fetch.js +9 -3
  8. package/lib/commands/list.js +18 -38
  9. package/lib/commands/namespace.js +2 -2
  10. package/lib/commands/quota.js +10 -2
  11. package/lib/commands/search.js +54 -14
  12. package/lib/commands/select.js +81 -70
  13. package/lib/commands/status-fields.js +68 -0
  14. package/lib/commands/status.js +23 -61
  15. package/lib/handler/imap-compiler.js +91 -60
  16. package/lib/handler/imap-parser.js +7 -0
  17. package/lib/handler/imap-stream.js +78 -12
  18. package/lib/handler/limits.js +16 -4
  19. package/lib/imap-flow.d.ts +22 -2
  20. package/lib/imap-flow.js +209 -94
  21. package/lib/jp-decoder.js +30 -5
  22. package/lib/limited-passthrough.js +19 -1
  23. package/lib/search-compiler.js +24 -16
  24. package/lib/tools.js +190 -39
  25. package/package.json +4 -4
  26. package/test/commands-branches-test.js +4 -0
  27. package/test/commands-integration-test.js +780 -5
  28. package/test/copyuid-parser-test.js +20 -0
  29. package/test/idle-polling-test.js +81 -0
  30. package/test/imap-compiler-test.js +74 -4
  31. package/test/imap-flow-coverage-test.js +4 -2
  32. package/test/imap-flow-fetch-download-test.js +26 -0
  33. package/test/imap-flow-internals-test.js +134 -0
  34. package/test/imap-flow-methods-test.js +92 -0
  35. package/test/imap-flow-secure-test.js +133 -116
  36. package/test/imap-flow-server-test.js +126 -0
  37. package/test/imap-parser-test.js +25 -0
  38. package/test/imap-stream-edge-cases-test.js +163 -3
  39. package/test/integration/rev2-live-test.js +30 -0
  40. package/test/jp-decoder-test.js +57 -0
  41. package/test/limited-passthrough-test.js +24 -0
  42. package/test/parser-limits-test.js +18 -0
  43. package/test/reliability-improvements-test.js +3 -3
  44. package/test/search-compiler-test.js +90 -3
  45. package/test/timer-policy-test.js +27 -1
  46. package/test/tools-test.js +151 -2
package/lib/jp-decoder.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { Transform } = require('stream');
4
4
  const encodingJapanese = require('encoding-japanese');
5
+ const { normalizeByteLimit } = require('./limited-passthrough.js');
5
6
 
6
7
  // A Transform stream for decoding Japanese character sets (Shift_JIS, EUC-JP, ISO-2022-JP).
7
8
  // Unlike iconv-lite which can decode incrementally, encoding-japanese requires the complete
@@ -9,23 +10,47 @@ const encodingJapanese = require('encoding-japanese');
9
10
  // which uses escape sequences to switch between ASCII and multi-byte modes). Therefore,
10
11
  // this stream buffers all input during _transform and performs the actual decoding in _flush.
11
12
  class JPDecoder extends Transform {
12
- constructor(charset) {
13
+ constructor(charset, maxBytes) {
13
14
  super();
14
15
 
15
16
  this.charset = charset;
16
17
  this.chunks = [];
17
18
  this.chunklen = 0;
19
+
20
+ // Upper bound for the buffered bytes, normalized the same way LimitedPassthrough
21
+ // normalizes its own. The whole-input buffering defeats a downstream maxBytes limiter
22
+ // (nothing is emitted until _flush), so without an internal bound a server could force
23
+ // unbounded memory use through a caller that asked for a limited download. Excess input
24
+ // is truncated, mirroring the truncation a maxBytes download applies anyway.
25
+ this.maxBytes = normalizeByteLimit(maxBytes);
26
+
27
+ // Also mirroring LimitedPassthrough: true once the bound is reached and every further
28
+ // chunk is being discarded. The download loop reads this to stop pulling from the
29
+ // server, which the limiter at the tail of the pipeline cannot tell it, because nothing
30
+ // is emitted from here until _flush().
31
+ this.limited = false;
18
32
  }
19
33
 
20
- // Buffer all incoming chunks; no decoding happens here because Japanese charsets
21
- // require the complete input for accurate conversion.
34
+ // Buffer all incoming chunks (up to maxBytes); no decoding happens here because
35
+ // Japanese charsets require the complete input for accurate conversion.
22
36
  _transform(chunk, encoding, done) {
23
37
  if (typeof chunk === 'string') {
24
38
  chunk = Buffer.from(chunk, encoding);
25
39
  }
26
40
 
27
- this.chunks.push(chunk);
28
- this.chunklen += chunk.length;
41
+ if (this.chunklen + chunk.length > this.maxBytes) {
42
+ chunk = chunk.slice(0, Math.max(0, this.maxBytes - this.chunklen));
43
+ }
44
+
45
+ if (chunk.length) {
46
+ this.chunks.push(chunk);
47
+ this.chunklen += chunk.length;
48
+ }
49
+
50
+ if (this.chunklen >= this.maxBytes) {
51
+ this.limited = true;
52
+ }
53
+
29
54
  done();
30
55
  }
31
56
 
@@ -2,6 +2,23 @@
2
2
 
3
3
  const { Transform } = require('stream');
4
4
 
5
+ /**
6
+ * Normalizes a byte budget for the download pipeline. Any finite positive number is honored and
7
+ * floored, because byte counts are integers: with a fractional bound a counter can only ever
8
+ * reach its floor, so a stage would never report itself full and a loop polling that flag would
9
+ * keep pulling forever. Anything else - 0, NaN, a non-numeric value - means "no limit".
10
+ *
11
+ * Lives here rather than in tools.js because tools.js requires jp-decoder.js, which needs this.
12
+ *
13
+ * @param {*} value - The configured budget.
14
+ * @returns {Number} The normalized budget, or Infinity when unbounded.
15
+ */
16
+ const normalizeByteLimit = value => {
17
+ let bytes = Number(value);
18
+ // Math.max keeps a sub-1 budget from flooring to 0, which would read back as "no limit"
19
+ return Number.isFinite(bytes) && bytes > 0 ? Math.max(Math.floor(bytes), 1) : Infinity;
20
+ };
21
+
5
22
  // A Transform stream that passes through data up to a maximum byte limit,
6
23
  // then silently discards all subsequent chunks. Used to enforce download
7
24
  // size limits when fetching message content from the IMAP server.
@@ -9,7 +26,7 @@ class LimitedPassthrough extends Transform {
9
26
  constructor(options) {
10
27
  super();
11
28
  this.options = options || {};
12
- this.maxBytes = this.options.maxBytes || Infinity;
29
+ this.maxBytes = normalizeByteLimit(this.options.maxBytes);
13
30
  this.processed = 0;
14
31
  // Once set to true, all subsequent chunks are dropped without error
15
32
  this.limited = false;
@@ -42,3 +59,4 @@ class LimitedPassthrough extends Transform {
42
59
  }
43
60
 
44
61
  module.exports.LimitedPassthrough = LimitedPassthrough;
62
+ module.exports.normalizeByteLimit = normalizeByteLimit;
@@ -30,6 +30,17 @@ let setBoolOpt = (attributes, term, value) => {
30
30
  attributes.push({ type: 'ATOM', value: term.toUpperCase() });
31
31
  };
32
32
 
33
+ /**
34
+ * Normalizes a user-supplied sequence set (string, number, bigint, or an array of
35
+ * them) into the single string value of a SEQUENCE token. An array is one
36
+ * comma-joined set: separate tokens would be parsed by the server as extra
37
+ * sequence-number search keys ANDed to the query, not as part of the set.
38
+ *
39
+ * @param {*} value - The sequence set value(s)
40
+ * @returns {string} The joined sequence set string
41
+ */
42
+ let toSequenceValue = value => [].concat(value).join(',');
43
+
33
44
  /**
34
45
  * Adds a search option with its value(s) to the attributes array.
35
46
  * Handles NOT operations and array values.
@@ -37,23 +48,20 @@ let setBoolOpt = (attributes, term, value) => {
37
48
  * @param {Array} attributes - Array to append the attribute to
38
49
  * @param {string} term - The search term (e.g., 'FROM', 'SUBJECT')
39
50
  * @param {*} value - The value for the search term (string, array, or falsy for NOT)
40
- * @param {string} [type='ATOM'] - The attribute type
41
51
  */
42
- let setOpt = (attributes, term, value, type) => {
43
- type = type || 'ATOM';
44
-
52
+ let setOpt = (attributes, term, value) => {
45
53
  // Handle NOT operations for false or null values
46
54
  if (value === false || value === null) {
47
- attributes.push({ type, value: 'NOT' });
55
+ attributes.push({ type: 'ATOM', value: 'NOT' });
48
56
  }
49
57
 
50
- attributes.push({ type, value: term.toUpperCase() });
58
+ attributes.push({ type: 'ATOM', value: term.toUpperCase() });
51
59
 
52
- // Handle array values (e.g., multiple UIDs)
60
+ // Handle array values (e.g. HEADER name/value pairs)
53
61
  if (Array.isArray(value)) {
54
- value.forEach(entry => attributes.push({ type, value: (entry || '').toString() }));
62
+ value.forEach(entry => attributes.push({ type: 'ATOM', value: (entry || '').toString() }));
55
63
  } else {
56
- attributes.push({ type, value: value.toString() });
64
+ attributes.push({ type: 'ATOM', value: value.toString() });
57
65
  }
58
66
  };
59
67
 
@@ -158,12 +166,12 @@ module.exports.searchCompiler = (connection, query) => {
158
166
  // Custom sequence range support (non-standard)
159
167
  case 'SEQ':
160
168
  {
161
- let value = params[term];
162
- if (typeof value === 'number') {
163
- value = value.toString();
164
- }
165
- // Only accept valid sequence strings (no whitespace)
166
- if (typeof value === 'string' && /^\S+$/.test(value)) {
169
+ // Passed through as a SEQUENCE token: the compiler validates the
170
+ // set grammar and throws a coded error. An invalid value used to
171
+ // be dropped silently here, which turned a bad filter into an
172
+ // unrestricted search that matched every message.
173
+ let value = params[term] || params[term] === 0 ? toSequenceValue(params[term]) : '';
174
+ if (value) {
167
175
  attributes.push({ type: 'SEQUENCE', value });
168
176
  }
169
177
  }
@@ -239,7 +247,7 @@ module.exports.searchCompiler = (connection, query) => {
239
247
  case 'UID':
240
248
  if (params[term]) {
241
249
  attributes.push({ type: 'ATOM', value: 'UID' });
242
- [].concat(params[term]).forEach(entry => attributes.push({ type: 'SEQUENCE', value: (entry ?? '').toString() }));
250
+ attributes.push({ type: 'SEQUENCE', value: toSequenceValue(params[term]) });
243
251
  }
244
252
  break;
245
253
 
package/lib/tools.js CHANGED
@@ -11,11 +11,26 @@ const iconv = require('iconv-lite');
11
11
 
12
12
  const FLAG_COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'grey'];
13
13
 
14
- // Upper bound for expanding a single server-supplied sequence range (see
15
- // expandRange). 2^24 entries is far beyond any legitimate mailbox while keeping
16
- // the worst-case expansion of a hostile range bounded.
14
+ // Upper bound for expanding server-supplied sequence ranges (see expandRange). 2^24
15
+ // entries in total is far beyond any legitimate mailbox while keeping the worst-case
16
+ // expansion of a hostile range set bounded.
17
17
  const EXPANDED_RANGE_LIMIT = 0x1000000;
18
18
 
19
+ // Digit bounds for untrusted numeric values in server responses. UIDs, UIDVALIDITY and
20
+ // message counts are 32-bit unsigned (nz-number in the RFC 9051 grammar, so at most 10
21
+ // digits); MODSEQ and number64 values are 63-bit unsigned (RFC 7162, RFC 9051), at most
22
+ // 19 digits. The bound is checked before BigInt()/Number(): a response line may carry up
23
+ // to maxLineLength digits, and BigInt() on a multi-megabyte digit run costs hundreds of
24
+ // milliseconds of non-yielding CPU.
25
+ const MAX_UINT32_DIGITS = 10;
26
+ const MAX_NUMBER64_DIGITS = 19;
27
+
28
+ // Object keys that reach through the prototype chain when assigned to, or resolve to an
29
+ // inherited member when read. Server-controlled strings become keys in several places
30
+ // (STATUS items, QUOTA resources, BODYSTRUCTURE parameters, FETCH body part names), so they
31
+ // all consult this one set rather than each carrying its own list.
32
+ const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
33
+
19
34
  // Extensions that RFC 9051 (IMAP4rev2) folds into the base protocol (Appendix E).
20
35
  // When IMAP4rev2 is active, these are available even without their own capability
21
36
  // token. BINARY is deliberately excluded - RFC 9051 only folds in the FETCH side,
@@ -252,7 +267,8 @@ const tools = {
252
267
 
253
268
  if (list && Array.isArray(list)) {
254
269
  list.forEach(val => {
255
- if (typeof val.value !== 'string') {
270
+ // any entry can be a parsed NIL
271
+ if (!val || typeof val.value !== 'string') {
256
272
  return;
257
273
  }
258
274
  let capability = val.value.toUpperCase().trim();
@@ -269,8 +285,7 @@ const tools = {
269
285
 
270
286
  if (capability.startsWith('APPENDLIMIT=')) {
271
287
  let splitPos = capability.indexOf('=');
272
- let appendLimit = Number(capability.substr(splitPos + 1)) || 0;
273
- map.set('APPENDLIMIT', appendLimit);
288
+ map.set('APPENDLIMIT', tools.parseUintValue(capability.substr(splitPos + 1)) || 0);
274
289
  return;
275
290
  }
276
291
 
@@ -493,7 +508,9 @@ const tools = {
493
508
  async formatMessageResponse(untagged, mailbox) {
494
509
  let map = {};
495
510
 
496
- map.seq = Number(untagged.command);
511
+ // The sequence number indexes into mailbox state, so an unusable one is dropped rather
512
+ // than coerced to NaN or Infinity
513
+ map.seq = tools.parseUintValue(untagged.command, MAX_UINT32_DIGITS) || undefined;
497
514
 
498
515
  let key;
499
516
  let attributes = (untagged.attributes && untagged.attributes[1]) || [];
@@ -538,15 +555,14 @@ const tools = {
538
555
  }
539
556
  };
540
557
 
541
- let getArray = attribute => {
542
- if (Array.isArray(attribute)) {
543
- return attribute.map(entry => (entry && typeof entry.value === 'string' ? entry.value : false)).filter(entry => entry);
544
- }
545
- // NIL (parsed as null) and other non-array values yield an empty array,
546
- // so callers can safely index into the result. RFC 8474 allows e.g.
547
- // `THREADID NIL` when the server has no thread relation to report.
548
- return [];
549
- };
558
+ // NIL (parsed as null) and other non-array values yield an empty array, so callers
559
+ // can safely index into the result. RFC 8474 allows e.g. `THREADID NIL` when the
560
+ // server has no thread relation to report.
561
+ let getArray = attribute => tools.getStringList(attribute);
562
+
563
+ // Counts, sizes and UIDs are written into mailbox state and into range
564
+ // computations, so only a bounded decimal run is usable - see parseUintValue().
565
+ let getUint = (attribute, maxDigits) => tools.parseUintValue(getString(attribute), maxDigits);
550
566
 
551
567
  switch (key) {
552
568
  case 'body[]':
@@ -555,7 +571,9 @@ const tools = {
555
571
  break;
556
572
 
557
573
  case 'uid':
558
- map.uid = Number(getString(attribute));
574
+ // A UID feeds mailbox.uidNext one line below, and from there every range
575
+ // computation, so an unusable one is dropped rather than coerced
576
+ map.uid = getUint(attribute, MAX_UINT32_DIGITS) || undefined;
559
577
  // If the UID we just saw is >= the mailbox's uidNext, bump uidNext.
560
578
  // This keeps the local uidNext estimate current without requiring a
561
579
  // separate STATUS command, handling cases where new messages arrived
@@ -565,14 +583,22 @@ const tools = {
565
583
  }
566
584
  break;
567
585
 
568
- case 'modseq':
569
- map.modseq = BigInt(getArray(attribute)[0]);
586
+ case 'modseq': {
587
+ // BigInt() throws on a non-numeric or missing value, and the throw
588
+ // drops the whole message from the result set - so a malformed
589
+ // MODSEQ from the server must be skipped, not surfaced.
590
+ let modseq = tools.parseBigIntValue(getArray(attribute)[0]);
591
+ if (modseq === false) {
592
+ break;
593
+ }
594
+ map.modseq = modseq;
570
595
  // Similarly, keep the local highestModseq estimate up to date.
571
596
  // This is critical for CONDSTORE/QRESYNC delta syncing.
572
597
  if (map.modseq && (!mailbox.highestModseq || mailbox.highestModseq < map.modseq)) {
573
598
  mailbox.highestModseq = map.modseq;
574
599
  }
575
600
  break;
601
+ }
576
602
 
577
603
  case 'emailid':
578
604
  // OBJECTID extension (RFC 8474): server-assigned stable email identifier
@@ -599,7 +625,7 @@ const tools = {
599
625
  break;
600
626
 
601
627
  case 'rfc822.size':
602
- map.size = Number(getString(attribute)) || 0;
628
+ map.size = getUint(attribute) || 0;
603
629
  break;
604
630
 
605
631
  case 'flags':
@@ -735,6 +761,11 @@ const tools = {
735
761
  return []
736
762
  .concat(list || [])
737
763
  .map(addr => {
764
+ if (!addr) {
765
+ // A NIL entry inside an address list: skip it instead of
766
+ // throwing on the dereference and dropping the message
767
+ return false;
768
+ }
738
769
  let address = (getStrValue(addr[2]) || '') + '@' + (getStrValue(addr[3]) || '');
739
770
  if (address === '@') {
740
771
  address = '';
@@ -744,7 +775,7 @@ const tools = {
744
775
  address
745
776
  };
746
777
  })
747
- .filter(addr => addr.name || addr.address);
778
+ .filter(addr => addr && (addr.name || addr.address));
748
779
  },
749
780
  envelope = {};
750
781
 
@@ -813,7 +844,12 @@ const tools = {
813
844
  // BODYSTRUCTURE parameters come as flat key/value pairs: [key1, val1, key2, val2, ...]
814
845
  [].concat(arr || []).forEach((val, j) => {
815
846
  if (j % 2) {
816
- params[key] = libmime.decodeWords(((val && val.value) || '').toString());
847
+ // Parameter names are server-controlled. The load-bearing check is the one in
848
+ // the continuation pass below, where the value is an object; here the value is
849
+ // always a string, which the __proto__ setter ignores anyway.
850
+ if (!tools.isUnsafeKey(key)) {
851
+ params[key] = libmime.decodeWords(((val && val.value) || '').toString());
852
+ }
817
853
  } else {
818
854
  key = ((val && val.value) || '').toString().toLowerCase();
819
855
  }
@@ -848,6 +884,14 @@ const tools = {
848
884
  actualKey = key.substr(0, match.index).toLowerCase();
849
885
  nr = Number(match[2]) || 0;
850
886
 
887
+ if (tools.isUnsafeKey(actualKey)) {
888
+ // A continuation key like "__proto__*0*" would group under "__proto__":
889
+ // params['__proto__'] resolves to Object.prototype, so the grouping
890
+ // writes below would mutate it (process-wide pollution). Drop the part.
891
+ delete params[key];
892
+ return;
893
+ }
894
+
851
895
  if (!params[actualKey] || typeof params[actualKey] !== 'object') {
852
896
  params[actualKey] = {
853
897
  charset: false,
@@ -1198,49 +1242,143 @@ const tools = {
1198
1242
  return Number.isSafeInteger(value) && value > 0 && value <= 0xffffffff;
1199
1243
  },
1200
1244
 
1245
+ /**
1246
+ * Checks that an untrusted response value is a pure decimal digit run no longer than
1247
+ * the given bound.
1248
+ *
1249
+ * `!isNaN(value)` is not usable for this: it also passes '1e5', ' 12 ', '0x10' and
1250
+ * 'Infinity'. BigInt() throws on all of them and Number() silently returns a value the
1251
+ * grammar never allowed, so both are wrong in a response handler that is only trying to
1252
+ * read one field. The length bound is checked before the pattern so an arbitrarily long
1253
+ * digit run is rejected without any conversion work.
1254
+ *
1255
+ * @param {*} value - Raw value from the response.
1256
+ * @param {Number} maxDigits - Maximum number of digits accepted.
1257
+ * @returns {Boolean} True if the value is a decimal string within the bound.
1258
+ */
1259
+ isDecimalString(value, maxDigits) {
1260
+ return typeof value === 'string' && value.length > 0 && value.length <= maxDigits && /^[0-9]+$/.test(value);
1261
+ },
1262
+
1263
+ /**
1264
+ * Checks whether a server-supplied string is unsafe to use as a key on a plain object.
1265
+ * Assigning "__proto__" writes through the prototype setter instead of creating an own
1266
+ * property, and reading "constructor" or "prototype" resolves to an inherited member.
1267
+ *
1268
+ * @param {*} key - Candidate key from a server response.
1269
+ * @returns {Boolean} True if the key must not be used.
1270
+ */
1271
+ isUnsafeKey(key) {
1272
+ return UNSAFE_OBJECT_KEYS.has(key);
1273
+ },
1274
+
1275
+ /**
1276
+ * Reads a parsed attribute list of atoms or strings (a flag list, a capability list) into
1277
+ * an array of strings. Any element can be a parsed NIL, and the list itself can be NIL,
1278
+ * so both levels are guarded here rather than at each call site.
1279
+ *
1280
+ * @param {*} list - Parsed attribute list from a response.
1281
+ * @returns {String[]} The string values, in order, with unusable entries dropped.
1282
+ */
1283
+ getStringList(list) {
1284
+ if (!Array.isArray(list)) {
1285
+ return [];
1286
+ }
1287
+ return list.map(entry => (entry && typeof entry.value === 'string' ? entry.value : false)).filter(entry => entry);
1288
+ },
1289
+
1290
+ /**
1291
+ * Parses an untrusted decimal value from a server response into a BigInt.
1292
+ *
1293
+ * @param {*} value - Raw value from the response.
1294
+ * @param {Number} [maxDigits=MAX_NUMBER64_DIGITS] - Maximum number of digits accepted.
1295
+ * @returns {BigInt|false} The parsed value, or false when it is not usable.
1296
+ */
1297
+ parseBigIntValue(value, maxDigits) {
1298
+ if (!tools.isDecimalString(value, maxDigits || MAX_NUMBER64_DIGITS)) {
1299
+ return false;
1300
+ }
1301
+ return BigInt(value);
1302
+ },
1303
+
1304
+ /**
1305
+ * Parses an untrusted decimal value from a server response into a Number. Values beyond
1306
+ * the safe integer range are rejected rather than rounded: a silently rounded count or
1307
+ * UID corrupts every range computation derived from it.
1308
+ *
1309
+ * @param {*} value - Raw value from the response.
1310
+ * @param {Number} [maxDigits=MAX_NUMBER64_DIGITS] - Maximum number of digits accepted.
1311
+ * @returns {Number|false} The parsed value, or false when it is not usable.
1312
+ */
1313
+ parseUintValue(value, maxDigits) {
1314
+ if (!tools.isDecimalString(value, maxDigits || MAX_NUMBER64_DIGITS)) {
1315
+ return false;
1316
+ }
1317
+ let num = Number(value);
1318
+ return Number.isSafeInteger(num) ? num : false;
1319
+ },
1320
+
1201
1321
  /**
1202
1322
  * Expands an IMAP sequence range string (e.g. "1:3,5,7:9") into an array of numbers.
1203
1323
  *
1204
1324
  * Entries with endpoints that are not valid nz-numbers are skipped - the input
1205
1325
  * may come from an untrusted server, and 'Infinity' or similar garbage would
1206
- * otherwise loop without bound. A single range is expanded to at most
1207
- * EXPANDED_RANGE_LIMIT entries: legitimate responses never reach the limit
1208
- * (the mailbox would need that many messages), while a hostile range like
1209
- * 1:4294967295 is cut off instead of exhausting memory.
1326
+ * otherwise loop without bound. The whole set is expanded to at most
1327
+ * EXPANDED_RANGE_LIMIT entries in total: legitimate responses never reach the limit
1328
+ * (the mailbox would need that many messages), while hostile input is cut off
1329
+ * instead of exhausting memory. The total is capped, not just each range -
1330
+ * otherwise "1:16777216,1:16777216,..." would multiply the per-range bound by an
1331
+ * unbounded number of ranges.
1210
1332
  *
1211
1333
  * @param {String} range - IMAP sequence range string
1212
1334
  * @returns {Number[]} Array of expanded sequence numbers
1213
1335
  */
1214
1336
  expandRange(range) {
1215
- return range.split(',').flatMap(entry => {
1337
+ let result = [];
1338
+ // Callers pass whatever the response parser produced for the sequence set, and a
1339
+ // malformed response can leave that as `false` (e.g. a VANISHED response carrying
1340
+ // only the (EARLIER) tag). Nothing to expand then, and throwing here would abort
1341
+ // the handler for the rest of the response.
1342
+ if (typeof range !== 'string') {
1343
+ return result;
1344
+ }
1345
+ for (let entry of range.split(',')) {
1346
+ if (result.length >= EXPANDED_RANGE_LIMIT) {
1347
+ break;
1348
+ }
1216
1349
  entry = entry.trim();
1217
1350
  let colon = entry.indexOf(':');
1218
1351
  if (colon < 0) {
1219
1352
  let value = Number(entry);
1220
- return tools.isValidSequenceValue(value) ? value : [];
1353
+ if (tools.isValidSequenceValue(value)) {
1354
+ result.push(value);
1355
+ }
1356
+ continue;
1221
1357
  }
1222
1358
  let first = Number(entry.substr(0, colon));
1223
1359
  let second = Number(entry.substr(colon + 1));
1224
1360
  if (!tools.isValidSequenceValue(first) || !tools.isValidSequenceValue(second)) {
1225
- return [];
1361
+ continue;
1226
1362
  }
1227
1363
  if (first === second) {
1228
- return first;
1364
+ result.push(first);
1365
+ continue;
1229
1366
  }
1230
- let list = [];
1367
+ // Remaining total budget doubles as the per-range bound
1368
+ let remaining = EXPANDED_RANGE_LIMIT - result.length;
1231
1369
  if (first < second) {
1232
- let last = Math.min(second, first + EXPANDED_RANGE_LIMIT - 1);
1370
+ let last = Math.min(second, first + remaining - 1);
1233
1371
  for (let i = first; i <= last; i++) {
1234
- list.push(i);
1372
+ result.push(i);
1235
1373
  }
1236
1374
  } else {
1237
- let last = Math.max(second, first - EXPANDED_RANGE_LIMIT + 1);
1375
+ let last = Math.max(second, first - remaining + 1);
1238
1376
  for (let i = first; i >= last; i--) {
1239
- list.push(i);
1377
+ result.push(i);
1240
1378
  }
1241
1379
  }
1242
- return list;
1243
- });
1380
+ }
1381
+ return result;
1244
1382
  },
1245
1383
 
1246
1384
  /**
@@ -1248,13 +1386,17 @@ const tools = {
1248
1386
  * charset decoder for JIS/ISO-2022-JP, otherwise delegates to iconv-lite.
1249
1387
  *
1250
1388
  * @param {String} [charset='ascii'] - Character set name
1389
+ * @param {Number} [maxBytes] - Bound for the bytes the decoder may buffer. Only
1390
+ * relevant for the Japanese decoder, which must buffer its whole input before
1391
+ * it can decode: without the bound a server could defeat a caller's maxBytes
1392
+ * download limit simply by labelling the part with a Japanese charset.
1251
1393
  * @returns {Object} A stream decoder (Transform stream) for the charset
1252
1394
  */
1253
- getDecoder(charset) {
1395
+ getDecoder(charset, maxBytes) {
1254
1396
  charset = (charset || 'ascii').toString().trim().toLowerCase();
1255
1397
  if (/^jis|^iso-?2022-?jp|^euc-?jp/.test(charset)) {
1256
1398
  // special case not supported by iconv-lite
1257
- return new JPDecoder(charset);
1399
+ return new JPDecoder(charset, maxBytes);
1258
1400
  }
1259
1401
 
1260
1402
  return iconv.decodeStream(charset);
@@ -1302,3 +1444,12 @@ const tools = {
1302
1444
  };
1303
1445
 
1304
1446
  module.exports = tools;
1447
+
1448
+ // Shared bound for expanding server-supplied sequence sets (see expandRange). Exported so
1449
+ // other places that expand server sequences (e.g. ESEARCH ALL in commands/search.js) can
1450
+ // apply the same absolute ceiling instead of inventing their own.
1451
+ module.exports.EXPANDED_RANGE_LIMIT = EXPANDED_RANGE_LIMIT;
1452
+
1453
+ // Exported so call sites can ask for the tighter 32-bit bound where the grammar requires it
1454
+ // (UID, UIDVALIDITY, message counts) instead of the parsers' 63-bit default.
1455
+ module.exports.MAX_UINT32_DIGITS = MAX_UINT32_DIGITS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.6.4",
3
+ "version": "1.6.6",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -35,7 +35,7 @@
35
35
  "eslint": "10.8.0",
36
36
  "eslint-config-nodemailer": "1.2.0",
37
37
  "eslint-config-prettier": "10.1.8",
38
- "grunt": "1.6.2",
38
+ "grunt": "1.6.3",
39
39
  "grunt-cli": "1.5.0",
40
40
  "grunt-contrib-nodeunit": "5.0.0",
41
41
  "grunt-eslint": "26.0.0",
@@ -44,11 +44,11 @@
44
44
  "typescript": "7.0.2"
45
45
  },
46
46
  "dependencies": {
47
- "@zone-eu/mailsplit": "5.4.14",
47
+ "@zone-eu/mailsplit": "5.4.15",
48
48
  "encoding-japanese": "2.2.0",
49
49
  "iconv-lite": "0.7.3",
50
50
  "libbase64": "1.3.0",
51
- "libmime": "5.4.1",
51
+ "libmime": "5.4.2",
52
52
  "libqp": "2.1.1",
53
53
  "pino": "10.3.1",
54
54
  "socks": "2.8.9"
@@ -56,6 +56,10 @@ const createMockConnection = (overrides = {}) => {
56
56
  // A live transport: command implementations that guard against polling or writing on a
57
57
  // dead connection (idle.js) need this to look established.
58
58
  socket: overrides.socket || { destroyed: false },
59
+ // Mirrors ImapFlow.throttleWait(): resolves false on normal expiry, true when close()
60
+ // aborted the wait. The mock resolves immediately so throttle retries stay fast.
61
+ throttleWait: overrides.throttleWait || (async () => false),
62
+ createNoConnectionError: overrides.createNoConnectionError || (() => Object.assign(new Error('Connection not available'), { code: 'NoConnection' })),
59
63
  messageFlagsAdd: overrides.messageFlagsAdd || (async () => {}),
60
64
  messageCopy: overrides.messageCopy || (async () => {}),
61
65
  messageDelete: overrides.messageDelete || (async () => {}),