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
@@ -12,16 +12,28 @@ const MAX_LITERAL_SIZE = 1024 * 1024 * 1024;
12
12
  // only to stop a server that never sends a line terminator, not to constrain normal traffic.
13
13
  const MAX_LINE_SIZE = MAX_LITERAL_SIZE;
14
14
 
15
+ // Default maximum total size of a single assembled response: every line segment and literal of
16
+ // one response combined. The per-line and per-literal caps alone cannot stop a server that
17
+ // spreads attacker-controlled bytes across an unbounded number of tokens of a single response
18
+ // (e.g. one FETCH answer carrying many maximum-size literals).
19
+ //
20
+ // Deliberately above the literal cap: the response total also carries the literal's marker line
21
+ // and the rest of the response framing, so a cap equal to MAX_LITERAL_SIZE would make a literal
22
+ // of exactly the maximum permitted size impossible to receive. Configuring both limits calls for
23
+ // the same headroom - set maxResponseSize above maxLiteralSize, not equal to it.
24
+ const MAX_RESPONSE_SIZE = 2 * MAX_LITERAL_SIZE;
25
+
15
26
  /**
16
27
  * Normalizes a configured size limit. A non-negative integer is honored as-is (including 0, which
17
- * means "reject anything non-empty"); anything else falls back to the default, so an explicit 0 is
18
- * not silently swallowed the way `value || DEFAULT` would swallow it.
28
+ * means "reject anything non-empty"), and `Infinity` disables the limit; anything else falls back
29
+ * to the default, so an explicit 0 is not silently swallowed the way `value || DEFAULT` would
30
+ * swallow it.
19
31
  *
20
32
  * @param {*} value - The configured value.
21
33
  * @param {number} defaultValue - Fallback when the value is not a usable limit.
22
34
  * @returns {number} The normalized limit.
23
35
  */
24
- const normalizeLimit = (value, defaultValue) => (Number.isInteger(value) && value >= 0 ? value : defaultValue);
36
+ const normalizeLimit = (value, defaultValue) => ((Number.isInteger(value) || value === Infinity) && value >= 0 ? value : defaultValue);
25
37
 
26
38
  /**
27
39
  * Builds the `LiteralTooLarge` error. One shape for every place a literal is refused, so callers
@@ -40,4 +52,4 @@ const createLiteralTooLargeError = (literalSize, maxSize, reason) => {
40
52
  return err;
41
53
  };
42
54
 
43
- module.exports = { MAX_LITERAL_SIZE, MAX_LINE_SIZE, normalizeLimit, createLiteralTooLargeError };
55
+ module.exports = { MAX_LITERAL_SIZE, MAX_LINE_SIZE, MAX_RESPONSE_SIZE, normalizeLimit, createLiteralTooLargeError };
@@ -85,18 +85,34 @@ export interface ImapFlowOptions {
85
85
  * Maximum allowed length in bytes of a single response line (a response without a literal).
86
86
  * Guards against a malicious or broken server that never sends a line terminator. Defaults to
87
87
  * 1GB. The line terminator counts towards the limit and a line exactly at the limit is
88
- * accepted. Exceeding it is terminal: the connection fails with error code `LineTooLarge` and
88
+ * accepted. `Infinity` disables the limit. An in-progress line is additionally bounded by
89
+ * whatever is left of `maxResponseSize`, so lowering that also bounds line buffering.
90
+ * Exceeding it is terminal: the connection fails with error code `LineTooLarge` and
89
91
  * no further input is parsed.
90
92
  */
91
93
  maxLineLength?: number;
92
94
  /**
93
95
  * Maximum allowed size in bytes of a single IMAP literal block. Bounds peak memory allocation
94
96
  * against a malicious or broken server announcing an oversized literal. Defaults to 1GB. A
95
- * literal exactly at the limit is accepted. Exceeding it is terminal: the connection fails
97
+ * literal exactly at the limit is accepted, provided `maxResponseSize` leaves room for the
98
+ * marker line as the defaults do. `Infinity` disables the limit. Exceeding it is terminal: the connection fails
96
99
  * with error code `LiteralTooLarge`, and neither the marker line nor any byte of the rejected
97
100
  * literal is interpreted as protocol.
98
101
  */
