imapflow 1.6.5 → 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.
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
@@ -816,6 +825,32 @@ class ImapFlow extends EventEmitter {
816
825
  }
817
826
  }
818
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
+
819
854
  async reader() {
820
855
  let data;
821
856
  let processedCount = 0;
@@ -901,8 +936,10 @@ class ImapFlow extends EventEmitter {
901
936
  try {
902
937
  parsed = await parser(data.payload, { literals: data.literals });
903
938
  } catch (err) {
904
- // can not make sense of this
905
- 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 });
906
943
  // An unparseable untagged line is junk that can be skipped, but the line may
907
944
  // have been the in-flight command's tagged completion. Dropping that one
908
945
  // silently strands the command: currentRequest is never cleared, so trySend()
@@ -974,7 +1011,9 @@ class ImapFlow extends EventEmitter {
974
1011
  }
975
1012
 
976
1013
  let section = parsed.attributes && parsed.attributes.length && parsed.attributes[0] && !parsed.attributes[0].value && parsed.attributes[0].section;
977
- 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') {
978
1017
  let sectionHandler = this.getSectionHandler(section[0].value.toUpperCase().trim());
979
1018
  if (sectionHandler) {
980
1019
  try {
@@ -1124,27 +1163,12 @@ class ImapFlow extends EventEmitter {
1124
1163
  err.code = 'ETHROTTLE';
1125
1164
  err.throttleReset = throttleDelay;
1126
1165
 
1127
- let delayResponse = throttleDelay;
1128
- if (delayResponse > 5 * 60 * 1000) {
1129
- // Cap wait at 5 minutes to avoid hanging connections indefinitely.
1130
- // The server-suggested delay can be very large.
1131
- delayResponse = 5 * 60 * 1000;
1132
- }
1166
+ // The server-suggested delay can be very large, so throttleWait() caps it
1167
+ let delayResponse = Math.min(throttleDelay, MAX_THROTTLE_DELAY);
1133
1168
 
1134
1169
  this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err });
1135
1170
 
1136
- // Tracked, abortable wait. Storing the timer lets close() clear it so
1137
- // the back-off never keeps the event loop alive, and storing the resolve
1138
- // lets close() abort the wait promptly (aborted=true) instead of blocking
1139
- // the reader for up to 5 minutes and rejecting long after the connection
1140
- // is gone. Normal expiry resolves with aborted=false.
1141
- let aborted = await new Promise(resolve => {
1142
- this._throttleAbort = resolve;
1143
- this._throttleTimer = setTimeout(() => resolve(false), delayResponse);
1144
- unrefTimer(this._throttleTimer);
1145
- });
1146
- this._throttleTimer = null;
1147
- this._throttleAbort = null;
1171
+ let aborted = await this.throttleWait(delayResponse);
1148
1172
 
1149
1173
  if (aborted) {
1150
1174
  // Connection closed during back-off: reject promptly with a
@@ -1571,6 +1595,12 @@ class ImapFlow extends EventEmitter {
1571
1595
  let opts = Object.assign(
1572
1596
  {
1573
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,
1574
1604
  servername: this.servername,
1575
1605
  port: this.port
1576
1606
  },
@@ -1894,11 +1924,18 @@ class ImapFlow extends EventEmitter {
1894
1924
  return;
1895
1925
  }
1896
1926
 
1897
- if (!untagged || !untagged.command || isNaN(untagged.command)) {
1927
+ if (!untagged) {
1898
1928
  return;
1899
1929
  }
1900
1930
 
1901
- 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
+ }
1902
1939
  if (count === this.mailbox.exists) {
1903
1940
  // nothing changed?
1904
1941
  return;
@@ -1920,11 +1957,12 @@ class ImapFlow extends EventEmitter {
1920
1957
  return;
1921
1958
  }
1922
1959
 
1923
- if (!untagged || !untagged.command || isNaN(untagged.command)) {
1960
+ if (!untagged) {
1924
1961
  return;
1925
1962
  }
1926
1963
 
1927
- 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);
1928
1966
  if (seq && seq <= this.mailbox.exists) {
1929
1967
  this.mailbox.exists--;
1930
1968
  let payload = {
@@ -1955,8 +1993,14 @@ class ImapFlow extends EventEmitter {
1955
1993
  let tags = [];
1956
1994
  let uids = false;
1957
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
+
1958
2002
  if (untagged.attributes.length > 1 && Array.isArray(untagged.attributes[0])) {
1959
- 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());
1960
2004
  untagged.attributes.shift();
1961
2005
  }
1962
2006
 
@@ -2320,14 +2364,13 @@ class ImapFlow extends EventEmitter {
2320
2364
  clearTimeout(this.connectTimeout);
2321
2365
  clearTimeout(this.greetingTimeout);
2322
2366
 
2323
- // Abort any in-flight throttle back-off so the reader unblocks and the
2324
- // throttled request is rejected promptly rather than after the full delay.
2325
- clearTimeout(this._throttleTimer);
2326
- this._throttleTimer = null;
2327
- if (typeof this._throttleAbort === 'function') {
2328
- this._throttleAbort(true);
2329
- 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);
2330
2372
  }
2373
+ this._throttleWaits.clear();
2331
2374
 
2332
2375
  this.usable = false;
2333
2376
  // close() takes over ownership of the idling state: dropping the session token means a
@@ -3593,7 +3636,8 @@ class ImapFlow extends EventEmitter {
3593
3636
  let processed = 0;
3594
3637
 
3595
3638
  let chunkSize = Number(options.chunkSize) || 64 * 1024;
3596
- 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);
3597
3641
 
3598
3642
  let uid = false;
3599
3643
 
@@ -3783,24 +3827,43 @@ class ImapFlow extends EventEmitter {
3783
3827
  output = stream = new PassThrough();
3784
3828
  }
3785
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
+
3786
3849
  let isTextNode = ['text/html', 'text/plain', 'text/x-amp-html'].includes(meta.contentType) || (part === '1' && !meta.contentType);
3787
3850
  if ((!meta.disposition || meta.disposition === 'inline') && isTextNode) {
3788
3851
  // RFC 3676 format=flowed text: unwrap soft line breaks
3789
3852
  if (meta.flowed) {
3790
- let flowDecoder = new FlowedDecoder({
3791
- delSp: meta.delSp
3792
- });
3793
- output.on('error', err => {
3794
- flowDecoder.emit('error', err);
3795
- });
3796
- 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 }));
3797
3860
  }
3798
3861
 
3799
3862
  // Convert non-UTF-8 charsets to UTF-8 via a streaming decoder.
3800
3863
  // ASCII and UTF-8 need no conversion. Unknown charsets are left as-is.
3801
3864
  if (meta.charset && !['ascii', 'usascii', 'utf8'].includes(meta.charset.toLowerCase().replace(/[^a-z0-9]+/g, ''))) {
3802
3865
  try {
3803
- let decoder = getDecoder(meta.charset);
3866
+ let decoder = getDecoder(meta.charset, maxBytes);
3804
3867
  // Safety listener attached first so the decoder always has at least
3805
3868
  // one 'error' listener. Prevents Node.js from throwing
3806
3869
  // ERR_UNHANDLED_ERROR if a later pipe setup step throws and leaves
@@ -3810,10 +3873,10 @@ class ImapFlow extends EventEmitter {
3810
3873
  decoder.on('error', err => {
3811
3874
  this.log.warn({ err, charset: meta.charset, cid: this.id });
3812
3875
  });
3813
- output.on('error', err => {
3814
- decoder.emit('error', err);
3815
- });
3816
- 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));
3817
3880
  // force to utf-8 for output
3818
3881
  meta.charset = 'utf-8';
3819
3882
  } catch {
@@ -3822,11 +3885,8 @@ class ImapFlow extends EventEmitter {
3822
3885
  }
3823
3886
  }
3824
3887
 
3825
- let limiter = new LimitedPassthrough({ maxBytes });
3826
- output.on('error', err => {
3827
- limiter.emit('error', err);
3828
- });
3829
- output = output.pipe(limiter);
3888
+ let limiter = pipeStage(new LimitedPassthrough({ maxBytes }));
3889
+ limiters.push(limiter);
3830
3890
 
3831
3891
  // Cleanup function
3832
3892
  const cleanup = () => {
@@ -3841,7 +3901,7 @@ class ImapFlow extends EventEmitter {
3841
3901
  output.once('close', cleanup);
3842
3902
 
3843
3903
  let writeChunk = chunk => {
3844
- if (limiter.limited || fetchAborted || stream.destroyed) {
3904
+ if (isLimited() || fetchAborted || stream.destroyed) {
3845
3905
  return true;
3846
3906
  }
3847
3907
  return stream.write(chunk);
@@ -3851,7 +3911,7 @@ class ImapFlow extends EventEmitter {
3851
3911
  // Stops when the server returns a short chunk (< chunkSize), the byte
3852
3912
  // limiter is satisfied, or the consumer destroys the output stream.
3853
3913
  let fetchAllParts = async () => {
3854
- while (hasMore && !limiter.limited && !fetchAborted) {
3914
+ while (hasMore && !isLimited() && !fetchAborted) {
3855
3915
  let { chunk } = await getNextPart();
3856
3916
  if (!chunk || fetchAborted) {
3857
3917
  break;
@@ -4012,6 +4072,12 @@ class ImapFlow extends EventEmitter {
4012
4072
 
4013
4073
  for (let [part, content] of response.bodyParts) {
4014
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
+ }
4015
4081
  if (keyParts.length === 1) {
4016
4082
  // content
4017
4083
  let key = keyParts[0];
@@ -4080,7 +4146,11 @@ class ImapFlow extends EventEmitter {
4080
4146
  }
4081
4147
 
4082
4148
  for (let part of Object.keys(data)) {
4083
- 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;
4084
4154
 
4085
4155
  // parts that arrived via FETCH BINARY (response.binaryParts) are already
4086
4156
  // decoded by the server - decoding again would corrupt the data
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;