imapflow 1.6.4 → 1.6.5
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 +7 -0
- package/lib/commands/compress.js +29 -18
- package/lib/handler/imap-compiler.js +91 -60
- package/lib/handler/imap-parser.js +7 -0
- package/lib/handler/imap-stream.js +22 -10
- package/lib/imap-flow.js +78 -33
- package/lib/search-compiler.js +24 -16
- package/package.json +1 -1
- package/test/commands-integration-test.js +97 -5
- package/test/idle-polling-test.js +81 -0
- package/test/imap-compiler-test.js +74 -4
- package/test/imap-flow-internals-test.js +134 -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 +27 -3
- package/test/integration/rev2-live-test.js +30 -0
- package/test/search-compiler-test.js +90 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.6.5](https://github.com/postalsys/imapflow/compare/v1.6.4...v1.6.5) (2026-07-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* resolve regressions from the rev2 hardening commit and harden further ([0739df9](https://github.com/postalsys/imapflow/commit/0739df93709d2c7d572e977c2b37b6d219e69e30))
|
|
9
|
+
|
|
3
10
|
## [1.6.4](https://github.com/postalsys/imapflow/compare/v1.6.3...v1.6.4) (2026-07-29)
|
|
4
11
|
|
|
5
12
|
|
package/lib/commands/compress.js
CHANGED
|
@@ -19,26 +19,37 @@ module.exports = async connection => {
|
|
|
19
19
|
let response;
|
|
20
20
|
try {
|
|
21
21
|
response = await connection.exec('COMPRESS', [{ type: 'ATOM', value: 'DEFLATE' }]);
|
|
22
|
-
// Everything after the tagged OK is already deflate-framed (RFC 4978 section 4).
|
|
23
|
-
// The socket stays piped into the plaintext parser until this call returns, so
|
|
24
|
-
// bytes that arrived in the same chunk as the OK have been consumed as cleartext
|
|
25
|
-
// and are missing from the head of the deflate stream - the inflater would then
|
|
26
|
-
// fail and take the connection with it. Rare (it needs the server to write again
|
|
27
|
-
// before we re-pipe), and staying uncompressed is a cheaper outcome than a dead
|
|
28
|
-
// connection, so decline the upgrade instead.
|
|
29
|
-
// This only covers the bytes the parser had already buffered when the OK was
|
|
30
|
-
// handled. Closing the remaining window, and keeping compression rather than
|
|
31
|
-
// dropping it, needs the stream to hand back its unconsumed tail on unpipe so
|
|
32
|
-
// the transport can feed it into the inflater - not something a command module
|
|
33
|
-
// can reach from here.
|
|
34
|
-
response.next();
|
|
35
|
-
if (response.hasTrailingData) {
|
|
36
|
-
connection.log.warn({ msg: 'Server sent data immediately after the COMPRESS response, skipping compression', cid: connection.id });
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
return true;
|
|
40
22
|
} catch (err) {
|
|
23
|
+
// The server declined (NO/BAD): nothing switched, staying uncompressed is safe.
|
|
41
24
|
connection.log.warn({ err, cid: connection.id });
|
|
42
25
|
return false;
|
|
43
26
|
}
|
|
27
|
+
|
|
28
|
+
// Everything after the tagged OK is already deflate-framed (RFC 4978 section 4) -
|
|
29
|
+
// the server switches at the OK, so declining the upgrade at this point is not a
|
|
30
|
+
// protocol option. The socket stays piped into the plaintext parser until the
|
|
31
|
+
// transport swaps in the inflater, so bytes that arrived in the same chunk as the
|
|
32
|
+
// OK have been consumed as cleartext and are missing from the head of the deflate
|
|
33
|
+
// stream: the session is unrecoverable in both directions. Fail it immediately
|
|
34
|
+
// (the same way STARTTLS treats post-OK trailing data) instead of letting it die
|
|
35
|
+
// slowly on garbage. Closing this window without failing needs the stream to hand
|
|
36
|
+
// back its unconsumed tail on unpipe so the transport can feed it into the
|
|
37
|
+
// inflater - not something a command module can reach from here.
|
|
38
|
+
if (response.hasTrailingData) {
|
|
39
|
+
let error = new Error('Server sent data between the COMPRESS response and the compression layer switch');
|
|
40
|
+
error.code = 'COMPRESS_TRAILING_DATA';
|
|
41
|
+
connection.log.error({ err: error, cid: connection.id });
|
|
42
|
+
// Schedule the close before releasing parser backpressure, so the buffered
|
|
43
|
+
// deflate-framed bytes cannot settle anything before teardown begins. This is
|
|
44
|
+
// why the decision lives here rather than at the connection layer the way the
|
|
45
|
+
// STARTTLS guard does (starttls.js records a flag, upgradeToSTARTTLS decides):
|
|
46
|
+
// only the command module holds the response before its backpressure release,
|
|
47
|
+
// so only it can order teardown ahead of that release.
|
|
48
|
+
connection.closeAfter();
|
|
49
|
+
response.next();
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
response.next();
|
|
54
|
+
return true;
|
|
44
55
|
};
|
|
@@ -4,11 +4,27 @@
|
|
|
4
4
|
|
|
5
5
|
const imapFormalSyntax = require('./imap-formal-syntax');
|
|
6
6
|
|
|
7
|
-
// A sequence-set as defined by the RFC 9051 grammar:
|
|
8
|
-
//
|
|
7
|
+
// A single element of a sequence-set as defined by the RFC 9051 grammar: a number
|
|
8
|
+
// or a range, where "*" stands for the largest number in use. Digit strings are not
|
|
9
9
|
// range-checked here (a server rejects "0" or an overlong number on its own); the
|
|
10
10
|
// point of the check is that nothing outside this alphabet can reach the wire.
|
|
11
|
-
const
|
|
11
|
+
const SEQ_RANGE = /^(\d+|\*)(:(\d+|\*))?$/;
|
|
12
|
+
|
|
13
|
+
// Validates a full sequence-set: comma-separated SEQ_RANGE elements, or "$"
|
|
14
|
+
// (RFC 5182 SEARCHRES), which references the previous SEARCH result and is only
|
|
15
|
+
// valid as the entire set. Split into per-element tests on purpose - a whole-set
|
|
16
|
+
// regex with an unbounded repeat group overflows the regex engine's backtrack
|
|
17
|
+
// stack with an uncoded RangeError on valid sets in the million-element range,
|
|
18
|
+
// while the per-element regex is bounded.
|
|
19
|
+
const isValidSequenceSet = value => value === '$' || value.split(',').every(part => SEQ_RANGE.test(part));
|
|
20
|
+
|
|
21
|
+
// Numeric tokens may only put the digit alphabet on the wire. Anything that does
|
|
22
|
+
// not round to a bounded non-negative integer (NaN, Infinity, negatives, unsafe
|
|
23
|
+
// magnitudes) degrades to 0 - the fallback the NaN coercion has always used.
|
|
24
|
+
const safeNumber = value => {
|
|
25
|
+
let num = Math.round(Number(value));
|
|
26
|
+
return Number.isSafeInteger(num) && num >= 0 ? num : 0;
|
|
27
|
+
};
|
|
12
28
|
|
|
13
29
|
// Characters that cannot appear in an IMAP quoted string: CR and LF terminate a
|
|
14
30
|
// command line, and NUL is outside the CHAR production entirely. A value carrying
|
|
@@ -37,33 +53,6 @@ const quoteString = value => {
|
|
|
37
53
|
return '"' + value.replace(/["\\]/g, char => '\\' + char) + '"';
|
|
38
54
|
};
|
|
39
55
|
|
|
40
|
-
/**
|
|
41
|
-
* Formats a response entry into a Buffer.
|
|
42
|
-
*
|
|
43
|
-
* @param {string|number|Buffer} entry - The value to convert to a Buffer.
|
|
44
|
-
* @param {boolean} [returnEmpty] - If true, returns null instead of an empty Buffer when the entry is not a recognized type.
|
|
45
|
-
* @returns {Buffer|null} The entry as a Buffer, or null if returnEmpty is true and the entry is not a recognized type.
|
|
46
|
-
*/
|
|
47
|
-
const formatRespEntry = (entry, returnEmpty) => {
|
|
48
|
-
if (typeof entry === 'string') {
|
|
49
|
-
return Buffer.from(entry);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (typeof entry === 'number') {
|
|
53
|
-
return Buffer.from(entry.toString());
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (Buffer.isBuffer(entry)) {
|
|
57
|
-
return entry;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (returnEmpty) {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return Buffer.alloc(0);
|
|
65
|
-
};
|
|
66
|
-
|
|
67
56
|
/**
|
|
68
57
|
* Compiles an input object into a sequence of Buffers representing an IMAP protocol response string.
|
|
69
58
|
* Handles various node types including literals, strings, atoms, sections, sequences, and nested lists.
|
|
@@ -83,7 +72,43 @@ module.exports = async (response, options) => {
|
|
|
83
72
|
let { asArray, isLogging, literalPlus, literalMinus } = options || {};
|
|
84
73
|
const respParts = [];
|
|
85
74
|
|
|
86
|
-
|
|
75
|
+
// Formats an entry (string, number or Buffer) into the Buffer that is written to
|
|
76
|
+
// the wire, and is the choke point every emission passes through: a line
|
|
77
|
+
// terminator ends an IMAP command, so no token - the tag and command name
|
|
78
|
+
// included - may put one on the wire. The literal size marker and literal data
|
|
79
|
+
// are the only emissions where CRLF is legitimate; those call sites opt out with
|
|
80
|
+
// `raw`. Never enforced when logging: re-encoding an incoming server response for
|
|
81
|
+
// the log or for error text must not throw, whatever the server sent. With
|
|
82
|
+
// `returnEmpty`, an unrecognized entry type yields null instead of an empty
|
|
83
|
+
// Buffer.
|
|
84
|
+
const emitEntry = (entry, opts) => {
|
|
85
|
+
let { returnEmpty, raw } = opts || {};
|
|
86
|
+
if (!raw && !isLogging && (typeof entry === 'string' || Buffer.isBuffer(entry)) && CRLF.test(entry.toString('latin1'))) {
|
|
87
|
+
let error = new Error('Line terminator in IMAP token');
|
|
88
|
+
error.code = 'InvalidTokenValue';
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (typeof entry === 'string') {
|
|
93
|
+
return Buffer.from(entry);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof entry === 'number') {
|
|
97
|
+
return Buffer.from(entry.toString());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (Buffer.isBuffer(entry)) {
|
|
101
|
+
return entry;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (returnEmpty) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return Buffer.alloc(0);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
let resp = [].concat(emitEntry(response.tag, { returnEmpty: true }) || []).concat(response.command ? emitEntry(' ' + response.command) : []);
|
|
87
112
|
let val;
|
|
88
113
|
let lastType;
|
|
89
114
|
|
|
@@ -108,7 +133,7 @@ module.exports = async (response, options) => {
|
|
|
108
133
|
// adjacent lists).
|
|
109
134
|
if (lastType === 'LITERAL' || (!['(', '<', '['].includes(lastRespByte) && resp.length)) {
|
|
110
135
|
if (!options.subArray) {
|
|
111
|
-
resp.push(
|
|
136
|
+
resp.push(emitEntry(' '));
|
|
112
137
|
}
|
|
113
138
|
}
|
|
114
139
|
|
|
@@ -119,7 +144,7 @@ module.exports = async (response, options) => {
|
|
|
119
144
|
|
|
120
145
|
if (Array.isArray(node)) {
|
|
121
146
|
lastType = 'LIST';
|
|
122
|
-
resp.push(
|
|
147
|
+
resp.push(emitEntry('('));
|
|
123
148
|
|
|
124
149
|
// check if we need to skip separator WS between two arrays
|
|
125
150
|
let subArray = node.length > 1 && Array.isArray(node[0]);
|
|
@@ -131,40 +156,40 @@ module.exports = async (response, options) => {
|
|
|
131
156
|
await walk(child, { subArray });
|
|
132
157
|
}
|
|
133
158
|
|
|
134
|
-
resp.push(
|
|
159
|
+
resp.push(emitEntry(')'));
|
|
135
160
|
return;
|
|
136
161
|
}
|
|
137
162
|
|
|
138
163
|
if (!node && typeof node !== 'string' && typeof node !== 'number' && !Buffer.isBuffer(node)) {
|
|
139
|
-
resp.push(
|
|
164
|
+
resp.push(emitEntry('NIL'));
|
|
140
165
|
return;
|
|
141
166
|
}
|
|
142
167
|
|
|
143
168
|
if (typeof node === 'string' || Buffer.isBuffer(node)) {
|
|
144
169
|
if (isLogging && node.length > 100) {
|
|
145
|
-
resp.push(
|
|
170
|
+
resp.push(emitEntry('"(* ' + node.length + 'B string *)"'));
|
|
146
171
|
} else {
|
|
147
|
-
resp.push(
|
|
172
|
+
resp.push(emitEntry(isLogging ? JSON.stringify(node.toString()) : quoteString(node.toString())));
|
|
148
173
|
}
|
|
149
174
|
return;
|
|
150
175
|
}
|
|
151
176
|
|
|
152
177
|
if (typeof node === 'number') {
|
|
153
|
-
resp.push(
|
|
178
|
+
resp.push(emitEntry(safeNumber(node))); // Only bounded non-negative integers allowed
|
|
154
179
|
return;
|
|
155
180
|
}
|
|
156
181
|
|
|
157
182
|
lastType = node.type;
|
|
158
183
|
|
|
159
184
|
if (isLogging && node.sensitive) {
|
|
160
|
-
resp.push(
|
|
185
|
+
resp.push(emitEntry('"(* value hidden *)"'));
|
|
161
186
|
return;
|
|
162
187
|
}
|
|
163
188
|
|
|
164
189
|
switch (node.type.toUpperCase()) {
|
|
165
190
|
case 'LITERAL':
|
|
166
191
|
if (isLogging) {
|
|
167
|
-
resp.push(
|
|
192
|
+
resp.push(emitEntry('"(* ' + node.value.length + 'B literal *)"'));
|
|
168
193
|
} else {
|
|
169
194
|
// The literal size marker counts octets - string values are written as
|
|
170
195
|
// UTF-8, so their UTF-16 .length would undercount multi-byte characters
|
|
@@ -180,29 +205,29 @@ module.exports = async (response, options) => {
|
|
|
180
205
|
let canAppend = !asArray || usePlus;
|
|
181
206
|
|
|
182
207
|
// Emit the literal header: optional '~' prefix for literal8, then {size[+]}\r\n
|
|
183
|
-
resp.push(
|
|
208
|
+
resp.push(emitEntry(`${node.isLiteral8 ? '~' : ''}{${literalLength}${usePlus ? '+' : ''}}\r\n`, { raw: true }));
|
|
184
209
|
|
|
185
210
|
if (canAppend) {
|
|
186
211
|
// Literal data follows immediately in the same buffer segment
|
|
187
212
|
if (node.value && node.value.length) {
|
|
188
|
-
resp.push(
|
|
213
|
+
resp.push(emitEntry(node.value, { raw: true }));
|
|
189
214
|
}
|
|
190
215
|
} else {
|
|
191
216
|
// For synchronizing literals in asArray mode, split output into separate
|
|
192
217
|
// parts. The caller must send each part and wait for a continuation
|
|
193
218
|
// response from the server before sending the next.
|
|
194
219
|
respParts.push(resp);
|
|
195
|
-
resp = [].concat(
|
|
220
|
+
resp = [].concat(emitEntry(node.value, { returnEmpty: true, raw: true }) || []);
|
|
196
221
|
}
|
|
197
222
|
}
|
|
198
223
|
break;
|
|
199
224
|
|
|
200
225
|
case 'STRING':
|
|
201
226
|
if (isLogging && node.value.length > 100) {
|
|
202
|
-
resp.push(
|
|
227
|
+
resp.push(emitEntry('"(* ' + node.value.length + 'B string *)"'));
|
|
203
228
|
} else {
|
|
204
229
|
val = (node.value || '').toString();
|
|
205
|
-
resp.push(
|
|
230
|
+
resp.push(emitEntry(isLogging ? JSON.stringify(val) : quoteString(val)));
|
|
206
231
|
}
|
|
207
232
|
break;
|
|
208
233
|
|
|
@@ -210,34 +235,39 @@ module.exports = async (response, options) => {
|
|
|
210
235
|
// Sequence sets are written verbatim - they are the one token type with
|
|
211
236
|
// no quoting to fall back on. Callers build them from user-supplied
|
|
212
237
|
// ranges, so validate here, at the single point every outgoing sequence
|
|
213
|
-
// set passes through, rather than trusting each command module.
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
238
|
+
// set passes through, rather than trusting each command module. Skipped
|
|
239
|
+
// when logging: the incoming token parser accepts sequence-shaped tokens
|
|
240
|
+
// this strict grammar rejects (an ESEARCH set like "1:2:3", a folder
|
|
241
|
+
// name like "12:30:00"), and re-compiling a server response for the log
|
|
242
|
+
// or for error text must never throw.
|
|
243
|
+
if (!isLogging && (typeof node.value === 'string' || typeof node.value === 'number' || Buffer.isBuffer(node.value))) {
|
|
217
244
|
val = node.value.toString();
|
|
218
|
-
if (val && !
|
|
245
|
+
if (val && !isValidSequenceSet(val)) {
|
|
219
246
|
let error = new Error('Invalid sequence set value');
|
|
220
247
|
error.code = 'InvalidSequenceSet';
|
|
221
248
|
throw error;
|
|
222
249
|
}
|
|
223
250
|
}
|
|
224
251
|
if (node.value) {
|
|
225
|
-
|
|
252
|
+
// raw: the validated alphabet cannot contain a line terminator, and
|
|
253
|
+
// re-scanning a potentially multi-megabyte set in the choke point
|
|
254
|
+
// would double the cost of exactly the sets this branch exists for
|
|
255
|
+
resp.push(emitEntry(node.value, { raw: true }));
|
|
226
256
|
}
|
|
227
257
|
break;
|
|
228
258
|
|
|
229
259
|
case 'TEXT':
|
|
230
260
|
// Response text is written verbatim. Only the parser produces it today, for
|
|
231
261
|
// incoming lines, so this is a re-encoding path rather than a command-building
|
|
232
|
-
// one
|
|
233
|
-
//
|
|
262
|
+
// one. The emitEntry choke point would refuse a line terminator here too;
|
|
263
|
+
// this check runs first only to raise the more specific InvalidTextValue code.
|
|
234
264
|
if (node.value) {
|
|
235
265
|
if (!isLogging && CRLF.test(node.value.toString())) {
|
|
236
266
|
let error = new Error('Line terminator in IMAP text value');
|
|
237
267
|
error.code = 'InvalidTextValue';
|
|
238
268
|
throw error;
|
|
239
269
|
}
|
|
240
|
-
resp.push(
|
|
270
|
+
resp.push(emitEntry(node.value));
|
|
241
271
|
}
|
|
242
272
|
break;
|
|
243
273
|
|
|
@@ -245,7 +275,7 @@ module.exports = async (response, options) => {
|
|
|
245
275
|
// Coerced rather than written through: formatRespEntry passes a string or
|
|
246
276
|
// Buffer straight to the wire, so a numeric token carrying a string value
|
|
247
277
|
// would be another verbatim channel
|
|
248
|
-
resp.push(
|
|
278
|
+
resp.push(emitEntry(safeNumber(node.value)));
|
|
249
279
|
break;
|
|
250
280
|
|
|
251
281
|
case 'ATOM':
|
|
@@ -255,24 +285,25 @@ module.exports = async (response, options) => {
|
|
|
255
285
|
if (!node.section || val) {
|
|
256
286
|
// Verify the value contains only valid ATOM-CHAR characters.
|
|
257
287
|
// Strip a leading backslash before checking (system flags like \Seen start with '\').
|
|
258
|
-
// If any character fails verification,
|
|
288
|
+
// If any character fails verification, fall back to an IMAP quoted string
|
|
289
|
+
// (JSON.stringify is used only for log output, where values are display-escaped).
|
|
259
290
|
if (node.value === '' || imapFormalSyntax.verify(val.charAt(0) === '\\' ? val.substr(1) : val, imapFormalSyntax['ATOM-CHAR']()) >= 0) {
|
|
260
291
|
val = isLogging ? JSON.stringify(val) : quoteString(val);
|
|
261
292
|
}
|
|
262
293
|
|
|
263
|
-
resp.push(
|
|
294
|
+
resp.push(emitEntry(val));
|
|
264
295
|
}
|
|
265
296
|
|
|
266
297
|
// Section bracket handling: emit [section-contents] after the ATOM value
|
|
267
298
|
// e.g., BODY[HEADER.FIELDS (Subject)] or BODY[1.MIME]
|
|
268
299
|
if (node.section) {
|
|
269
|
-
resp.push(
|
|
300
|
+
resp.push(emitEntry('['));
|
|
270
301
|
|
|
271
302
|
for (let child of node.section) {
|
|
272
303
|
await walk(child);
|
|
273
304
|
}
|
|
274
305
|
|
|
275
|
-
resp.push(
|
|
306
|
+
resp.push(emitEntry(']'));
|
|
276
307
|
}
|
|
277
308
|
// Partial range: emit <origin.length> after the section brackets. Coerced
|
|
278
309
|
// rather than joined as-is: this is the last token component written
|
|
@@ -280,7 +311,7 @@ module.exports = async (response, options) => {
|
|
|
280
311
|
// all of them. Every producer already passes numbers, so nothing changes
|
|
281
312
|
// for them.
|
|
282
313
|
if (node.partial) {
|
|
283
|
-
resp.push(
|
|
314
|
+
resp.push(emitEntry(`<${node.partial.map(safeNumber).join('.')}>`));
|
|
284
315
|
}
|
|
285
316
|
break;
|
|
286
317
|
}
|
|
@@ -81,6 +81,13 @@ module.exports = async (command, options) => {
|
|
|
81
81
|
if (err.code === 'ParserErrorExchange' && err.parserContext && err.parserContext.value) {
|
|
82
82
|
return err.parserContext.value;
|
|
83
83
|
}
|
|
84
|
+
if (response.tag) {
|
|
85
|
+
// The tag had already been parsed when the rest of the line failed. Expose it
|
|
86
|
+
// so the connection can settle the command this line was addressed to - unlike
|
|
87
|
+
// re-deriving the tag from the raw bytes, this inherits the leading-NUL
|
|
88
|
+
// workaround above.
|
|
89
|
+
err.parsedTag = response.tag;
|
|
90
|
+
}
|
|
84
91
|
throw err;
|
|
85
92
|
}
|
|
86
93
|
|
|
@@ -166,23 +166,35 @@ class ImapStream extends Transform {
|
|
|
166
166
|
|
|
167
167
|
// Scan backwards through the line to find an IMAP literal marker: {size}\r\n
|
|
168
168
|
// The format is: '{' followed by one or more ASCII digits followed by '}'.
|
|
169
|
-
// Only the digit run's bounds are tracked
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
169
|
+
// Only the digit run's bounds are tracked - a single linear pass, unlike
|
|
170
|
+
// collecting digits into a growing array, which would make a line of n digits
|
|
171
|
+
// cost O(n^2). The run length is deliberately not capped: the RFC "number"
|
|
172
|
+
// production permits leading zeros, so a long digit run can still denote a
|
|
173
|
+
// small, valid size, and treating the marker as an ordinary line instead
|
|
174
|
+
// would feed the announced literal body to the line parser and desynchronize
|
|
175
|
+
// the session.
|
|
175
176
|
let digitsEnd = pos;
|
|
176
177
|
for (; pos >= 0; pos--) {
|
|
177
178
|
let c = line[pos];
|
|
178
179
|
if (c >= NUM_0 && c <= NUM_9) {
|
|
179
|
-
if (digitsEnd - pos >= MAX_SIZE_DIGITS) {
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
180
|
continue;
|
|
183
181
|
}
|
|
184
182
|
if (c === CURLY_OPEN && pos < digitsEnd) {
|
|
185
|
-
|
|
183
|
+
// Skip leading zeros so only the significant digits are converted: a
|
|
184
|
+
// marker padded with megabytes of zeros must not cost a string
|
|
185
|
+
// allocation and Number() parse of the whole run.
|
|
186
|
+
let digitsStart = pos + 1;
|
|
187
|
+
while (digitsStart < digitsEnd && line[digitsStart] === NUM_0) {
|
|
188
|
+
digitsStart++;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// More significant digits than any number64 has cannot fit any
|
|
192
|
+
// permissible maxLiteralSize; fail closed without materializing them
|
|
193
|
+
if (digitsEnd + 1 - digitsStart > 19) {
|
|
194
|
+
return this.failStream(createLiteralTooLargeError(Infinity, this.maxLiteralSize, 'the widest permissible literal size (19 digits)'));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const literalSize = Number(line.toString('latin1', digitsStart, digitsEnd + 1));
|
|
186
198
|
|
|
187
199
|
if (literalSize > this.maxLiteralSize) {
|
|
188
200
|
return this.failStream(createLiteralTooLargeError(literalSize, this.maxLiteralSize));
|
package/lib/imap-flow.js
CHANGED
|
@@ -643,22 +643,40 @@ class ImapFlow extends EventEmitter {
|
|
|
643
643
|
}
|
|
644
644
|
|
|
645
645
|
if (typeof options.onSend === 'function') {
|
|
646
|
-
|
|
646
|
+
// The command is already on the wire, so a throwing onSend callback must not
|
|
647
|
+
// reach trySend()'s catch - that would reject the request and dispatch the
|
|
648
|
+
// next command into the server's pending state for this one.
|
|
649
|
+
try {
|
|
650
|
+
options.onSend();
|
|
651
|
+
} catch (err) {
|
|
652
|
+
this.log.warn({ err, cid: this.id });
|
|
653
|
+
}
|
|
647
654
|
}
|
|
648
655
|
}
|
|
649
656
|
|
|
650
657
|
async trySend() {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
}
|
|
654
|
-
this.currentRequest = this.requestQueue.shift();
|
|
658
|
+
while (!this.currentRequest && this.requestQueue.length) {
|
|
659
|
+
this.currentRequest = this.requestQueue.shift();
|
|
655
660
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
661
|
+
try {
|
|
662
|
+
await this.send({
|
|
663
|
+
tag: this.currentRequest.tag,
|
|
664
|
+
command: this.currentRequest.command,
|
|
665
|
+
attributes: this.currentRequest.attributes,
|
|
666
|
+
options: this.currentRequest.options
|
|
667
|
+
});
|
|
668
|
+
return;
|
|
669
|
+
} catch (err) {
|
|
670
|
+
// A failure here (most likely the compiler refusing an invalid
|
|
671
|
+
// user-supplied value) belongs to the command that was being dispatched.
|
|
672
|
+
// Without this the shifted request would stay currentRequest forever:
|
|
673
|
+
// nothing reached the wire, so no tagged response ever clears it, and
|
|
674
|
+
// every later command would queue behind it until the socket timeout.
|
|
675
|
+
// Reject the failed command and keep draining the queue.
|
|
676
|
+
this.commandParts = [];
|
|
677
|
+
this.rejectCurrentRequest(err);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
662
680
|
}
|
|
663
681
|
|
|
664
682
|
exec(command, attributes, options) {
|
|
@@ -685,10 +703,10 @@ class ImapFlow extends EventEmitter {
|
|
|
685
703
|
let promise = new Promise((resolve, reject) => {
|
|
686
704
|
this.requestTagMap.set(tag, { command, attributes, options, resolve, reject });
|
|
687
705
|
this.requestQueue.push({ tag, command, attributes, options });
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
});
|
|
706
|
+
// trySend() settles dispatch failures itself, by rejecting the affected
|
|
707
|
+
// command through requestTagMap; this catch exists only so a throw from the
|
|
708
|
+
// dispatch machinery itself can never surface as a floating rejection.
|
|
709
|
+
this.trySend().catch(err => this.log.warn({ err, cid: this.id }));
|
|
692
710
|
});
|
|
693
711
|
|
|
694
712
|
// Prevent unhandled promise rejection if close() rejects this request
|
|
@@ -848,8 +866,17 @@ class ImapFlow extends EventEmitter {
|
|
|
848
866
|
return;
|
|
849
867
|
}
|
|
850
868
|
|
|
851
|
-
|
|
852
|
-
|
|
869
|
+
// Prefer the tag the parser had already extracted before it failed - it went
|
|
870
|
+
// through the same leading-NUL workaround as every parsed response. Fall back
|
|
871
|
+
// to the raw bytes for lines whose tag itself was unparseable: skip the NUL
|
|
872
|
+
// padding buggy servers prepend and stop at the first byte a tag cannot contain.
|
|
873
|
+
let tag = parserError && parserError.parsedTag;
|
|
874
|
+
if (!tag) {
|
|
875
|
+
// eslint-disable-next-line no-control-regex
|
|
876
|
+
let match = payload.toString('latin1', 0, 64).match(/^\0*([^\s\x00-\x1f\x7f]+)/);
|
|
877
|
+
tag = match && match[1];
|
|
878
|
+
}
|
|
879
|
+
if (!tag || tag !== this.currentRequest.tag) {
|
|
853
880
|
return;
|
|
854
881
|
}
|
|
855
882
|
|
|
@@ -873,20 +900,6 @@ class ImapFlow extends EventEmitter {
|
|
|
873
900
|
|
|
874
901
|
try {
|
|
875
902
|
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
903
|
} catch (err) {
|
|
891
904
|
// can not make sense of this
|
|
892
905
|
this.log.error({ src: 's', msg: data.payload.toString(), err, cid: this.id });
|
|
@@ -901,6 +914,28 @@ class ImapFlow extends EventEmitter {
|
|
|
901
914
|
return true;
|
|
902
915
|
}
|
|
903
916
|
|
|
917
|
+
if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
|
|
918
|
+
let payload = { response: parsed.command };
|
|
919
|
+
|
|
920
|
+
if (
|
|
921
|
+
parsed.attributes &&
|
|
922
|
+
parsed.attributes[0] &&
|
|
923
|
+
parsed.attributes[0].section &&
|
|
924
|
+
parsed.attributes[0].section[0] &&
|
|
925
|
+
parsed.attributes[0].section[0].type === 'ATOM'
|
|
926
|
+
) {
|
|
927
|
+
payload.code = parsed.attributes[0].section[0].value;
|
|
928
|
+
}
|
|
929
|
+
// Outside the parse try/catch on purpose: a throwing user 'response' listener
|
|
930
|
+
// is not a parse failure and must not settle the in-flight command or fail the
|
|
931
|
+
// connection - the same contract untagged handlers get.
|
|
932
|
+
try {
|
|
933
|
+
this.emit('response', payload);
|
|
934
|
+
} catch (err) {
|
|
935
|
+
this.log.warn({ err, cid: this.id });
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
904
939
|
let logCompiled = await compiler(parsed, {
|
|
905
940
|
isLogging: true
|
|
906
941
|
});
|
|
@@ -1672,8 +1707,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1672
1707
|
// STARTTLS OK itself clears expectCapabilityUpdate, so keying the discard
|
|
1673
1708
|
// on that flag would keep exactly the pre-TLS list an attacker controls -
|
|
1674
1709
|
// the list that then picks the AUTH mechanism and answers LOGINDISABLED.
|
|
1675
|
-
this.
|
|
1676
|
-
this.authCapabilities.clear();
|
|
1710
|
+
this.clearCapabilities();
|
|
1677
1711
|
await this.run('CAPABILITY');
|
|
1678
1712
|
}
|
|
1679
1713
|
|
|
@@ -1818,6 +1852,17 @@ class ImapFlow extends EventEmitter {
|
|
|
1818
1852
|
this.state = this.states.LOGOUT;
|
|
1819
1853
|
}
|
|
1820
1854
|
|
|
1855
|
+
// Drops every capability-derived field together - the counterpart of
|
|
1856
|
+
// updateCapabilitiesFromRaw() below, which sets them together. rawCapabilities is
|
|
1857
|
+
// public surface external consumers read, so a discard (RFC 9051 6.2.1 requires
|
|
1858
|
+
// one after STARTTLS) that missed it would leave the stale list visible if the
|
|
1859
|
+
// re-fetch fails.
|
|
1860
|
+
clearCapabilities() {
|
|
1861
|
+
this.capabilities.clear();
|
|
1862
|
+
this.authCapabilities.clear();
|
|
1863
|
+
this.rawCapabilities = null;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1821
1866
|
updateCapabilitiesFromRaw(rawCapabilities) {
|
|
1822
1867
|
this.rawCapabilities = rawCapabilities;
|
|
1823
1868
|
this.capabilities = updateCapabilities(rawCapabilities);
|