99
102
  maxLiteralSize?: number;
103
+ /**
104
+ * Maximum allowed total size in bytes of a single assembled IMAP response (every line
105
+ * segment and literal of one response combined). Bounds peak memory allocation against
106
+ * a malicious or broken server that spreads response data across an unbounded number of
107
+ * tokens, which the per-line and per-literal caps alone cannot stop. Defaults to 2GB,
108
+ * which is above the default literal cap on purpose: the total also carries the literal
109
+ * marker line and the rest of the response framing, so a value equal to `maxLiteralSize`
110
+ * would make a literal of exactly the maximum permitted size impossible to receive. Set
111
+ * this above `maxLiteralSize` when configuring both. `Infinity` disables the limit.
112
+ * Exceeding it is terminal: the connection fails with error code `ResponseTooLarge` and
113
+ * no further input is parsed.
114
+ */
115
+ maxResponseSize?: number;
100
116
  /**
101
117
  * Threshold in milliseconds for warning that a mailbox lock has been held
102
118
  * for a long time (diagnostic for forgotten release() calls). Defaults to
@@ -145,6 +161,10 @@ export interface MailboxObject {
145
161
  uidNext: number;
146
162
  /** Messages in this folder */
147
163
  exists: number;
164
+ /** Sequence number of the first unseen message, if the server reported [UNSEEN] on SELECT. Not a count of unseen messages - use mailboxStatus() with {unseen: true} for that */
165
+ unseen?: number;
166
+ /** Largest message size in octets the server accepts for APPEND into this mailbox, if it reported [APPENDLIMIT] (RFC 7889) */
167
+ appendlimit?: number;
148
168
  /** Read-only state */
149
169
  readOnly?: boolean;
150
170
  }
package/lib/imap-flow.js CHANGED
@@ -12,7 +12,7 @@ const logger = require('./logger');
12
12
  const libmime = require('libmime');
13
13
  const zlib = require('zlib');
14
14
  const { Headers } = require('@zone-eu/mailsplit');
15
- const { LimitedPassthrough } = require('./limited-passthrough');
15
+ const { LimitedPassthrough, normalizeByteLimit } = require('./limited-passthrough');
16
16
 
17
17
  const { ImapStream } = require('./handler/imap-stream');
18
18
  const { parser, compiler } = require('./handler/imap-handler');
@@ -38,7 +38,11 @@ const {
38
38
  AuthenticationFailure,
39
39
  getColorFlags,
40
40
  hasCapability,
41
- unrefTimer
41
+ unrefTimer,
42
+ parseUintValue,
43
+ isUnsafeKey,
44
+ getStringList,
45
+ MAX_UINT32_DIGITS
42
46
  } = require('./tools');
43
47
 
44
48
  const imapCommands = require('./imap-commands.js');
@@ -50,6 +54,10 @@ const UPGRADE_TIMEOUT = 10 * 1000;
50
54
 
51
55
  const SOCKET_TIMEOUT = 5 * 60 * 1000;
52
56
 
57
+ // Ceiling for any throttle back-off wait. Both the connection-level back-off and the per-command
58
+ // retries derive their delay from server-supplied hints, which are unbounded.
59
+ const MAX_THROTTLE_DELAY = 5 * 60 * 1000;
60
+
53
61
  // Default threshold for warning that a mailbox lock has been held for a long
54
62
  // time. Intended to catch forgotten release() calls, not legitimate long ops
55
63
  // (e.g. fetching hundreds of thousands of messages). Configurable via the
@@ -323,17 +331,18 @@ class ImapFlow extends EventEmitter {
323
331
  logRaw: this.logRaw,
324
332
  secureConnection: this.secureConnection,
325
333
  maxLineLength: this.options.maxLineLength,
326
- maxLiteralSize: this.options.maxLiteralSize
334
+ maxLiteralSize: this.options.maxLiteralSize,
335
+ maxResponseSize: this.options.maxResponseSize
327
336
  });
