imapflow 1.4.9 → 1.6.0
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/.github/workflows/test.yml +20 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +22 -0
- package/CLAUDE.md +3 -5
- package/lib/commands/fetch.js +18 -14
- package/lib/commands/idle.js +197 -104
- package/lib/commands/list.js +19 -8
- package/lib/commands/quota.js +3 -0
- package/lib/commands/select.js +5 -0
- package/lib/commands/status.js +10 -1
- package/lib/connection-deadline.js +98 -0
- package/lib/handler/imap-compiler.js +20 -14
- package/lib/handler/imap-stream.js +141 -50
- package/lib/handler/limits.js +43 -0
- package/lib/handler/token-parser.js +38 -1
- package/lib/imap-flow.d.ts +47 -5
- package/lib/imap-flow.js +594 -283
- package/lib/proxy-connection.js +393 -98
- package/lib/special-use.js +660 -51
- package/lib/tools.js +52 -3
- package/package.json +2 -2
- package/test/commands-branches-test.js +17 -1
- package/test/commands-integration-test.js +353 -2
- package/test/connection-edge-cases-test.js +4 -40
- package/test/fixtures/fake-timers.js +115 -0
- package/test/handler-branches-test.js +4 -28
- package/test/idle-polling-test.js +349 -0
- package/test/imap-compiler-test.js +85 -0
- package/test/imap-flow-compress-test.js +12 -0
- package/test/imap-flow-coverage-test.js +3 -3
- package/test/imap-flow-fetch-download-test.js +56 -0
- package/test/imap-flow-internals-test.js +23 -0
- package/test/imap-flow-proxy-paths-test.js +151 -0
- package/test/imap-flow-secure-test.js +182 -9
- package/test/imap-flow-server-test.js +229 -0
- package/test/imap-parser-test.js +112 -1
- package/test/imap-stream-test.js +46 -0
- package/test/integration/README.md +17 -5
- package/test/integration/rev2-live-test.js +125 -0
- package/test/integration/run-rev2-tests.sh +14 -0
- package/test/parser-limits-test.js +274 -0
- package/test/proxy-connection-test.js +553 -442
- package/test/reliability-improvements-test.js +87 -0
- package/test/search-compiler-test.js +17 -0
- package/test/special-use-test.js +337 -0
- package/test/tag-correlation-test.js +333 -0
- package/test/timer-policy-test.js +214 -0
- package/test/tools-test.js +42 -4
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Default upper bound for establishing a usable transport, including DNS and proxy negotiation.
|
|
4
|
+
const CONNECT_TIMEOUT = 90 * 1000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One deadline for an entire connection attempt.
|
|
8
|
+
*
|
|
9
|
+
* DNS resolution, proxy negotiation and the transport handshake all draw from the same budget, so
|
|
10
|
+
* a phase that stalls cannot extend the documented `connectionTimeout`. Every expiry - whether it
|
|
11
|
+
* comes from this deadline or is normalized from a dependency - is reported with the same
|
|
12
|
+
* `CONNECT_TIMEOUT` error shape, so callers do not need to know which phase was blocked.
|
|
13
|
+
*/
|
|
14
|
+
class ConnectionDeadline {
|
|
15
|
+
/**
|
|
16
|
+
* @param {Number} [timeout] Configured connection timeout in milliseconds. Normalized once
|
|
17
|
+
* here; 0 and any other falsy or invalid value fall back to the 90 second default.
|
|
18
|
+
*/
|
|
19
|
+
constructor(timeout) {
|
|
20
|
+
this.timeout = Number(timeout) || CONNECT_TIMEOUT;
|
|
21
|
+
this.startedAt = Date.now();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @returns {Number} Milliseconds left in the budget, never negative.
|
|
26
|
+
*/
|
|
27
|
+
remaining() {
|
|
28
|
+
return Math.max(0, this.timeout - (Date.now() - this.startedAt));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @returns {Error} The shared `CONNECT_TIMEOUT` error.
|
|
33
|
+
*/
|
|
34
|
+
error() {
|
|
35
|
+
let err = new Error('Failed to establish connection in required time');
|
|
36
|
+
err.code = 'CONNECT_TIMEOUT';
|
|
37
|
+
err.details = { connectionTimeout: this.timeout };
|
|
38
|
+
return err;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Maps a dependency's own expiry onto the shared `CONNECT_TIMEOUT` shape, so callers see one
|
|
43
|
+
* timeout error whichever layer noticed first. The original error is kept as `_err`. Anything
|
|
44
|
+
* that is not a timeout is returned unchanged.
|
|
45
|
+
*
|
|
46
|
+
* @param {Error} err Error raised by a dependency during a connection phase.
|
|
47
|
+
* @returns {Error} Either the normalized timeout error or the original error.
|
|
48
|
+
*/
|
|
49
|
+
normalize(err) {
|
|
50
|
+
if (!err || err.code === 'CONNECT_TIMEOUT') {
|
|
51
|
+
return err;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The `socks` client reports its own expiry as "Proxy connection timed out"
|
|
55
|
+
if (err.code !== 'ETIMEDOUT' && !/timed out/i.test(err.message || '')) {
|
|
56
|
+
return err;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let normalized = this.error();
|
|
60
|
+
normalized._err = err;
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Throws before a phase is started if the budget is already used up, so no work is begun
|
|
66
|
+
* that could only ever time out.
|
|
67
|
+
*/
|
|
68
|
+
check() {
|
|
69
|
+
if (!this.remaining()) {
|
|
70
|
+
throw this.error();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Races a phase against the remaining budget. The timer is always cleared, so a completed
|
|
76
|
+
* phase never leaves a pending timer behind.
|
|
77
|
+
*
|
|
78
|
+
* @param {Promise} promise Phase to run under the deadline.
|
|
79
|
+
* @returns {Promise<*>} Resolves with the phase result, rejects with `CONNECT_TIMEOUT`.
|
|
80
|
+
*/
|
|
81
|
+
async race(promise) {
|
|
82
|
+
this.check();
|
|
83
|
+
|
|
84
|
+
let timer = null;
|
|
85
|
+
try {
|
|
86
|
+
return await Promise.race([
|
|
87
|
+
promise,
|
|
88
|
+
new Promise((resolve, reject) => {
|
|
89
|
+
timer = setTimeout(() => reject(this.error()), this.remaining());
|
|
90
|
+
})
|
|
91
|
+
]);
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { ConnectionDeadline, CONNECT_TIMEOUT };
|
|
@@ -65,11 +65,14 @@ module.exports = async (response, options) => {
|
|
|
65
65
|
lastRespByte = String.fromCharCode(lastRespByte);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
// Add a space separator
|
|
69
|
-
// - The previous token was a LITERAL
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// -
|
|
68
|
+
// Add a space separator when:
|
|
69
|
+
// - The previous token was a LITERAL. Literal data ends exactly at its declared length, so
|
|
70
|
+
// a following token always needs an explicit separator, even though the last written byte
|
|
71
|
+
// is arbitrary literal content.
|
|
72
|
+
// - Otherwise: there is something written already (resp is not empty) and the last byte is
|
|
73
|
+
// not an opening delimiter ('(', '<' or '['), which suppresses the space.
|
|
74
|
+
// A sub-array element in a consecutive-list context never gets one (no space between
|
|
75
|
+
// adjacent lists).
|
|
73
76
|
if (lastType === 'LITERAL' || (!['(', '<', '['].includes(lastRespByte) && resp.length)) {
|
|
74
77
|
if (!options.subArray) {
|
|
75
78
|
resp.push(formatRespEntry(' '));
|
|
@@ -130,15 +133,18 @@ module.exports = async (response, options) => {
|
|
|
130
133
|
if (isLogging) {
|
|
131
134
|
resp.push(formatRespEntry('"(* ' + node.value.length + 'B literal *)"'));
|
|
132
135
|
} else {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
136
|
+
// The literal size marker counts octets - string values are written as
|
|
137
|
+
// UTF-8, so their UTF-16 .length would undercount multi-byte characters
|
|
138
|
+
let literalLength = !node.value ? 0 : Buffer.isBuffer(node.value) ? node.value.length : Buffer.byteLength(node.value.toString());
|
|
139
|
+
|
|
140
|
+
// Append '+' to the size marker only when the extension actually permits a
|
|
141
|
+
// non-synchronizing literal of this size (RFC 7888): LITERAL+ always,
|
|
142
|
+
// LITERAL- only up to 4096 bytes
|
|
143
|
+
let usePlus = literalPlus || (literalMinus && literalLength <= 4096);
|
|
144
|
+
// canAppend: whether the literal data can be sent in the same buffer segment -
|
|
145
|
+
// non-synchronizing literals always, and everything in single-buffer mode
|
|
146
|
+
// (asArray false), which has no continuation flow
|
|
147
|
+
let canAppend = !asArray || usePlus;
|
|
142
148
|
|
|
143
149
|
// Emit the literal header: optional '~' prefix for literal8, then {size[+]}\r\n
|
|
144
150
|
resp.push(formatRespEntry(`${node.isLiteral8 ? '~' : ''}{${literalLength}${usePlus ? '+' : ''}}\r\n`));
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const Transform = require('stream').Transform;
|
|
4
4
|
const logger = require('../logger');
|
|
5
|
+
const { MAX_LITERAL_SIZE, MAX_LINE_SIZE, normalizeLimit, createLiteralTooLargeError } = require('./limits');
|
|
5
6
|
|
|
6
7
|
const LINE = 0x01;
|
|
7
8
|
const LITERAL = 0x02;
|
|
@@ -13,14 +14,6 @@ const NUM_9 = 0x39;
|
|
|
13
14
|
const CURLY_OPEN = 0x7b;
|
|
14
15
|
const CURLY_CLOSE = 0x7d;
|
|
15
16
|
|
|
16
|
-
// Maximum allowed literal size: 1GB (1073741824 bytes)
|
|
17
|
-
const MAX_LITERAL_SIZE = 1024 * 1024 * 1024;
|
|
18
|
-
|
|
19
|
-
// Default maximum length of a single line (a response without a literal). Matches the literal cap:
|
|
20
|
-
// large literal-free responses (e.g. big SEARCH/LIST results) are legitimate, so this bound exists
|
|
21
|
-
// only to stop a server that never sends a line terminator, not to constrain normal traffic.
|
|
22
|
-
const MAX_LINE_SIZE = MAX_LITERAL_SIZE;
|
|
23
|
-
|
|
24
17
|
/**
|
|
25
18
|
* A Transform stream that parses raw IMAP protocol data from a socket into structured
|
|
26
19
|
* command/response objects. Reads binary input, splits it into lines delimited by LF,
|
|
@@ -42,10 +35,15 @@ class ImapStream extends Transform {
|
|
|
42
35
|
* @param {number} [options.maxLineLength] - Maximum allowed length (in bytes) of a single
|
|
43
36
|
* line (a response without a literal). Defaults to MAX_LITERAL_SIZE (1GB). Guards against a
|
|
44
37
|
* malicious or broken server that never sends a line terminator, which would otherwise grow
|
|
45
|
-
* the internal line buffer without bound.
|
|
38
|
+
* the internal line buffer without bound. The line terminator counts toward the limit, and a
|
|
39
|
+
* line exactly at the limit is accepted. Exceeding it is terminal: the stream is destroyed
|
|
40
|
+
* with a `LineTooLarge` error and no further input is parsed.
|
|
46
41
|
* @param {number} [options.maxLiteralSize] - Maximum allowed size (in bytes) of a single
|
|
47
42
|
* literal block. Defaults to MAX_LITERAL_SIZE (1GB). Lower it to bound peak memory
|
|
48
|
-
* allocation against a malicious or broken server announcing an oversized literal.
|
|
43
|
+
* allocation against a malicious or broken server announcing an oversized literal. A literal
|
|
44
|
+
* exactly at the limit is accepted. Exceeding it is terminal: the stream is destroyed with a
|
|
45
|
+
* `LiteralTooLarge` error, the marker line is not emitted, and no byte of the rejected
|
|
46
|
+
* literal body is parsed as protocol.
|
|
49
47
|
*/
|
|
50
48
|
constructor(options) {
|
|
51
49
|
super({
|
|
@@ -68,16 +66,12 @@ class ImapStream extends Transform {
|
|
|
68
66
|
this.readBytesCounter = 0;
|
|
69
67
|
|
|
70
68
|
// Maximum length of a single line (response without a literal). Bounds the line buffer
|
|
71
|
-
// so a server that never sends a line terminator cannot exhaust memory.
|
|
72
|
-
|
|
73
|
-
// explicit 0 is not silently swallowed into the 1GB default the way `|| MAX_LINE_SIZE` was.
|
|
74
|
-
this.maxLineLength = Number.isInteger(this.options.maxLineLength) && this.options.maxLineLength >= 0 ? this.options.maxLineLength : MAX_LINE_SIZE;
|
|
69
|
+
// so a server that never sends a line terminator cannot exhaust memory.
|
|
70
|
+
this.maxLineLength = normalizeLimit(this.options.maxLineLength, MAX_LINE_SIZE);
|
|
75
71
|
|
|
76
72
|
// Maximum size of a single literal block. Bounds peak memory allocation so a server
|
|
77
|
-
// announcing an oversized literal cannot exhaust memory.
|
|
78
|
-
|
|
79
|
-
this.maxLiteralSize =
|
|
80
|
-
Number.isInteger(this.options.maxLiteralSize) && this.options.maxLiteralSize >= 0 ? this.options.maxLiteralSize : MAX_LITERAL_SIZE;
|
|
73
|
+
// announcing an oversized literal cannot exhaust memory.
|
|
74
|
+
this.maxLiteralSize = normalizeLimit(this.options.maxLiteralSize, MAX_LITERAL_SIZE);
|
|
81
75
|
|
|
82
76
|
this.state = LINE;
|
|
83
77
|
this.literalWaiting = 0;
|
|
@@ -92,6 +86,52 @@ class ImapStream extends Transform {
|
|
|
92
86
|
|
|
93
87
|
this.processingInput = false;
|
|
94
88
|
this.inputQueue = []; // unprocessed input chunks
|
|
89
|
+
this.activeInput = null; // chunk currently being processed (already shifted off inputQueue)
|
|
90
|
+
|
|
91
|
+
// Resolver of the in-flight push() backpressure promise, so destruction can settle it
|
|
92
|
+
// instead of leaving processInput() awaiting a consumer that will never read again.
|
|
93
|
+
this.pendingPush = null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Terminally fails the stream. Used for response limit violations and for any other
|
|
98
|
+
* error raised while parsing.
|
|
99
|
+
*
|
|
100
|
+
* The stream is destroyed instead of only emitting `error`: emitting on a Transform leaves
|
|
101
|
+
* it running, so the caller would keep scanning the rejected payload and could emit it as
|
|
102
|
+
* protocol (an oversized literal body contains attacker-chosen CRLF delimited lines).
|
|
103
|
+
* Destroying stops all parsing, drops the offending line, and releases every queued
|
|
104
|
+
* transform callback exactly once (see `_destroy()`).
|
|
105
|
+
*
|
|
106
|
+
* `destroyed` (set synchronously by destroy()) is the single liveness flag every other path
|
|
107
|
+
* checks, so a second failure attempt is a no-op and nothing is parsed after the first.
|
|
108
|
+
*
|
|
109
|
+
* @param {Error} err - The error to destroy the stream with.
|
|
110
|
+
* @returns {boolean} Always false, so callers can `return this.failStream(err)`.
|
|
111
|
+
*/
|
|
112
|
+
failStream(err) {
|
|
113
|
+
if (this.destroyed) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
this.destroy(err);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Releases a queued input chunk's transform callback exactly once, signalling the writable
|
|
122
|
+
* side that the chunk was consumed. The mirror image of ImapFlow's releaseStreamData(), which
|
|
123
|
+
* releases the readable items this stream pushes downstream.
|
|
124
|
+
*
|
|
125
|
+
* @param {Object} item - Queue entry holding the chunk and its transform callback.
|
|
126
|
+
*/
|
|
127
|
+
releaseInput(item) {
|
|
128
|
+
if (!item || item.released) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
item.released = true;
|
|
132
|
+
if (typeof item.next === 'function') {
|
|
133
|
+
item.next();
|
|
134
|
+
}
|
|
95
135
|
}
|
|
96
136
|
|
|
97
137
|
/**
|
|
@@ -137,12 +177,7 @@ class ImapStream extends Transform {
|
|
|
137
177
|
const literalSize = Number(Buffer.from(numBytes).toString());
|
|
138
178
|
|
|
139
179
|
if (literalSize > this.maxLiteralSize) {
|
|
140
|
-
|
|
141
|
-
err.code = 'LiteralTooLarge';
|
|
142
|
-
err.literalSize = literalSize;
|
|
143
|
-
err.maxSize = this.maxLiteralSize;
|
|
144
|
-
this.emit('error', err);
|
|
145
|
-
return false;
|
|
180
|
+
return this.failStream(createLiteralTooLargeError(literalSize, this.maxLiteralSize));
|
|
146
181
|
}
|
|
147
182
|
|
|
148
183
|
this.state = LITERAL;
|
|
@@ -154,6 +189,25 @@ class ImapStream extends Transform {
|
|
|
154
189
|
return false;
|
|
155
190
|
}
|
|
156
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Enforces the configured line-length cap for a projected line length. The projected length
|
|
194
|
+
* covers every byte of the line, the line terminator included, whether or not the line was
|
|
195
|
+
* split across input chunks. A line exactly at the limit is accepted.
|
|
196
|
+
*
|
|
197
|
+
* @param {number} lineLength - Total length the current line would reach.
|
|
198
|
+
* @returns {boolean} True if the line is within the limit, false if the stream was failed.
|
|
199
|
+
*/
|
|
200
|
+
checkLineLength(lineLength) {
|
|
201
|
+
if (lineLength <= this.maxLineLength) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
const err = new Error(`Line length ${lineLength} exceeds maximum allowed size of ${this.maxLineLength} bytes`);
|
|
205
|
+
err.code = 'LineTooLarge';
|
|
206
|
+
err.lineLength = lineLength;
|
|
207
|
+
err.maxSize = this.maxLineLength;
|
|
208
|
+
return this.failStream(err);
|
|
209
|
+
}
|
|
210
|
+
|
|
157
211
|
/**
|
|
158
212
|
* Processes a single input chunk of raw data. In LINE state, scans for LF-terminated
|
|
159
213
|
* lines and checks for literal markers. In LITERAL state, collects the expected number
|
|
@@ -166,7 +220,7 @@ class ImapStream extends Transform {
|
|
|
166
220
|
*/
|
|
167
221
|
async processInputChunk(chunk, startPos) {
|
|
168
222
|
startPos = startPos || 0;
|
|
169
|
-
if (startPos >= chunk.length) {
|
|
223
|
+
if (this.destroyed || startPos >= chunk.length) {
|
|
170
224
|
return;
|
|
171
225
|
}
|
|
172
226
|
|
|
@@ -175,19 +229,34 @@ class ImapStream extends Transform {
|
|
|
175
229
|
let lineStart = startPos;
|
|
176
230
|
for (let i = startPos, len = chunk.length; i < len; i++) {
|
|
177
231
|
if (chunk[i] === LF) {
|
|
178
|
-
// line end found
|
|
179
|
-
|
|
232
|
+
// line end found. Measure the completed line (terminator included) before
|
|
233
|
+
// concatenating or emitting anything, so the cap does not depend on where
|
|
234
|
+
// TCP chunk boundaries happen to fall.
|
|
235
|
+
let segment = chunk.slice(lineStart, i + 1);
|
|
236
|
+
if (!this.checkLineLength(this.lineBytes + segment.length)) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.lineBuffer.push(segment);
|
|
180
241
|
lineStart = i + 1;
|
|
181
242
|
|
|
182
|
-
let line = Buffer.concat(this.lineBuffer);
|
|
243
|
+
let line = this.lineBuffer.length === 1 ? this.lineBuffer[0] : Buffer.concat(this.lineBuffer);
|
|
183
244
|
|
|
184
|
-
this.inputBuffer.push(line);
|
|
185
245
|
this.lineBuffer = [];
|
|
186
246
|
this.lineBytes = 0;
|
|
187
247
|
|
|
188
|
-
// try to detect if this is a literal start
|
|
189
|
-
|
|
190
|
-
|
|
248
|
+
// try to detect if this is a literal start. An oversized literal fails the
|
|
249
|
+
// stream, so the marker line must not be buffered before the check - it
|
|
250
|
+
// would otherwise be emitted as part of the rejected command.
|
|
251
|
+
let isLiteralMarker = this.checkLiteralMarker(line);
|
|
252
|
+
if (this.destroyed) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this.inputBuffer.push(line);
|
|
257
|
+
|
|
258
|
+
if (isLiteralMarker) {
|
|
259
|
+
// switch into literal mode and start over
|
|
191
260
|
return await this.processInputChunk(chunk, lineStart);
|
|
192
261
|
}
|
|
193
262
|
|
|
@@ -215,27 +284,29 @@ class ImapStream extends Transform {
|
|
|
215
284
|
// boundaries can read it from the pushed object.
|
|
216
285
|
let trailingAfterLine = lineStart < chunk.length || this.inputQueue.length > 0;
|
|
217
286
|
await new Promise(resolve => {
|
|
287
|
+
// Tracked so destruction can settle the wait instead of leaving
|
|
288
|
+
// this loop (and the chunk's transform callback) pending forever
|
|
289
|
+
// when the consumer stops reading.
|
|
290
|
+
this.pendingPush = resolve;
|
|
218
291
|
this.push({ payload, literals, next: resolve, trailingAfterLine });
|
|
219
292
|
});
|
|
293
|
+
this.pendingPush = null;
|
|
294
|
+
|
|
295
|
+
if (this.destroyed) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
220
298
|
}
|
|
221
299
|
}
|
|
222
300
|
}
|
|
223
301
|
}
|
|
224
302
|
if (lineStart < chunk.length) {
|
|
225
303
|
// No line terminator was found in the remaining bytes; carry the tail over to
|
|
226
|
-
// the next chunk
|
|
227
|
-
// path that grows the line buffer across chunks.
|
|
304
|
+
// the next chunk after measuring the line it belongs to.
|
|
228
305
|
let tail = chunk.slice(lineStart);
|
|
229
|
-
|
|
230
|
-
if (lineLength > this.maxLineLength) {
|
|
231
|
-
const err = new Error(`Line length ${lineLength} exceeds maximum allowed size of ${this.maxLineLength} bytes`);
|
|
232
|
-
err.code = 'LineTooLarge';
|
|
233
|
-
err.lineLength = lineLength;
|
|
234
|
-
err.maxSize = this.maxLineLength;
|
|
235
|
-
this.emit('error', err);
|
|
306
|
+
if (!this.checkLineLength(this.lineBytes + tail.length)) {
|
|
236
307
|
return;
|
|
237
308
|
}
|
|
238
|
-
this.lineBytes
|
|
309
|
+
this.lineBytes += tail.length;
|
|
239
310
|
this.lineBuffer.push(tail);
|
|
240
311
|
}
|
|
241
312
|
break;
|
|
@@ -273,10 +344,12 @@ class ImapStream extends Transform {
|
|
|
273
344
|
async processInput() {
|
|
274
345
|
let data;
|
|
275
346
|
let processedCount = 0;
|
|
276
|
-
while ((data = this.inputQueue.shift())) {
|
|
347
|
+
while (!this.destroyed && (data = this.inputQueue.shift())) {
|
|
348
|
+
this.activeInput = data;
|
|
277
349
|
await this.processInputChunk(data.chunk);
|
|
350
|
+
this.activeInput = null;
|
|
278
351
|
// mark chunk as processed
|
|
279
|
-
|
|
352
|
+
this.releaseInput(data);
|
|
280
353
|
|
|
281
354
|
// Yield to event loop every 10 chunks to prevent CPU blocking
|
|
282
355
|
processedCount++;
|
|
@@ -317,6 +390,12 @@ class ImapStream extends Transform {
|
|
|
317
390
|
});
|
|
318
391
|
}
|
|
319
392
|
|
|
393
|
+
// A terminal parser failure must not accept any more protocol input, even if the
|
|
394
|
+
// transport delivers a chunk that was already in flight.
|
|
395
|
+
if (this.destroyed) {
|
|
396
|
+
return next();
|
|
397
|
+
}
|
|
398
|
+
|
|
320
399
|
// Queue the chunk for async processing. The 'next' callback serves as
|
|
321
400
|
// backpressure: it is called only after this chunk is fully processed,
|
|
322
401
|
// which signals the writable side that more data can be accepted.
|
|
@@ -325,7 +404,7 @@ class ImapStream extends Transform {
|
|
|
325
404
|
if (!this.processingInput) {
|
|
326
405
|
this.processingInput = true;
|
|
327
406
|
this.processInput()
|
|
328
|
-
.catch(err => this.
|
|
407
|
+
.catch(err => this.failStream(err))
|
|
329
408
|
.finally(() => (this.processingInput = false));
|
|
330
409
|
}
|
|
331
410
|
}
|
|
@@ -347,18 +426,30 @@ class ImapStream extends Transform {
|
|
|
347
426
|
* @param {Function} callback - Callback to signal destruction completion.
|
|
348
427
|
*/
|
|
349
428
|
_destroy(err, callback) {
|
|
429
|
+
// Destruction is the single release point for parser-owned callbacks, so a terminal
|
|
430
|
+
// failure can never leave the writable side or the processing loop waiting.
|
|
350
431
|
this.inputBuffer = [];
|
|
351
432
|
this.lineBuffer = [];
|
|
352
433
|
this.lineBytes = 0;
|
|
353
434
|
this.literalBuffer = [];
|
|
354
435
|
this.literals = [];
|
|
355
|
-
|
|
436
|
+
|
|
437
|
+
// Settle an in-flight push() wait so processInput() can unwind
|
|
438
|
+
if (typeof this.pendingPush === 'function') {
|
|
439
|
+
const resolve = this.pendingPush;
|
|
440
|
+
this.pendingPush = null;
|
|
441
|
+
resolve();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Release the chunk currently being processed, then everything still queued.
|
|
445
|
+
// releaseInput() is idempotent, so the processing loop releasing the same chunk
|
|
446
|
+
// afterwards is a no-op.
|
|
447
|
+
this.releaseInput(this.activeInput);
|
|
448
|
+
this.activeInput = null;
|
|
356
449
|
while (this.inputQueue.length) {
|
|
357
|
-
|
|
358
|
-
if (typeof item.next === 'function') {
|
|
359
|
-
item.next();
|
|
360
|
-
}
|
|
450
|
+
this.releaseInput(this.inputQueue.shift());
|
|
361
451
|
}
|
|
452
|
+
|
|
362
453
|
callback(err);
|
|
363
454
|
}
|
|
364
455
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared response-size limits for the IMAP parser. Kept in one place so the streaming parser
|
|
4
|
+
// (ImapStream) and the standalone token parser cannot drift apart, and so the documented
|
|
5
|
+
// defaults in imap-flow.d.ts describe both paths.
|
|
6
|
+
|
|
7
|
+
// Maximum allowed literal size: 1GB (1073741824 bytes)
|
|
8
|
+
const MAX_LITERAL_SIZE = 1024 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
// Default maximum length of a single line (a response without a literal). Matches the literal cap:
|
|
11
|
+
// large literal-free responses (e.g. big SEARCH/LIST results) are legitimate, so this bound exists
|
|
12
|
+
// only to stop a server that never sends a line terminator, not to constrain normal traffic.
|
|
13
|
+
const MAX_LINE_SIZE = MAX_LITERAL_SIZE;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 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.
|
|
19
|
+
*
|
|
20
|
+
* @param {*} value - The configured value.
|
|
21
|
+
* @param {number} defaultValue - Fallback when the value is not a usable limit.
|
|
22
|
+
* @returns {number} The normalized limit.
|
|
23
|
+
*/
|
|
24
|
+
const normalizeLimit = (value, defaultValue) => (Number.isInteger(value) && value >= 0 ? value : defaultValue);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds the `LiteralTooLarge` error. One shape for every place a literal is refused, so callers
|
|
28
|
+
* can rely on `code`, `literalSize` and `maxSize` regardless of which parser rejected it.
|
|
29
|
+
*
|
|
30
|
+
* @param {number} literalSize - The declared literal size.
|
|
31
|
+
* @param {number} maxSize - The bound that was exceeded.
|
|
32
|
+
* @param {string} [reason] - What the bound was, when it is not the configured maximum.
|
|
33
|
+
* @returns {Error} The error to emit or throw.
|
|
34
|
+
*/
|
|
35
|
+
const createLiteralTooLargeError = (literalSize, maxSize, reason) => {
|
|
36
|
+
const err = new Error(`Literal size ${literalSize} exceeds ${reason || `maximum allowed size of ${maxSize} bytes`}`);
|
|
37
|
+
err.code = 'LiteralTooLarge';
|
|
38
|
+
err.literalSize = literalSize;
|
|
39
|
+
err.maxSize = maxSize;
|
|
40
|
+
return err;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
module.exports = { MAX_LITERAL_SIZE, MAX_LINE_SIZE, normalizeLimit, createLiteralTooLargeError };
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
5
|
const imapFormalSyntax = require('./imap-formal-syntax');
|
|
6
|
+
const { MAX_LITERAL_SIZE, normalizeLimit, createLiteralTooLargeError } = require('./limits');
|
|
6
7
|
|
|
7
8
|
const STATE_ATOM = 0x001;
|
|
8
9
|
const STATE_LITERAL = 0x002;
|
|
@@ -34,12 +35,18 @@ class TokenParser {
|
|
|
34
35
|
* @param {Object} [options] - Parser options.
|
|
35
36
|
* @param {boolean} [options.literalPlus] - Whether the LITERAL+ extension is in use.
|
|
36
37
|
* @param {Array<Buffer>} [options.literals] - Pre-parsed literal values from the input stream.
|
|
38
|
+
* @param {number} [options.maxLiteralSize] - Maximum size (in bytes) of a literal parsed inline
|
|
39
|
+
* from the input, i.e. when no pre-parsed literal buffers were supplied. Defaults to 1GB.
|
|
37
40
|
*/
|
|
38
41
|
constructor(parent, startPos, str, options) {
|
|
39
42
|
this.str = (str || '').toString();
|
|
40
43
|
this.options = options || {};
|
|
41
44
|
this.parent = parent;
|
|
42
45
|
|
|
46
|
+
// Same normalization and default as the streaming parser, so a direct user of this parser
|
|
47
|
+
// gets the same bound (an explicit 0 means "reject any non-empty inline literal").
|
|
48
|
+
this.maxLiteralSize = normalizeLimit(this.options.maxLiteralSize, MAX_LITERAL_SIZE);
|
|
49
|
+
|
|
43
50
|
this.tree = this.currentNode = this.createNode();
|
|
44
51
|
this.pos = startPos || 0;
|
|
45
52
|
|
|
@@ -580,6 +587,13 @@ class TokenParser {
|
|
|
580
587
|
if (!this.currentNode.literalLength) {
|
|
581
588
|
// special case where literal content length is 0
|
|
582
589
|
// close the node right away, do not wait for additional input
|
|
590
|
+
if (this.options.literals && this.options.literals.length) {
|
|
591
|
+
// ImapStream queues a Buffer for every literal marker it
|
|
592
|
+
// extracts, including {0} - consume the queue entry so
|
|
593
|
+
// subsequent literals in the same response stay aligned
|
|
594
|
+
// with their markers instead of shifting by one
|
|
595
|
+
this.currentNode.value = this.options.literals.shift();
|
|
596
|
+
}
|
|
583
597
|
this.currentNode.endPos = this.pos + i;
|
|
584
598
|
this.currentNode.isClosed = true;
|
|
585
599
|
this.currentNode = this.currentNode.parentNode;
|
|
@@ -604,8 +618,31 @@ class TokenParser {
|
|
|
604
618
|
this.state = STATE_NORMAL;
|
|
605
619
|
checkSP();
|
|
606
620
|
} else {
|
|
621
|
+
// No pre-parsed literal buffers were supplied, so the literal is read
|
|
622
|
+
// inline from this input and its declared length decides an
|
|
623
|
+
// allocation. ImapStream always supplies buffers (and has already
|
|
624
|
+
// enforced its own cap), so this branch means the parser is being used
|
|
625
|
+
// directly and the declared length is untrusted: bound it before
|
|
626
|
+
// allocating anything.
|
|
627
|
+
// Two bounds apply: the configured maximum, and the bytes actually
|
|
628
|
+
// available here - an inline literal has to be present in the input
|
|
629
|
+
// being parsed, so a longer declaration can never be satisfied and
|
|
630
|
+
// must not reserve memory for itself.
|
|
631
|
+
let available = this.str.length - i - 1;
|
|
632
|
+
let literalLength = this.currentNode.literalLength;
|
|
633
|
+
if (literalLength > this.maxLiteralSize || literalLength > available) {
|
|
634
|
+
let overMax = literalLength > this.maxLiteralSize;
|
|
635
|
+
let error = createLiteralTooLargeError(
|
|
636
|
+
literalLength,
|
|
637
|
+
overMax ? this.maxLiteralSize : available,
|
|
638
|
+
overMax ? null : `the ${available} bytes available in the input`
|
|
639
|
+
);
|
|
640
|
+
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
|
|
607
644
|
this.currentNode.started = true;
|
|
608
|
-
// Allocate expected size buffer.
|
|
645
|
+
// Allocate expected size buffer.
|
|
609
646
|
// Maybe should use allocUnsafe instead?
|
|
610
647
|
this.currentNode.chBuffer = Buffer.alloc(this.currentNode.literalLength);
|
|
611
648
|
this.currentNode.chPos = 0;
|