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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/lib/commands/append.js +26 -4
- package/lib/commands/compress.js +29 -18
- package/lib/commands/copyuid-parser.js +4 -2
- package/lib/commands/expunge.js +5 -2
- package/lib/commands/fetch.js +9 -3
- package/lib/commands/list.js +18 -38
- package/lib/commands/namespace.js +2 -2
- package/lib/commands/quota.js +10 -2
- package/lib/commands/search.js +54 -14
- package/lib/commands/select.js +81 -70
- package/lib/commands/status-fields.js +68 -0
- package/lib/commands/status.js +23 -61
- package/lib/handler/imap-compiler.js +91 -60
- package/lib/handler/imap-parser.js +7 -0
- package/lib/handler/imap-stream.js +78 -12
- package/lib/handler/limits.js +16 -4
- package/lib/imap-flow.d.ts +22 -2
- package/lib/imap-flow.js +209 -94
- package/lib/jp-decoder.js +30 -5
- package/lib/limited-passthrough.js +19 -1
- package/lib/search-compiler.js +24 -16
- package/lib/tools.js +190 -39
- package/package.json +4 -4
- package/test/commands-branches-test.js +4 -0
- package/test/commands-integration-test.js +780 -5
- package/test/copyuid-parser-test.js +20 -0
- package/test/idle-polling-test.js +81 -0
- package/test/imap-compiler-test.js +74 -4
- package/test/imap-flow-coverage-test.js +4 -2
- package/test/imap-flow-fetch-download-test.js +26 -0
- package/test/imap-flow-internals-test.js +134 -0
- package/test/imap-flow-methods-test.js +92 -0
- package/test/imap-flow-secure-test.js +133 -116
- package/test/imap-flow-server-test.js +126 -0
- package/test/imap-parser-test.js +25 -0
- package/test/imap-stream-edge-cases-test.js +163 -3
- package/test/integration/rev2-live-test.js +30 -0
- package/test/jp-decoder-test.js +57 -0
- package/test/limited-passthrough-test.js +24 -0
- package/test/parser-limits-test.js +18 -0
- package/test/reliability-improvements-test.js +3 -3
- package/test/search-compiler-test.js +90 -3
- package/test/timer-policy-test.js +27 -1
- package/test/tools-test.js +151 -2
package/lib/handler/limits.js
CHANGED
|
@@ -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")
|
|
18
|
-
* not silently swallowed the way `value || DEFAULT` would
|
|
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 };
|
package/lib/imap-flow.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
}
|
|
654
|
-
this.currentRequest = this.requestQueue.shift();
|
|
667
|
+
while (!this.currentRequest && this.requestQueue.length) {
|
|
668
|
+
this.currentRequest = this.requestQueue.shift();
|
|
655
669
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
852
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1927
|
+
if (!untagged) {
|
|
1853
1928
|
return;
|
|
1854
1929
|
}
|
|
1855
1930
|
|
|
1856
|
-
|
|
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
|
|
1960
|
+
if (!untagged) {
|
|
1879
1961
|
return;
|
|
1880
1962
|
}
|
|
1881
1963
|
|
|
1882
|
-
|
|
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(
|
|
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
|
|
2279
|
-
//
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
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
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
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
|
-
|
|
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 (
|
|
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 && !
|
|
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
|
-
|
|
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
|