328
337
 
329
338
  this.reading = false;
330
339
  this.socket = false;
331
340
  this.writeSocket = false;
332
341
 
333
- // Tracked throttle back-off timer (see reader()). Stored so close() can clear it
334
- // and abort the wait instead of letting it keep the event loop alive for minutes.
335
- this._throttleTimer = null;
336
- this._throttleAbort = null;
342
+ // In-flight throttle back-offs (see throttleWait()). Tracked as a set because more than
343
+ // one can be pending at a time: the reader's connection-level back-off and a command
344
+ // retrying its own throttled request. close() clears them all.
345
+ this._throttleWaits = new Set();
337
346
 
338
347
  // Pending rejector of the in-flight STARTTLS upgrade promise (see upgradeToSTARTTLS()).
339
348
  // Stored so emitError() can route a streamer-originated error into the upgrade's single
@@ -643,22 +652,40 @@ class ImapFlow extends EventEmitter {
643
652
  }
644
653
 
645
654
  if (typeof options.onSend === 'function') {
646
- options.onSend();
655
+ // The command is already on the wire, so a throwing onSend callback must not
656
+ // reach trySend()'s catch - that would reject the request and dispatch the
657
+ // next command into the server's pending state for this one.
658
+ try {
659
+ options.onSend();
660
+ } catch (err) {
661
+ this.log.warn({ err, cid: this.id });
662
+ }
647
663
  }
648
664
  }
649
665
 
650
666
  async trySend() {
651
- if (this.currentRequest || !this.requestQueue.length) {
652
- return;
653
- }
654
- this.currentRequest = this.requestQueue.shift();
667
+ while (!this.currentRequest && this.requestQueue.length) {
668
+ this.currentRequest = this.requestQueue.shift();
655
669
 
656
- await this.send({
657
- tag: this.currentRequest.tag,
658
- command: this.currentRequest.command,
659
- attributes: this.currentRequest.attributes,
660
- options: this.currentRequest.options
661
- });
670
+ try {
671
+ await this.send({
672
+ tag: this.currentRequest.tag,
673
+ command: this.currentRequest.command,
674
+ attributes: this.currentRequest.attributes,
675
+ options: this.currentRequest.options
676
+ });
677
+ return;
678
+ } catch (err) {
679
+ // A failure here (most likely the compiler refusing an invalid
680
+ // user-supplied value) belongs to the command that was being dispatched.
681
+ // Without this the shifted request would stay currentRequest forever:
682
+ // nothing reached the wire, so no tagged response ever clears it, and
683
+ // every later command would queue behind it until the socket timeout.
684
+ // Reject the failed command and keep draining the queue.
685
+ this.commandParts = [];
686
+ this.rejectCurrentRequest(err);
687
+ }
688
+ }
662
689
  }
663
690
 
