imapflow 1.6.3 → 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 +14 -0
- package/lib/commands/compress.js +29 -2
- package/lib/commands/search.js +10 -0
- package/lib/handler/imap-compiler.js +146 -46
- package/lib/handler/imap-parser.js +7 -0
- package/lib/handler/imap-stream.js +25 -5
- package/lib/handler/parser-instance.js +22 -3
- package/lib/imap-flow.d.ts +2 -0
- package/lib/imap-flow.js +119 -36
- package/lib/search-compiler.js +28 -17
- package/lib/tools.js +18 -4
- package/package.json +1 -1
- package/test/commands-integration-test.js +188 -5
- package/test/idle-polling-test.js +81 -0
- package/test/imap-compiler-test.js +168 -0
- package/test/imap-flow-internals-test.js +134 -0
- package/test/imap-flow-secure-test.js +154 -81
- package/test/imap-flow-server-test.js +162 -0
- package/test/imap-parser-test.js +51 -0
- package/test/imap-stream-edge-cases-test.js +72 -0
- package/test/integration/rev2-live-test.js +67 -3
- package/test/search-compiler-test.js +90 -3
- package/test/search-test.js +32 -3
- package/test/tools-test.js +28 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
10
|
+
## [1.6.4](https://github.com/postalsys/imapflow/compare/v1.6.3...v1.6.4) (2026-07-29)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* block IMAP command injection and harden rev2 protocol handling ([e107292](https://github.com/postalsys/imapflow/commit/e107292bc907b29d94b0d810dc01e9680f820bfa))
|
|
16
|
+
|
|
3
17
|
## [1.6.3](https://github.com/postalsys/imapflow/compare/v1.6.2...v1.6.3) (2026-07-28)
|
|
4
18
|
|
|
5
19
|
|
package/lib/commands/compress.js
CHANGED
|
@@ -19,10 +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
|
-
response.next();
|
|
23
|
-
return true;
|
|
24
22
|
} catch (err) {
|
|
23
|
+
// The server declined (NO/BAD): nothing switched, staying uncompressed is safe.
|
|
25
24
|
connection.log.warn({ err, cid: connection.id });
|
|
26
25
|
return false;
|
|
27
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;
|
|
28
55
|
};
|
package/lib/commands/search.js
CHANGED
|
@@ -27,6 +27,8 @@ const stripEsearchPrefix = attrs => {
|
|
|
27
27
|
*
|
|
28
28
|
* ALL and PARTIAL.messages are kept as compact sequence-set strings.
|
|
29
29
|
* Use expandRange() from tools.js if you need to expand them.
|
|
30
|
+
* MODSEQ (RFC 7162, sent when the search used a MODSEQ criterion) is
|
|
31
|
+
* returned as a BigInt.
|
|
30
32
|
*
|
|
31
33
|
* @param {Array} attrs - Attribute array from the IMAP parser
|
|
32
34
|
* @returns {Object} ESearchResult object
|
|
@@ -61,6 +63,14 @@ function parseEsearchResponse(attrs) {
|
|
|
61
63
|
if (!isNaN(n)) result.max = n;
|
|
62
64
|
break;
|
|
63
65
|
}
|
|
66
|
+
case 'MODSEQ': {
|
|
67
|
+
// RFC 7162 section 3.1.5: present when the SEARCH used a MODSEQ
|
|
68
|
+
// criterion on a CONDSTORE-enabled session. BigInt because
|
|
69
|
+
// mod-sequence values are unsigned 63-bit
|
|
70
|
+
const value = attrs[++i]?.value;
|
|
71
|
+
if (typeof value === 'string' && /^\d+$/.test(value)) result.modseq = BigInt(value);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
64
74
|
case 'ALL': {
|
|
65
75
|
const allToken = attrs[++i];
|
|
66
76
|
if (allToken && typeof allToken.value === 'string') {
|
|
@@ -4,31 +4,53 @@
|
|
|
4
4
|
|
|
5
5
|
const imapFormalSyntax = require('./imap-formal-syntax');
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
// range-checked here (a server rejects "0" or an overlong number on its own); the
|
|
10
|
+
// point of the check is that nothing outside this alphabet can reach the wire.
|
|
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
|
+
};
|
|
18
28
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
// Characters that cannot appear in an IMAP quoted string: CR and LF terminate a
|
|
30
|
+
// command line, and NUL is outside the CHAR production entirely. A value carrying
|
|
31
|
+
// any of them has to be sent as a literal, so quoting it is never correct.
|
|
32
|
+
const NOT_QUOTABLE = /[\r\n\0]/;
|
|
22
33
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
34
|
+
// A line terminator ends an IMAP command, so no token may carry one to the wire.
|
|
35
|
+
const CRLF = /[\r\n]/;
|
|
26
36
|
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Quotes a value as an IMAP quoted string. Only DQUOTE and backslash are escaped -
|
|
39
|
+
* the IMAP grammar defines no other escape sequence, so JSON-style escaping (which
|
|
40
|
+
* turns a tab into a literal backslash-t and a control character into \\uXXXX) would
|
|
41
|
+
* silently change the value the server receives.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} value - The value to quote.
|
|
44
|
+
* @returns {string} The quoted string, ready to be written to the wire.
|
|
45
|
+
* @throws {Error} If the value contains CR, LF or NUL, which a quoted string cannot carry.
|
|
46
|
+
*/
|
|
47
|
+
const quoteString = value => {
|
|
48
|
+
if (NOT_QUOTABLE.test(value)) {
|
|
49
|
+
let error = new Error('Unquotable character in IMAP string value');
|
|
50
|
+
error.code = 'InvalidStringValue';
|
|
51
|
+
throw error;
|
|
29
52
|
}
|
|
30
|
-
|
|
31
|
-
return Buffer.alloc(0);
|
|
53
|
+
return '"' + value.replace(/["\\]/g, char => '\\' + char) + '"';
|
|
32
54
|
};
|
|
33
55
|
|
|
34
56
|
/**
|
|
@@ -50,7 +72,43 @@ module.exports = async (response, options) => {
|
|
|
50
72
|
let { asArray, isLogging, literalPlus, literalMinus } = options || {};
|
|
51
73
|
const respParts = [];
|
|
52
74
|
|
|
53
|
-
|
|
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) : []);
|
|
54
112
|
let val;
|
|
55
113
|
let lastType;
|
|
56
114
|
|
|
@@ -75,7 +133,7 @@ module.exports = async (response, options) => {
|
|
|
75
133
|
// adjacent lists).
|
|
76
134
|
if (lastType === 'LITERAL' || (!['(', '<', '['].includes(lastRespByte) && resp.length)) {
|
|
77
135
|
if (!options.subArray) {
|
|
78
|
-
resp.push(
|
|
136
|
+
resp.push(emitEntry(' '));
|
|
79
137
|
}
|
|
80
138
|
}
|
|
81
139
|
|
|
@@ -86,7 +144,7 @@ module.exports = async (response, options) => {
|
|
|
86
144
|
|
|
87
145
|
if (Array.isArray(node)) {
|
|
88
146
|
lastType = 'LIST';
|
|
89
|
-
resp.push(
|
|
147
|
+
resp.push(emitEntry('('));
|
|
90
148
|
|
|
91
149
|
// check if we need to skip separator WS between two arrays
|
|
92
150
|
let subArray = node.length > 1 && Array.isArray(node[0]);
|
|
@@ -98,40 +156,40 @@ module.exports = async (response, options) => {
|
|
|
98
156
|
await walk(child, { subArray });
|
|
99
157
|
}
|
|
100
158
|
|
|
101
|
-
resp.push(
|
|
159
|
+
resp.push(emitEntry(')'));
|
|
102
160
|
return;
|
|
103
161
|
}
|
|
104
162
|
|
|
105
163
|
if (!node && typeof node !== 'string' && typeof node !== 'number' && !Buffer.isBuffer(node)) {
|
|
106
|
-
resp.push(
|
|
164
|
+
resp.push(emitEntry('NIL'));
|
|
107
165
|
return;
|
|
108
166
|
}
|
|
109
167
|
|
|
110
168
|
if (typeof node === 'string' || Buffer.isBuffer(node)) {
|
|
111
169
|
if (isLogging && node.length > 100) {
|
|
112
|
-
resp.push(
|
|
170
|
+
resp.push(emitEntry('"(* ' + node.length + 'B string *)"'));
|
|
113
171
|
} else {
|
|
114
|
-
resp.push(
|
|
172
|
+
resp.push(emitEntry(isLogging ? JSON.stringify(node.toString()) : quoteString(node.toString())));
|
|
115
173
|
}
|
|
116
174
|
return;
|
|
117
175
|
}
|
|
118
176
|
|
|
119
177
|
if (typeof node === 'number') {
|
|
120
|
-
resp.push(
|
|
178
|
+
resp.push(emitEntry(safeNumber(node))); // Only bounded non-negative integers allowed
|
|
121
179
|
return;
|
|
122
180
|
}
|
|
123
181
|
|
|
124
182
|
lastType = node.type;
|
|
125
183
|
|
|
126
184
|
if (isLogging && node.sensitive) {
|
|
127
|
-
resp.push(
|
|
185
|
+
resp.push(emitEntry('"(* value hidden *)"'));
|
|
128
186
|
return;
|
|
129
187
|
}
|
|
130
188
|
|
|
131
189
|
switch (node.type.toUpperCase()) {
|
|
132
190
|
case 'LITERAL':
|
|
133
191
|
if (isLogging) {
|
|
134
|
-
resp.push(
|
|
192
|
+
resp.push(emitEntry('"(* ' + node.value.length + 'B literal *)"'));
|
|
135
193
|
} else {
|
|
136
194
|
// The literal size marker counts octets - string values are written as
|
|
137
195
|
// UTF-8, so their UTF-16 .length would undercount multi-byte characters
|
|
@@ -147,40 +205,77 @@ module.exports = async (response, options) => {
|
|
|
147
205
|
let canAppend = !asArray || usePlus;
|
|
148
206
|
|
|
149
207
|
// Emit the literal header: optional '~' prefix for literal8, then {size[+]}\r\n
|
|
150
|
-
resp.push(
|
|
208
|
+
resp.push(emitEntry(`${node.isLiteral8 ? '~' : ''}{${literalLength}${usePlus ? '+' : ''}}\r\n`, { raw: true }));
|
|
151
209
|
|
|
152
210
|
if (canAppend) {
|
|
153
211
|
// Literal data follows immediately in the same buffer segment
|
|
154
212
|
if (node.value && node.value.length) {
|
|
155
|
-
resp.push(
|
|
213
|
+
resp.push(emitEntry(node.value, { raw: true }));
|
|
156
214
|
}
|
|
157
215
|
} else {
|
|
158
216
|
// For synchronizing literals in asArray mode, split output into separate
|
|
159
217
|
// parts. The caller must send each part and wait for a continuation
|
|
160
218
|
// response from the server before sending the next.
|
|
161
219
|
respParts.push(resp);
|
|
162
|
-
resp = [].concat(
|
|
220
|
+
resp = [].concat(emitEntry(node.value, { returnEmpty: true, raw: true }) || []);
|
|
163
221
|
}
|
|
164
222
|
}
|
|
165
223
|
break;
|
|
166
224
|
|
|
167
225
|
case 'STRING':
|
|
168
226
|
if (isLogging && node.value.length > 100) {
|
|
169
|
-
resp.push(
|
|
227
|
+
resp.push(emitEntry('"(* ' + node.value.length + 'B string *)"'));
|
|
170
228
|
} else {
|
|
171
|
-
|
|
229
|
+
val = (node.value || '').toString();
|
|
230
|
+
resp.push(emitEntry(isLogging ? JSON.stringify(val) : quoteString(val)));
|
|
172
231
|
}
|
|
173
232
|
break;
|
|
174
233
|
|
|
175
|
-
case 'TEXT':
|
|
176
234
|
case 'SEQUENCE':
|
|
235
|
+
// Sequence sets are written verbatim - they are the one token type with
|
|
236
|
+
// no quoting to fall back on. Callers build them from user-supplied
|
|
237
|
+
// ranges, so validate here, at the single point every outgoing sequence
|
|
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))) {
|
|
244
|
+
val = node.value.toString();
|
|
245
|
+
if (val && !isValidSequenceSet(val)) {
|
|
246
|
+
let error = new Error('Invalid sequence set value');
|
|
247
|
+
error.code = 'InvalidSequenceSet';
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (node.value) {
|
|
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 }));
|
|
256
|
+
}
|
|
257
|
+
break;
|
|
258
|
+
|
|
259
|
+
case 'TEXT':
|
|
260
|
+
// Response text is written verbatim. Only the parser produces it today, for
|
|
261
|
+
// incoming lines, so this is a re-encoding path rather than a command-building
|
|
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.
|
|
177
264
|
if (node.value) {
|
|
178
|
-
|
|
265
|
+
if (!isLogging && CRLF.test(node.value.toString())) {
|
|
266
|
+
let error = new Error('Line terminator in IMAP text value');
|
|
267
|
+
error.code = 'InvalidTextValue';
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
resp.push(emitEntry(node.value));
|
|
179
271
|
}
|
|
180
272
|
break;
|
|
181
273
|
|
|
182
274
|
case 'NUMBER':
|
|
183
|
-
|
|
275
|
+
// Coerced rather than written through: formatRespEntry passes a string or
|
|
276
|
+
// Buffer straight to the wire, so a numeric token carrying a string value
|
|
277
|
+
// would be another verbatim channel
|
|
278
|
+
resp.push(emitEntry(safeNumber(node.value)));
|
|
184
279
|
break;
|
|
185
280
|
|
|
186
281
|
case 'ATOM':
|
|
@@ -190,28 +285,33 @@ module.exports = async (response, options) => {
|
|
|
190
285
|
if (!node.section || val) {
|
|
191
286
|
// Verify the value contains only valid ATOM-CHAR characters.
|
|
192
287
|
// Strip a leading backslash before checking (system flags like \Seen start with '\').
|
|
193
|
-
// 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).
|
|
194
290
|
if (node.value === '' || imapFormalSyntax.verify(val.charAt(0) === '\\' ? val.substr(1) : val, imapFormalSyntax['ATOM-CHAR']()) >= 0) {
|
|
195
|
-
val = JSON.stringify(val);
|
|
291
|
+
val = isLogging ? JSON.stringify(val) : quoteString(val);
|
|
196
292
|
}
|
|
197
293
|
|
|
198
|
-
resp.push(
|
|
294
|
+
resp.push(emitEntry(val));
|
|
199
295
|
}
|
|
200
296
|
|
|
201
297
|
// Section bracket handling: emit [section-contents] after the ATOM value
|
|
202
298
|
// e.g., BODY[HEADER.FIELDS (Subject)] or BODY[1.MIME]
|
|
203
299
|
if (node.section) {
|
|
204
|
-
resp.push(
|
|
300
|
+
resp.push(emitEntry('['));
|
|
205
301
|
|
|
206
302
|
for (let child of node.section) {
|
|
207
303
|
await walk(child);
|
|
208
304
|
}
|
|
209
305
|
|
|
210
|
-
resp.push(
|
|
306
|
+
resp.push(emitEntry(']'));
|
|
211
307
|
}
|
|
212
|
-
// Partial range: emit <origin.length> after the section brackets
|
|
308
|
+
// Partial range: emit <origin.length> after the section brackets. Coerced
|
|
309
|
+
// rather than joined as-is: this is the last token component written
|
|
310
|
+
// verbatim, and the choke point is only worth relying on if it holds for
|
|
311
|
+
// all of them. Every producer already passes numbers, so nothing changes
|
|
312
|
+
// for them.
|
|
213
313
|
if (node.partial) {
|
|
214
|
-
resp.push(
|
|
314
|
+
resp.push(emitEntry(`<${node.partial.map(safeNumber).join('.')}>`));
|
|
215
315
|
}
|
|
216
316
|
break;
|
|
217
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
|
|
|
@@ -165,16 +165,36 @@ class ImapStream extends Transform {
|
|
|
165
165
|
pos--;
|
|
166
166
|
|
|
167
167
|
// Scan backwards through the line to find an IMAP literal marker: {size}\r\n
|
|
168
|
-
// The format is: '{' followed by one or more ASCII digits followed by '}'
|
|
169
|
-
|
|
168
|
+
// The format is: '{' followed by one or more ASCII digits followed by '}'.
|
|
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.
|
|
176
|
+
let digitsEnd = pos;
|
|
170
177
|
for (; pos >= 0; pos--) {
|
|
171
178
|
let c = line[pos];
|
|
172
179
|
if (c >= NUM_0 && c <= NUM_9) {
|
|
173
|
-
numBytes.unshift(c);
|
|
174
180
|
continue;
|
|
175
181
|
}
|
|
176
|
-
if (c === CURLY_OPEN &&
|
|
177
|
-
|
|
182
|
+
if (c === CURLY_OPEN && pos < digitsEnd) {
|
|
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));
|
|
178
198
|
|
|
179
199
|
if (literalSize > this.maxLiteralSize) {
|
|
180
200
|
return this.failStream(createLiteralTooLargeError(literalSize, this.maxLiteralSize));
|
|
@@ -77,8 +77,13 @@ class ParserInstance {
|
|
|
77
77
|
{
|
|
78
78
|
let match = this.remainder.match(/^\s+\[/);
|
|
79
79
|
if (match) {
|
|
80
|
+
// Find the ']' that closes the response code. Inner brackets are
|
|
81
|
+
// tracked because servers do put bracketed values inside a code
|
|
82
|
+
// (e.g. a "[css3-page]" keyword in a PERMANENTFLAGS list), which a
|
|
83
|
+
// first-']' scan would cut in half.
|
|
80
84
|
let nesting = 1;
|
|
81
|
-
|
|
85
|
+
let end = -1;
|
|
86
|
+
for (let i = match[0].length; i < this.remainder.length; i++) {
|
|
82
87
|
let c = this.remainder[i];
|
|
83
88
|
|
|
84
89
|
if (c === '[') {
|
|
@@ -87,11 +92,25 @@ class ParserInstance {
|
|
|
87
92
|
nesting--;
|
|
88
93
|
}
|
|
89
94
|
if (!nesting) {
|
|
90
|
-
|
|
91
|
-
this.remainder = this.remainder.substring(0, i + 1);
|
|
95
|
+
end = i;
|
|
92
96
|
break;
|
|
93
97
|
}
|
|
94
98
|
}
|
|
99
|
+
|
|
100
|
+
// Unbalanced '[' inside the code: the RFC 9051 free-text form
|
|
101
|
+
// (`atom [SP 1*<any TEXT-CHAR except "]">]`) permits '[' but not
|
|
102
|
+
// ']', so the code really does end at the first ']' here. Without
|
|
103
|
+
// this fallback the scan finds no closing bracket at all and the
|
|
104
|
+
// human-readable text - what every error message is built from -
|
|
105
|
+
// is swallowed into the response code.
|
|
106
|
+
if (end < 0) {
|
|
107
|
+
end = this.remainder.indexOf(']', match[0].length);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (end >= 0) {
|
|
111
|
+
this.humanReadable = this.remainder.substring(end + 1).trim();
|
|
112
|
+
this.remainder = this.remainder.substring(0, end + 1);
|
|
113
|
+
}
|
|
95
114
|
} else {
|
|
96
115
|
this.humanReadable = this.remainder.trim();
|
|
97
116
|
this.remainder = '';
|
package/lib/imap-flow.d.ts
CHANGED
|
@@ -699,6 +699,8 @@ export interface ESearchResult {
|
|
|
699
699
|
/** Matching UIDs in that range as compact sequence-set */
|
|
700
700
|
messages: string;
|
|
701
701
|
};
|
|
702
|
+
/** Highest mod-sequence of the matching messages (RFC 7162, present when the search used a modseq criterion on a CONDSTORE session) */
|
|
703
|
+
modseq?: bigint;
|
|
702
704
|
}
|
|
703
705
|
|
|
704
706
|
export class AuthenticationFailure extends Error {
|