664
691
  exec(command, attributes, options) {
@@ -685,10 +712,10 @@ class ImapFlow extends EventEmitter {
685
712
  let promise = new Promise((resolve, reject) => {
686
713
  this.requestTagMap.set(tag, { command, attributes, options, resolve, reject });
687
714
  this.requestQueue.push({ tag, command, attributes, options });
688
- this.trySend().catch(err => {
689
- this.requestTagMap.delete(tag);
690
- reject(err);
691
- });
715
+ // trySend() settles dispatch failures itself, by rejecting the affected
716
+ // command through requestTagMap; this catch exists only so a throw from the
717
+ // dispatch machinery itself can never surface as a floating rejection.
718
+ this.trySend().catch(err => this.log.warn({ err, cid: this.id }));
692
719
  });
693
720
 
694
721
  // Prevent unhandled promise rejection if close() rejects this request
@@ -798,6 +825,32 @@ class ImapFlow extends EventEmitter {
798
825
  }
799
826
  }
800
827
 
828
+ /**
829
+ * Waits out a throttle back-off.
830
+ *
831
+ * The delay is capped at MAX_THROTTLE_DELAY because it can come straight from a server hint
832
+ * (a Microsoft 365 "Suggested Backoff Time", say) and an uncapped hint would park the caller
833
+ * for weeks. The timer is unref'd and tracked so it can never outlive the client: a bare
834
+ * setTimeout here keeps a short-lived process alive for the full delay after close(), and
835
+ * leaves the caller waiting on a connection that is already gone.
836
+ *
837
+ * @param {Number} delay - Requested delay in milliseconds.
838
+ * @returns {Promise<Boolean>} True if close() aborted the wait, false on normal expiry.
839
+ */
840
+ async throttleWait(delay) {
841
+ delay = Math.min(Math.max(Number(delay) || 0, 0), MAX_THROTTLE_DELAY);
842
+
843
+ return await new Promise(resolve => {
844
+ let entry = { resolve };
845
+ entry.timer = setTimeout(() => {
846
+ this._throttleWaits.delete(entry);
847
+ resolve(false);
848
+ }, delay);
849
+ unrefTimer(entry.timer);
850
+ this._throttleWaits.add(entry);
851
+ });
852
+ }
853
+
801
854
  async reader() {
802
855
  let data;
803
856
  let processedCount = 0;
@@ -848,8 +901,17 @@ class ImapFlow extends EventEmitter {
848
901
  return;
849
902
  }
850
903
 
851
- let tag = payload.toString('latin1', 0, 64).match(/^(\S+)/);
852
- if (!tag || tag[1] !== this.currentRequest.tag) {
904
+ // Prefer the tag the parser had already extracted before it failed - it went
905
+ // through the same leading-NUL workaround as every parsed response. Fall back
906
+ // to the raw bytes for lines whose tag itself was unparseable: skip the NUL
907
+ // padding buggy servers prepend and stop at the first byte a tag cannot contain.
908
+ let tag = parserError && parserError.parsedTag;
909
+ if (!tag) {
910
+ // eslint-disable-next-line no-control-regex
911
+ let match = payload.toString('latin1', 0, 64).match(/^\0*([^\s\x00-\x1f\x7f]+)/);
912
+ tag = match && match[1];
913
+ }
914
+ if (!tag || tag !== this.currentRequest.tag) {
853
915
  return;
854
916
  }
855
917
 
@@ -873,23 +935,11 @@ class ImapFlow extends EventEmitter {
873
935
 
874
936
  try {
875
937
  parsed = await parser(data.payload, { literals: data.literals });
876
- if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
877
- let payload = { response: parsed.command };
878
-
879
- if (
880
- parsed.attributes &&
881
- parsed.attributes[0] &&
882
- parsed.attributes[0].section &&
883
- parsed.attributes[0].section[0] &&
884
- parsed.attributes[0].section[0].type === 'ATOM'
885
- ) {
886
- payload.code = parsed.attributes[0].section[0].value;
887
- }
888
- this.emit('response', payload);
889
- }
890
938
  } catch (err) {
891
- // can not make sense of this
892
- this.log.error({ src: 's', msg: data.payload.toString(), err, cid: this.id });
939
+ // can not make sense of this. The payload can be up to the configured line
940
+ // cap (1GB by default), so log only a bounded prefix: a server looping
941
+ // unparseable garbage would otherwise turn this error log into a disk filler.
942
+ this.log.error({ src: 's', msg: data.payload.toString('latin1', 0, 1024), payloadBytes: data.payload.length, err, cid: this.id });
893
943
  // An unparseable untagged line is junk that can be skipped, but the line may
894
944
  // have been the in-flight command's tagged completion. Dropping that one
895
945
  // silently strands the command: currentRequest is never cleared, so trySend()
@@ -901,6 +951,28 @@ class ImapFlow extends EventEmitter {
901
951
  return true;
902
952
  }
903
953
 
954
+ if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
955
+ let payload = { response: parsed.command };
956
+
957
+ if (
958
+ parsed.attributes &&
959
+ parsed.attributes[0] &&
960
+ parsed.attributes[0].section &&
961
+ parsed.attributes[0].section[0] &&
962
+ parsed.attributes[0].section[0].type === 'ATOM'
963
+ ) {
964
+ payload.code = parsed.attributes[0].section[0].value;
965
+ }
966
+ // Outside the parse try/catch on purpose: a throwing user 'response' listener
967
+ // is not a parse failure and must not settle the in-flight command or fail the
968
+ // connection - the same contract untagged handlers get.
969
+ try {
970
+ this.emit('response', payload);
971
+ } catch (err) {
972
+ this.log.warn({ err, cid: this.id });
973
+ }
974
+ }
975
+
904
976
  let logCompiled = await compiler(parsed, {
905
977
  isLogging: true
906
978
  });
@@ -939,7 +1011,9 @@ class ImapFlow extends EventEmitter {
939
1011
  }
940
1012
 
941
1013
  let section = parsed.attributes && parsed.attributes.length && parsed.attributes[0] && !parsed.attributes[0].value && parsed.attributes[0].section;
942
- if (section && section.length && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
1014
+ // section[0] can be a parsed NIL (null), e.g. from a "[NIL]" response code - the
1015
+ // dereference must be guarded or one such line tears down the whole connection
1016
+ if (section && section.length && section[0] && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
943
1017
  let sectionHandler = this.getSectionHandler(section[0].value.toUpperCase().trim());
944
1018
  if (sectionHandler) {
945
1019
  try {
@@ -1089,27 +1163,12 @@ class ImapFlow extends EventEmitter {
1089
1163
  err.code = 'ETHROTTLE';
1090
1164
  err.throttleReset = throttleDelay;
1091
1165
 
1092
- let delayResponse = throttleDelay;
1093
- if (delayResponse > 5 * 60 * 1000) {
1094
- // Cap wait at 5 minutes to avoid hanging connections indefinitely.
1095
- // The server-suggested delay can be very large.
1096
- delayResponse = 5 * 60 * 1000;
1097
- }
1166
+ // The server-suggested delay can be very large, so throttleWait() caps it
1167
+ let delayResponse = Math.min(throttleDelay, MAX_THROTTLE_DELAY);
1098
1168
 
1099
1169
  this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err });
1100
1170
 
1101
- // Tracked, abortable wait. Storing the timer lets close() clear it so
1102
- // the back-off never keeps the event loop alive, and storing the resolve
1103
- // lets close() abort the wait promptly (aborted=true) instead of blocking
1104
- // the reader for up to 5 minutes and rejecting long after the connection
1105
- // is gone. Normal expiry resolves with aborted=false.
1106
- let aborted = await new Promise(resolve => {
1107
- this._throttleAbort = resolve;
1108
- this._throttleTimer = setTimeout(() => resolve(false), delayResponse);
1109
- unrefTimer(this._throttleTimer);
1110
- });
1111
- this._throttleTimer = null;
1112
- this._throttleAbort = null;
1171
+ let aborted = await this.throttleWait(delayResponse);
1113
1172
 
1114
1173
  if (aborted) {
1115
1174
  // Connection closed during back-off: reject promptly with a
@@ -1536,6 +1595,12 @@ class ImapFlow extends EventEmitter {
1536
1595
  let opts = Object.assign(
1537
1596
  {
1538
1597
  socket: this.socket,
1598
+ // host is required even though the socket is already connected: without
1599
+ // it, a connection made to an IP literal (servername=false) has its
1600
+ // certificate verified against Node's fallback name "localhost" instead
1601
+ // of the IP - accepting any "localhost" certificate for any IP-hosted
1602
+ // server, and rejecting legitimate IP-SAN certificates.
1603
+ host: this.host,
1539
1604
  servername: this.servername,
1540
1605
  port: this.port
1541
1606
  },
@@ -1672,8 +1737,7 @@ class ImapFlow extends EventEmitter {
1672
1737
  // STARTTLS OK itself clears expectCapabilityUpdate, so keying the discard
1673
1738
  // on that flag would keep exactly the pre-TLS list an attacker controls -
1674
1739
  // the list that then picks the AUTH mechanism and answers LOGINDISABLED.
1675
- this.capabilities.clear();
1676
- this.authCapabilities.clear();
1740
+ this.clearCapabilities();
1677
1741
  await this.run('CAPABILITY');
1678
1742
  }
1679
1743
 
@@ -1818,6 +1882,17 @@ class ImapFlow extends EventEmitter {
1818
1882
  this.state = this.states.LOGOUT;
1819
1883
  }
1820
1884
 
1885
+ // Drops every capability-derived field together - the counterpart of
1886
+ // updateCapabilitiesFromRaw() below, which sets them together. rawCapabilities is
1887
+ // public surface external consumers read, so a discard (RFC 9051 6.2.1 requires
1888
+ // one after STARTTLS) that missed it would leave the stale list visible if the
1889
+ // re-fetch fails.
1890
+ clearCapabilities() {
1891
+ this.capabilities.clear();
1892
+ this.authCapabilities.clear();
1893
+ this.rawCapabilities = null;
1894
+ }
1895
+
1821
1896
  updateCapabilitiesFromRaw(rawCapabilities) {
1822
1897
  this.rawCapabilities = rawCapabilities;
1823
1898
  this.capabilities = updateCapabilities(rawCapabilities);
@@ -1849,11 +1924,18 @@ class ImapFlow extends EventEmitter {
1849
1924
  return;
1850
1925
  }
1851
1926
 
1852
- if (!untagged || !untagged.command || isNaN(untagged.command)) {
1927
+ if (!untagged) {
1853
1928
  return;
1854
1929
  }
1855
1930
 
1856
- let count = Number(untagged.command);
1931
+ // Not a usable count: anything but a bounded digit run. A digit run long enough
1932
+ // coerces to Infinity, which would corrupt mailbox state (resolveRange('*') would
1933
+ // compile to the literal "Infinity" and every range-based command would fail until
1934
+ // the next SELECT)
1935
+ let count = parseUintValue(untagged.command, MAX_UINT32_DIGITS);
1936
+ if (count === false) {
1937
+ return;
1938
+ }
1857
1939
  if (count === this.mailbox.exists) {
1858
1940
  // nothing changed?
1859
1941
  return;
@@ -1875,11 +1957,12 @@ class ImapFlow extends EventEmitter {
1875
1957
  return;
1876
1958
  }
1877
1959
 
1878
- if (!untagged || !untagged.command || isNaN(untagged.command)) {
1960
+ if (!untagged) {
1879
1961
  return;
1880
1962
  }
1881
1963
 
1882
- let seq = Number(untagged.command);
1964
+ // Same bound untaggedExists() applies: only a bounded decimal run is a usable sequence number
1965
+ let seq = parseUintValue(untagged.command, MAX_UINT32_DIGITS);
1883
1966
  if (seq && seq <= this.mailbox.exists) {
1884
1967
  this.mailbox.exists--;
1885
1968
  let payload = {
@@ -1910,8 +1993,14 @@ class ImapFlow extends EventEmitter {
1910
1993
  let tags = [];
1911
1994
  let uids = false;
1912
1995
 
1996
+ // A malformed VANISHED can carry no attributes at all, and one carrying only the
1997
+ // (EARLIER) tag leaves `uids` false - expandRange() handles that and yields nothing
1998
+ if (!untagged.attributes || !untagged.attributes.length) {
1999
+ return;
2000
+ }
2001
+
1913
2002
  if (untagged.attributes.length > 1 && Array.isArray(untagged.attributes[0])) {
1914
- tags = untagged.attributes[0].map(entry => (typeof entry.value === 'string' ? entry.value.toUpperCase() : false)).filter(value => value);
2003
+ tags = getStringList(untagged.attributes[0]).map(value => value.toUpperCase());
1915
2004
  untagged.attributes.shift();
1916
2005
  }
1917
2006
 
@@ -2275,14 +2364,13 @@ class ImapFlow extends EventEmitter {
2275
2364
  clearTimeout(this.connectTimeout);
2276
2365
  clearTimeout(this.greetingTimeout);
2277
2366
 
2278
- // Abort any in-flight throttle back-off so the reader unblocks and the
2279
- // throttled request is rejected promptly rather than after the full delay.
2280
- clearTimeout(this._throttleTimer);
2281
- this._throttleTimer = null;
2282
- if (typeof this._throttleAbort === 'function') {
2283
- this._throttleAbort(true);
2284
- this._throttleAbort = null;
2367
+ // Abort every in-flight throttle back-off so each waiter unblocks and its request is
2368
+ // settled promptly rather than after the full delay.
2369
+ for (let entry of this._throttleWaits) {
2370
+ clearTimeout(entry.timer);
2371
+ entry.resolve(true);
2285
2372
  }
2373
+ this._throttleWaits.clear();
2286
2374
 
2287
2375
  this.usable = false;
2288
2376
  // close() takes over ownership of the idling state: dropping the session token means a
@@ -3548,7 +3636,8 @@ class ImapFlow extends EventEmitter {
3548
3636
  let processed = 0;
3549
3637
 
3550
3638
  let chunkSize = Number(options.chunkSize) || 64 * 1024;
3551
- let maxBytes = Number(options.maxBytes) || Infinity;
3639
+ // Normalized once here so every bounded stage of the pipeline below agrees on the budget
3640
+ let maxBytes = normalizeByteLimit(options.maxBytes);
3552
3641
 
3553
3642
  let uid = false;
3554
3643
 
@@ -3738,24 +3827,43 @@ class ImapFlow extends EventEmitter {
3738
3827
  output = stream = new PassThrough();
3739
3828
  }
3740
3829
 
3830
+ // Every byte-bounded stage of the pipeline. The fetch loop below stops as soon as any of
3831
+ // them has taken all it will accept. The limiter at the tail is not enough on its own: a
3832
+ // transform in the middle that buffers its whole input before emitting anything (the
3833
+ // format=flowed decoder, the Japanese charset decoder) leaves the tail limiter reporting
3834
+ // `limited === false` however much the server sends, so a download with a small maxBytes
3835
+ // would still pull the entire part off the wire.
3836
+ let limiters = [];
3837
+ let isLimited = () => limiters.some(entry => entry.limited);
3838
+
3839
+ // Appending a stage means forwarding the current tail's errors to it before piping, so a
3840
+ // failure anywhere reaches the stream the caller is reading
3841
+ let pipeStage = stage => {
3842
+ output.on('error', err => {
3843
+ stage.emit('error', err);
3844
+ });
3845
+ output = output.pipe(stage);
3846
+ return stage;
3847
+ };
3848
+
3741
3849
  let isTextNode = ['text/html', 'text/plain', 'text/x-amp-html'].includes(meta.contentType) || (part === '1' && !meta.contentType);
3742
3850
  if ((!meta.disposition || meta.disposition === 'inline') && isTextNode) {
3743
3851
  // RFC 3676 format=flowed text: unwrap soft line breaks
3744
3852
  if (meta.flowed) {
3745
- let flowDecoder = new FlowedDecoder({
3746
- delSp: meta.delSp
3747
- });
3748
- output.on('error', err => {
3749
- flowDecoder.emit('error', err);
3750
- });
3751
- output = output.pipe(flowDecoder);
3853
+ // FlowedDecoder buffers its whole input before emitting, and being third party it
3854
+ // carries no bound of its own, so bound what it can ever be handed. Unwrapping only
3855
+ // removes bytes, so capping its input at maxBytes cannot push the delivered output
3856
+ // above the cap either.
3857
+ limiters.push(pipeStage(new LimitedPassthrough({ maxBytes })));
3858
+
3859
+ pipeStage(new FlowedDecoder({ delSp: meta.delSp }));
3752
3860
  }
3753
3861
 
3754
3862
  // Convert non-UTF-8 charsets to UTF-8 via a streaming decoder.
3755
3863
  // ASCII and UTF-8 need no conversion. Unknown charsets are left as-is.
3756
3864
  if (meta.charset && !['ascii', 'usascii', 'utf8'].includes(meta.charset.toLowerCase().replace(/[^a-z0-9]+/g, ''))) {
3757
3865
  try {
3758
- let decoder = getDecoder(meta.charset);
3866
+ let decoder = getDecoder(meta.charset, maxBytes);
3759
3867
  // Safety listener attached first so the decoder always has at least
3760
3868
  // one 'error' listener. Prevents Node.js from throwing
3761
3869
  // ERR_UNHANDLED_ERROR if a later pipe setup step throws and leaves
@@ -3765,10 +3873,10 @@ class ImapFlow extends EventEmitter {
3765
3873
  decoder.on('error', err => {
3766
3874
  this.log.warn({ err, charset: meta.charset, cid: this.id });
3767
3875
  });
3768
- output.on('error', err => {
3769
- decoder.emit('error', err);
3770
- });
3771
- output = output.pipe(decoder);
3876
+ // The Japanese decoder buffers its whole input as well, and reports the same
3877
+ // `limited` flag the limiters do so the fetch loop can stop once it is full.
3878
+ // A streaming decoder has no such flag, which reads as false and is correct.
3879
+ limiters.push(pipeStage(decoder));
3772
3880
  // force to utf-8 for output
3773
3881
  meta.charset = 'utf-8';
3774
3882
  } catch {
@@ -3777,11 +3885,8 @@ class ImapFlow extends EventEmitter {
3777
3885
  }
3778
3886
  }
3779
3887
 
3780
- let limiter = new LimitedPassthrough({ maxBytes });
3781
- output.on('error', err => {
3782
- limiter.emit('error', err);
3783
- });
3784
- output = output.pipe(limiter);
3888
+ let limiter = pipeStage(new LimitedPassthrough({ maxBytes }));
3889
+ limiters.push(limiter);
3785
3890
 
3786
3891
  // Cleanup function
3787
3892
  const cleanup = () => {
@@ -3796,7 +3901,7 @@ class ImapFlow extends EventEmitter {
3796
3901
  output.once('close', cleanup);
3797
3902
 
3798
3903
  let writeChunk = chunk => {
3799
- if (limiter.limited || fetchAborted || stream.destroyed) {
3904
+ if (isLimited() || fetchAborted || stream.destroyed) {
3800
3905
  return true;
3801
3906
  }
3802
3907
  return stream.write(chunk);
@@ -3806,7 +3911,7 @@ class ImapFlow extends EventEmitter {
3806
3911
  // Stops when the server returns a short chunk (< chunkSize), the byte
3807
3912
  // limiter is satisfied, or the consumer destroys the output stream.
3808
3913
  let fetchAllParts = async () => {
3809
- while (hasMore && !limiter.limited && !fetchAborted) {
3914
+ while (hasMore && !isLimited() && !fetchAborted) {
3810
3915
  let { chunk } = await getNextPart();
3811
3916
  if (!chunk || fetchAborted) {
3812
3917
  break;
@@ -3967,6 +4072,12 @@ class ImapFlow extends EventEmitter {
3967
4072
 
3968
4073
  for (let [part, content] of response.bodyParts) {
3969
4074
  let keyParts = part.split('.mime');
4075
+ // The server chooses the BODY[...] keys it answers with: never let one be a
4076
+ // prototype-chain name, or the assignments below write onto Object.prototype
4077
+ // (process-wide pollution) instead of the result object.
4078
+ if (isUnsafeKey(keyParts[0])) {
4079
+ continue;
4080
+ }
3970
4081
  if (keyParts.length === 1) {
3971
4082
  // content
3972
4083
  let key = keyParts[0];
@@ -4035,7 +4146,11 @@ class ImapFlow extends EventEmitter {
4035
4146
  }
4036
4147
 
4037
4148
  for (let part of Object.keys(data)) {
4038
- let meta = data[part].meta;
4149
+ // `meta` is only built from the companion BODY[<part>.MIME] item. A server may
4150
+ // legally answer with fewer items than were requested, and one part arriving
4151
+ // without its MIME headers must not cost the caller the whole download.
4152
+ let meta = data[part].meta || {};
4153
+ data[part].meta = meta;
4039
4154
 
4040
4155
  // parts that arrived via FETCH BINARY (response.binaryParts) are already
4041
4156
  // decoded by the server - decoding again would corrupt the data