imapflow 2.5.8 → 2.5.9
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/CHANGELOG.md +8 -0
- package/dist/cjs/commands/append.js +6 -3
- package/dist/cjs/errors.d.ts +2 -1
- package/dist/cjs/errors.js +1 -0
- package/dist/cjs/handler/imap-parser.js +38 -6
- package/dist/cjs/handler/limits.d.ts +1 -0
- package/dist/cjs/handler/limits.js +8 -1
- package/dist/cjs/handler/token-parser.d.ts +8 -2
- package/dist/cjs/handler/token-parser.js +55 -16
- package/dist/cjs/handler/types.d.ts +5 -2
- package/dist/cjs/imap-flow.js +9 -1
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/cjs/types.d.ts +18 -4
- package/dist/esm/commands/append.js +6 -3
- package/dist/esm/errors.d.ts +2 -1
- package/dist/esm/errors.js +1 -0
- package/dist/esm/handler/imap-parser.js +38 -6
- package/dist/esm/handler/limits.d.ts +1 -0
- package/dist/esm/handler/limits.js +7 -0
- package/dist/esm/handler/token-parser.d.ts +8 -2
- package/dist/esm/handler/token-parser.js +56 -17
- package/dist/esm/handler/types.d.ts +5 -2
- package/dist/esm/imap-flow.js +9 -1
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/dist/esm/types.d.ts +18 -4
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.5.9](https://github.com/postalsys/imapflow/compare/v2.5.8...v2.5.9) (2026-10-20)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* bound the parse cost of responses with many literals or tokens ([cb2124f](https://github.com/postalsys/imapflow/commit/cb2124f7a4db4a4c3ec0f15bffa6e8d6e81c2426))
|
|
9
|
+
* do not report APPENDUID UIDVALIDITY next to a looked up UID ([#448](https://github.com/postalsys/imapflow/issues/448)) ([b5aadd3](https://github.com/postalsys/imapflow/commit/b5aadd399599f600009bb4db8ce16fdc574d7ce6))
|
|
10
|
+
|
|
3
11
|
## [2.5.8](https://github.com/postalsys/imapflow/compare/v2.5.7...v2.5.8) (2026-10-19)
|
|
4
12
|
|
|
5
13
|
|
|
@@ -144,11 +144,14 @@ async function append(connection, destination, content, flags, idate) {
|
|
|
144
144
|
// message was already stored and a retrying caller would duplicate it.
|
|
145
145
|
let uidValidity = (0, tools_js_1.parseBigIntValue)(section[1] && section[1].value, tools_js_1.MAX_UINT32_DIGITS);
|
|
146
146
|
let uid = (0, tools_js_1.parseUintValue)(section[2] && section[2].value, tools_js_1.MAX_UINT32_DIGITS);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
// UIDVALIDITY is only reported next to the UID it was sent with: kept without one,
|
|
148
|
+
// it would end up paired with the UID the SEARCH fallback below guesses from the
|
|
149
|
+
// sequence number, and the pair would pass for a server-confirmed identity.
|
|
150
150
|
if (uid) {
|
|
151
151
|
map.uid = uid;
|
|
152
|
+
if (uidValidity) {
|
|
153
|
+
map.uidValidity = uidValidity;
|
|
154
|
+
}
|
|
152
155
|
}
|
|
153
156
|
}
|
|
154
157
|
}
|
package/dist/cjs/errors.d.ts
CHANGED
|
@@ -35,6 +35,7 @@ export declare const ImapFlowErrorCode: {
|
|
|
35
35
|
readonly LineTooLarge: "LineTooLarge";
|
|
36
36
|
readonly LiteralTooLarge: "LiteralTooLarge";
|
|
37
37
|
readonly ResponseTooLarge: "ResponseTooLarge";
|
|
38
|
+
readonly TooManyTokens: "TooManyTokens";
|
|
38
39
|
readonly TooManyResponses: "TooManyResponses";
|
|
39
40
|
readonly InvalidStringValue: "InvalidStringValue";
|
|
40
41
|
readonly InvalidTokenValue: "InvalidTokenValue";
|
|
@@ -117,7 +118,7 @@ export interface ImapFlowError extends Error {
|
|
|
117
118
|
lineLength?: number | undefined;
|
|
118
119
|
/** The size of a rejected response */
|
|
119
120
|
responseSize?: number | undefined;
|
|
120
|
-
/** The bound that was exceeded */
|
|
121
|
+
/** The bound that was exceeded (bytes, or tokens for TooManyTokens) */
|
|
121
122
|
maxSize?: number | undefined;
|
|
122
123
|
/** OAuth error details from the server, for XOAUTH2 authentication failures */
|
|
123
124
|
oauthError?: any;
|
package/dist/cjs/errors.js
CHANGED
|
@@ -43,6 +43,7 @@ exports.ImapFlowErrorCode = {
|
|
|
43
43
|
LineTooLarge: 'LineTooLarge',
|
|
44
44
|
LiteralTooLarge: 'LiteralTooLarge',
|
|
45
45
|
ResponseTooLarge: 'ResponseTooLarge',
|
|
46
|
+
TooManyTokens: 'TooManyTokens',
|
|
46
47
|
// one command received more untagged responses than are kept, see MAX_COLLECTED_RESPONSES
|
|
47
48
|
TooManyResponses: 'TooManyResponses',
|
|
48
49
|
// invalid values in a command
|
|
@@ -9,8 +9,8 @@ const parser_instance_js_1 = require("./parser-instance.js");
|
|
|
9
9
|
const errors_js_1 = require("../errors.js");
|
|
10
10
|
const limits_js_1 = require("./limits.js");
|
|
11
11
|
// The codes the parser raises on purpose: the numbered ParserErrorN codes, ParserErrorExchange,
|
|
12
|
-
// the nesting limit
|
|
13
|
-
const PARSER_ERROR_CODES = /^(?:ParserError\d+|ParserErrorExchange|MAX_IMAP_NESTING_REACHED|LiteralTooLarge)$/;
|
|
12
|
+
// the nesting limit, the inline literal size limit and the token limit
|
|
13
|
+
const PARSER_ERROR_CODES = /^(?:ParserError\d+|ParserErrorExchange|MAX_IMAP_NESTING_REACHED|LiteralTooLarge|TooManyTokens)$/;
|
|
14
14
|
/**
|
|
15
15
|
* Anything the parser throws other than one of its own coded errors - a TypeError from a parser
|
|
16
16
|
* bug, a RangeError from runaway recursion or a bad Buffer offset (which carries a Node code), a
|
|
@@ -81,6 +81,39 @@ function readSearchNumbers(str) {
|
|
|
81
81
|
numbers.push(value);
|
|
82
82
|
return numbers;
|
|
83
83
|
}
|
|
84
|
+
// The CONDSTORE list that ends a SEARCH answer to a MODSEQ query (RFC 7162 section 3.1.5)
|
|
85
|
+
const RE_SEARCH_MODSEQ = /^ \(MODSEQ (\d{1,19})\) *$/i;
|
|
86
|
+
/**
|
|
87
|
+
* Reads an untagged SEARCH response without the tokenizer, see readSearchNumbers(): the numbers,
|
|
88
|
+
* and the MODSEQ value of a trailing CONDSTORE list. A MODSEQ query on a large mailbox matches as
|
|
89
|
+
* many messages as any other, so its answer must not fall back to the tokenizer either.
|
|
90
|
+
*
|
|
91
|
+
* @param str - The rest of the line after the command
|
|
92
|
+
* @returns The numbers, with the MODSEQ list as the attributes the tokenizer would have made of
|
|
93
|
+
* it, or null when the line is left to the tokenizer
|
|
94
|
+
*/
|
|
95
|
+
function readSearchResponse(str) {
|
|
96
|
+
// the last character tells whether there is a list to look for, without scanning the line
|
|
97
|
+
if (!str.trimEnd().endsWith(')')) {
|
|
98
|
+
const numbers = readSearchNumbers(str);
|
|
99
|
+
return numbers && { numbers };
|
|
100
|
+
}
|
|
101
|
+
const open = str.lastIndexOf(' (');
|
|
102
|
+
const match = open < 0 ? null : RE_SEARCH_MODSEQ.exec(str.slice(open));
|
|
103
|
+
if (!match) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const numbers = readSearchNumbers(str.slice(0, open));
|
|
107
|
+
return (numbers && {
|
|
108
|
+
numbers,
|
|
109
|
+
attributes: [
|
|
110
|
+
[
|
|
111
|
+
{ type: 'ATOM', value: 'MODSEQ' },
|
|
112
|
+
{ type: 'ATOM', value: match[1] }
|
|
113
|
+
]
|
|
114
|
+
]
|
|
115
|
+
});
|
|
116
|
+
}
|
|
84
117
|
/**
|
|
85
118
|
* Parses a raw IMAP command or response buffer into a structured object.
|
|
86
119
|
* Handles edge cases such as null-byte-padded responses from buggy servers and
|
|
@@ -136,10 +169,9 @@ async function parser(command, options) {
|
|
|
136
169
|
response.command += ' ' + (await parserInstance.getElement(imap_formal_syntax_js_1.default.command()));
|
|
137
170
|
}
|
|
138
171
|
if (response.tag === '*' && (response.command || '').toUpperCase() === 'SEARCH') {
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
response
|
|
142
|
-
return response;
|
|
172
|
+
const search = readSearchResponse(parserInstance.remainder);
|
|
173
|
+
if (search) {
|
|
174
|
+
return Object.assign(response, search);
|
|
143
175
|
}
|
|
144
176
|
}
|
|
145
177
|
if (parserInstance.remainder.trim().length) {
|
|
@@ -2,6 +2,7 @@ import { type ImapFlowError } from '../errors.js';
|
|
|
2
2
|
export declare const MAX_LITERAL_SIZE: number;
|
|
3
3
|
export declare const MAX_LINE_SIZE: number;
|
|
4
4
|
export declare const MAX_RESPONSE_SIZE: number;
|
|
5
|
+
export declare const MAX_RESPONSE_TOKENS = 1000000;
|
|
5
6
|
export declare const ERROR_CONTEXT_LENGTH = 1024;
|
|
6
7
|
/**
|
|
7
8
|
* The input a parse error carries: a bounded prefix with the full length.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// (ImapStream) and the standalone token parser cannot drift apart, and so the documented
|
|
4
4
|
// defaults in the ImapFlowOptions type describe both paths.
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.createLiteralTooLargeError = exports.normalizeLimit = exports.boundedInput = exports.ERROR_CONTEXT_LENGTH = exports.MAX_RESPONSE_SIZE = exports.MAX_LINE_SIZE = exports.MAX_LITERAL_SIZE = void 0;
|
|
6
|
+
exports.createLiteralTooLargeError = exports.normalizeLimit = exports.boundedInput = exports.ERROR_CONTEXT_LENGTH = exports.MAX_RESPONSE_TOKENS = exports.MAX_RESPONSE_SIZE = exports.MAX_LINE_SIZE = exports.MAX_LITERAL_SIZE = void 0;
|
|
7
7
|
const errors_js_1 = require("../errors.js");
|
|
8
8
|
// Maximum allowed literal size: 1GB (1073741824 bytes)
|
|
9
9
|
exports.MAX_LITERAL_SIZE = 1024 * 1024 * 1024;
|
|
@@ -21,6 +21,13 @@ exports.MAX_LINE_SIZE = exports.MAX_LITERAL_SIZE;
|
|
|
21
21
|
// of exactly the maximum permitted size impossible to receive. Configuring both limits calls for
|
|
22
22
|
// the same headroom - set maxResponseSize above maxLiteralSize, not equal to it.
|
|
23
23
|
exports.MAX_RESPONSE_SIZE = 2 * exports.MAX_LITERAL_SIZE;
|
|
24
|
+
// Default maximum number of tokens (atoms, strings, literals, lists and the other parse tree
|
|
25
|
+
// nodes) in a single response. The byte limits bound what a server sends, not what parsing it
|
|
26
|
+
// costs: every token becomes a tree node and an attribute object, around a hundred bytes of heap
|
|
27
|
+
// for a two-byte "1 ", so a line of many small tokens costs fifty times its size. A legitimate
|
|
28
|
+
// response stays far below this (the long one, a SEARCH answer of plain numbers, is read without
|
|
29
|
+
// the tokenizer, see parser()), so the bound only stops a server that floods one response.
|
|
30
|
+
exports.MAX_RESPONSE_TOKENS = 1000000;
|
|
24
31
|
// How much of the offending input a parse error carries along. The error travels whole into log
|
|
25
32
|
// entries (pino copies every property of a logged error) and into the rejection of the command
|
|
26
33
|
// the line belonged to, and the line can be as long as the configured line cap.
|
|
@@ -7,7 +7,8 @@ import type { ParserInstance } from './parser-instance.js';
|
|
|
7
7
|
* (ATOM, string, LITERAL, SEQUENCE, LIST, SECTION, PARTIAL).
|
|
8
8
|
*/
|
|
9
9
|
export interface TokenNode {
|
|
10
|
-
|
|
10
|
+
/** Created with the first child, a leaf token does not carry an empty array */
|
|
11
|
+
childNodes?: TokenNode[] | undefined;
|
|
11
12
|
type: string | false;
|
|
12
13
|
value: string | Buffer;
|
|
13
14
|
isClosed: boolean;
|
|
@@ -44,6 +45,9 @@ export declare class TokenParser {
|
|
|
44
45
|
options: ParserOptions;
|
|
45
46
|
parent: TokenParserParent | ParserInstance;
|
|
46
47
|
maxLiteralSize: number;
|
|
48
|
+
maxResponseTokens: number;
|
|
49
|
+
/** Nodes created so far, checked against maxResponseTokens */
|
|
50
|
+
tokenCount: number;
|
|
47
51
|
tree: TokenNode;
|
|
48
52
|
currentNode: TokenNode;
|
|
49
53
|
pos: number;
|
|
@@ -62,6 +66,7 @@ export declare class TokenParser {
|
|
|
62
66
|
* @param options.literals - Pre-parsed literal values from the input stream.
|
|
63
67
|
* @param options.maxLiteralSize - Maximum size (in bytes) of a literal parsed inline
|
|
64
68
|
* from the input, i.e. when no pre-parsed literal buffers were supplied. Defaults to 1GB.
|
|
69
|
+
* @param options.maxResponseTokens - Maximum number of parse tree nodes, see MAX_RESPONSE_TOKENS.
|
|
65
70
|
*/
|
|
66
71
|
constructor(parent: TokenParserParent | ParserInstance, startPos?: number | undefined, str?: string | null | undefined, options?: ParserOptions | undefined);
|
|
67
72
|
/**
|
|
@@ -93,7 +98,8 @@ export declare class TokenParser {
|
|
|
93
98
|
* @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
|
|
94
99
|
* @param startPos - The starting position of this node in the original input string.
|
|
95
100
|
* @returns The newly created node with childNodes, type, value, and isClosed properties.
|
|
96
|
-
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item
|
|
101
|
+
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item, or
|
|
102
|
+
* the response has more than maxResponseTokens nodes.
|
|
97
103
|
*/
|
|
98
104
|
createNode(parentNode?: TokenNode | undefined, startPos?: number | undefined): TokenNode;
|
|
99
105
|
/**
|
|
@@ -45,6 +45,7 @@ class TokenParser {
|
|
|
45
45
|
* @param options.literals - Pre-parsed literal values from the input stream.
|
|
46
46
|
* @param options.maxLiteralSize - Maximum size (in bytes) of a literal parsed inline
|
|
47
47
|
* from the input, i.e. when no pre-parsed literal buffers were supplied. Defaults to 1GB.
|
|
48
|
+
* @param options.maxResponseTokens - Maximum number of parse tree nodes, see MAX_RESPONSE_TOKENS.
|
|
48
49
|
*/
|
|
49
50
|
constructor(parent, startPos, str, options) {
|
|
50
51
|
this.str = (str || '').toString();
|
|
@@ -53,6 +54,8 @@ class TokenParser {
|
|
|
53
54
|
// Same normalization and default as the streaming parser, so a direct user of this parser
|
|
54
55
|
// gets the same bound (an explicit 0 means "reject any non-empty inline literal").
|
|
55
56
|
this.maxLiteralSize = (0, limits_js_1.normalizeLimit)(this.options.maxLiteralSize, limits_js_1.MAX_LITERAL_SIZE);
|
|
57
|
+
this.maxResponseTokens = (0, limits_js_1.normalizeLimit)(this.options.maxResponseTokens, limits_js_1.MAX_RESPONSE_TOKENS);
|
|
58
|
+
this.tokenCount = 0;
|
|
56
59
|
this.truncatedAttributes = [];
|
|
57
60
|
this.tree = this.currentNode = this.createNode();
|
|
58
61
|
this.pos = startPos || 0;
|
|
@@ -89,7 +92,9 @@ class TokenParser {
|
|
|
89
92
|
await this.processString();
|
|
90
93
|
const attributes = [];
|
|
91
94
|
let branch = attributes;
|
|
92
|
-
|
|
95
|
+
// Synchronous on purpose: the recursion is bounded by MAX_NODE_DEPTH, and an async walk
|
|
96
|
+
// allocated a promise per token, which a line of many small tokens multiplies
|
|
97
|
+
let walk = (node) => {
|
|
93
98
|
let curBranch = branch;
|
|
94
99
|
let elm;
|
|
95
100
|
let partial;
|
|
@@ -146,12 +151,16 @@ class TokenParser {
|
|
|
146
151
|
branch[branch.length - 1].partial = partial;
|
|
147
152
|
break;
|
|
148
153
|
}
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
// Each child is released once it is converted, so the tree and the attributes built
|
|
155
|
+
// from it are not both held whole at the peak of a line of many tokens
|
|
156
|
+
let childNodes = node.childNodes;
|
|
157
|
+
for (let i = 0; childNodes && i < childNodes.length; i++) {
|
|
158
|
+
walk(childNodes[i]);
|
|
159
|
+
childNodes[i] = null;
|
|
151
160
|
}
|
|
152
161
|
branch = curBranch;
|
|
153
162
|
};
|
|
154
|
-
|
|
163
|
+
walk(this.tree);
|
|
155
164
|
return attributes;
|
|
156
165
|
}
|
|
157
166
|
/**
|
|
@@ -162,11 +171,20 @@ class TokenParser {
|
|
|
162
171
|
* @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
|
|
163
172
|
* @param startPos - The starting position of this node in the original input string.
|
|
164
173
|
* @returns The newly created node with childNodes, type, value, and isClosed properties.
|
|
165
|
-
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item
|
|
174
|
+
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item, or
|
|
175
|
+
* the response has more than maxResponseTokens nodes.
|
|
166
176
|
*/
|
|
167
177
|
createNode(parentNode, startPos) {
|
|
178
|
+
// The root node is not a token of the response
|
|
179
|
+
if (parentNode && ++this.tokenCount > this.maxResponseTokens) {
|
|
180
|
+
throw (0, errors_js_1.createImapError)(`Response has more than ${this.maxResponseTokens} tokens`, 'TooManyTokens', {
|
|
181
|
+
maxSize: this.maxResponseTokens,
|
|
182
|
+
parserContext: { ...(0, limits_js_1.boundedInput)(this.str), pos: startPos }
|
|
183
|
+
});
|
|
184
|
+
}
|
|
168
185
|
let node = {
|
|
169
|
-
|
|
186
|
+
// declared up front, so a node keeps one shape when its first child arrives
|
|
187
|
+
childNodes: undefined,
|
|
170
188
|
type: false,
|
|
171
189
|
value: '',
|
|
172
190
|
isClosed: true,
|
|
@@ -199,7 +217,7 @@ class TokenParser {
|
|
|
199
217
|
node.startPos = startPos;
|
|
200
218
|
}
|
|
201
219
|
if (parentNode) {
|
|
202
|
-
parentNode.childNodes.push(node);
|
|
220
|
+
(parentNode.childNodes ||= []).push(node);
|
|
203
221
|
}
|
|
204
222
|
return node;
|
|
205
223
|
}
|
|
@@ -211,7 +229,7 @@ class TokenParser {
|
|
|
211
229
|
*/
|
|
212
230
|
isFetchResponse() {
|
|
213
231
|
let command = this.parent.command;
|
|
214
|
-
let first = this.tree.childNodes[0];
|
|
232
|
+
let first = this.tree.childNodes?.[0];
|
|
215
233
|
return (typeof command === 'string' &&
|
|
216
234
|
/^\d+$/.test(command) &&
|
|
217
235
|
!!first &&
|
|
@@ -237,7 +255,7 @@ class TokenParser {
|
|
|
237
255
|
// The item is the last child of the list while it is being parsed, so its key is the
|
|
238
256
|
// child before it. A key is an atom (BODYSTRUCTURE, ENVELOPE, BODY); anything else means
|
|
239
257
|
// the row itself is malformed, and the key is reported as unknown.
|
|
240
|
-
let siblings = item.parentNode
|
|
258
|
+
let siblings = item.parentNode?.childNodes || [];
|
|
241
259
|
let key = siblings[siblings.length - 2];
|
|
242
260
|
this.truncatedAttributes.push(key && key.type === 'ATOM' && typeof key.value === 'string' ? key.value.toUpperCase() : '?');
|
|
243
261
|
}
|
|
@@ -265,13 +283,26 @@ class TokenParser {
|
|
|
265
283
|
// character, which is quadratic in the token length, and the server decides that length:
|
|
266
284
|
// a 600 KB ESEARCH result took seconds of event loop time
|
|
267
285
|
let tokenLength = 0;
|
|
286
|
+
// Where the token starts in the input, and whether its value still is that run of the
|
|
287
|
+
// input. A token is read as a run of characters, so its value is sliced from the input
|
|
288
|
+
// instead of grown with +=: each += of a long token adds a cons string of some twenty
|
|
289
|
+
// bytes per character, which the server multiplies with a long token
|
|
290
|
+
let tokenStart = 0;
|
|
291
|
+
let tokenSliced = true;
|
|
292
|
+
// Where the quoted string being read starts, and whether it holds an escape
|
|
293
|
+
let stringStart = 0;
|
|
294
|
+
let stringEscaped = false;
|
|
268
295
|
let tokenLast = '';
|
|
269
296
|
let tokenPrev = '';
|
|
270
297
|
let tokenDigitsOnly = true;
|
|
271
298
|
let tokenHasStar = false;
|
|
272
299
|
let tokenHasDot = false;
|
|
273
300
|
const appendToToken = (c) => {
|
|
274
|
-
|
|
301
|
+
if (!tokenLength) {
|
|
302
|
+
tokenStart = i;
|
|
303
|
+
}
|
|
304
|
+
tokenSliced = tokenSliced && i === tokenStart + tokenLength && this.str.charAt(i) === c;
|
|
305
|
+
this.currentNode.value = tokenSliced ? this.str.slice(tokenStart, i + 1) : this.currentNode.value + c;
|
|
275
306
|
tokenLength++;
|
|
276
307
|
tokenPrev = tokenLast;
|
|
277
308
|
tokenLast = c;
|
|
@@ -295,6 +326,7 @@ class TokenParser {
|
|
|
295
326
|
const startToken = (first) => {
|
|
296
327
|
this.currentNode.value = '';
|
|
297
328
|
tokenLength = 0;
|
|
329
|
+
tokenSliced = true;
|
|
298
330
|
tokenLast = tokenPrev = '';
|
|
299
331
|
tokenDigitsOnly = true;
|
|
300
332
|
tokenHasStar = tokenHasDot = false;
|
|
@@ -305,11 +337,14 @@ class TokenParser {
|
|
|
305
337
|
// ImapStream supplies one buffer per literal marker it framed, {0} included. A marker
|
|
306
338
|
// without a buffer means the line and its literals do not match up, so fail with a parser
|
|
307
339
|
// error instead of reading a missing buffer or shifting every later literal by one
|
|
340
|
+
// Read by index: shift() moves every remaining buffer, which made a response of many
|
|
341
|
+
// literals quadratic to parse
|
|
342
|
+
let literalIndex = 0;
|
|
308
343
|
const takeLiteral = (literals) => {
|
|
309
|
-
if (
|
|
344
|
+
if (literalIndex >= literals.length) {
|
|
310
345
|
throw this.parserError(`Literal without data at position ${this.pos + i} [E35]`, 'ParserError35', this.pos + i, chr);
|
|
311
346
|
}
|
|
312
|
-
return literals
|
|
347
|
+
return literals[literalIndex++];
|
|
313
348
|
};
|
|
314
349
|
// Any ATOM supported char starts a new Atom sequence, otherwise throw an error
|
|
315
350
|
// Allow \ as the first char for atom to support system flags
|
|
@@ -336,6 +371,8 @@ class TokenParser {
|
|
|
336
371
|
this.currentNode = this.createNode(this.currentNode, this.pos + i);
|
|
337
372
|
this.currentNode.type = 'string';
|
|
338
373
|
this.state = STATE_STRING;
|
|
374
|
+
stringStart = i + 1;
|
|
375
|
+
stringEscaped = false;
|
|
339
376
|
this.currentNode.isClosed = false;
|
|
340
377
|
break;
|
|
341
378
|
// ( starts a new list
|
|
@@ -566,8 +603,11 @@ class TokenParser {
|
|
|
566
603
|
appendToToken(chr);
|
|
567
604
|
break;
|
|
568
605
|
case STATE_STRING:
|
|
569
|
-
// DQUOTE ends the string sequence
|
|
606
|
+
// DQUOTE ends the string sequence. Its value is taken from the input in one
|
|
607
|
+
// piece, for the same reason tokens are sliced, see appendToToken()
|
|
570
608
|
if (chr === '"') {
|
|
609
|
+
let value = this.str.slice(stringStart, i);
|
|
610
|
+
this.currentNode.value = stringEscaped ? value.replace(/\\([\s\S])/g, '$1') : value;
|
|
571
611
|
this.currentNode.endPos = this.pos + i;
|
|
572
612
|
this.currentNode.isClosed = true;
|
|
573
613
|
this.currentNode = this.currentNode.parentNode;
|
|
@@ -575,15 +615,14 @@ class TokenParser {
|
|
|
575
615
|
checkSP();
|
|
576
616
|
break;
|
|
577
617
|
}
|
|
578
|
-
// \ Escapes the following char
|
|
618
|
+
// \ Escapes the following char, which is skipped here so it can not end the string
|
|
579
619
|
if (chr === '\\') {
|
|
580
620
|
i++;
|
|
581
621
|
if (i >= len) {
|
|
582
622
|
throw this.parserError(`Unexpected end of input at position ${this.pos + i} [E18]`, 'ParserError18', this.pos + i);
|
|
583
623
|
}
|
|
584
|
-
|
|
624
|
+
stringEscaped = true;
|
|
585
625
|
}
|
|
586
|
-
this.currentNode.value += chr;
|
|
587
626
|
break;
|
|
588
627
|
case STATE_PARTIAL:
|
|
589
628
|
if (chr === '>') {
|
|
@@ -38,8 +38,9 @@ export interface ImapResponse {
|
|
|
38
38
|
/** Parsed attributes of the response */
|
|
39
39
|
attributes?: ImapAttributeList | undefined;
|
|
40
40
|
/**
|
|
41
|
-
* The numbers of an untagged SEARCH response (`* SEARCH 2 5 9`), set
|
|
42
|
-
*
|
|
41
|
+
* The numbers of an untagged SEARCH response (`* SEARCH 2 5 9`), set when the line holds
|
|
42
|
+
* nothing else but a CONDSTORE `(MODSEQ n)` list, which is then the only entry of
|
|
43
|
+
* `attributes`, see parser()
|
|
43
44
|
*/
|
|
44
45
|
numbers?: number[] | undefined;
|
|
45
46
|
/** Number of leading NUL bytes stripped from the line, if any */
|
|
@@ -79,6 +80,8 @@ export interface ParserOptions {
|
|
|
79
80
|
literals?: Buffer[] | undefined;
|
|
80
81
|
/** Maximum size in bytes of a literal parsed inline from the input */
|
|
81
82
|
maxLiteralSize?: number | undefined;
|
|
83
|
+
/** Maximum number of tokens in the response, see MAX_RESPONSE_TOKENS */
|
|
84
|
+
maxResponseTokens?: number | undefined;
|
|
82
85
|
}
|
|
83
86
|
/**
|
|
84
87
|
* One assembled response as pushed by ImapStream: the line payload, its literals and the
|
package/dist/cjs/imap-flow.js
CHANGED
|
@@ -870,9 +870,17 @@ class ImapFlow extends node_events_1.EventEmitter {
|
|
|
870
870
|
async handleResponse(data) {
|
|
871
871
|
let parsed;
|
|
872
872
|
try {
|
|
873
|
-
parsed = await (0, imap_handler_js_1.parser)(data.payload, { literals: data.literals });
|
|
873
|
+
parsed = await (0, imap_handler_js_1.parser)(data.payload, { literals: data.literals, maxResponseTokens: this.options.maxResponseTokens });
|
|
874
874
|
}
|
|
875
875
|
catch (err) {
|
|
876
|
+
if (err.code === 'TooManyTokens') {
|
|
877
|
+
// A size limit, so as terminal as the ones ImapStream enforces: the server is
|
|
878
|
+
// flooding responses, and skipping the line would only invite the next one
|
|
879
|
+
this.log.error({ msg: 'Response exceeded the token limit', payloadBytes: data.payload.length, err, cid: this.id });
|
|
880
|
+
this.rejectCurrentRequest(err);
|
|
881
|
+
this.failProtocol(err);
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
876
884
|
// can not make sense of this. The payload can be up to the configured line
|
|
877
885
|
// cap (1GB by default), so log only a bounded prefix: a server looping
|
|
878
886
|
// unparseable garbage would otherwise turn this error log into a disk filler.
|
package/dist/cjs/package-info.js
CHANGED
package/dist/cjs/types.d.ts
CHANGED
|
@@ -133,8 +133,9 @@ export interface ImapFlowOptions {
|
|
|
133
133
|
* whatever is left of `maxResponseSize`, so lowering that also bounds line buffering.
|
|
134
134
|
* Exceeding it is terminal: the connection fails with error code `LineTooLarge` and
|
|
135
135
|
* no further input is parsed. The limit bounds the bytes of a line, not the memory its parse
|
|
136
|
-
* takes, which is several times the line size for a line of many small tokens
|
|
137
|
-
* answer of plain numbers is read into an array of about the
|
|
136
|
+
* takes, which is several times the line size for a line of many small tokens, see
|
|
137
|
+
* `maxResponseTokens`. A SEARCH answer of plain numbers is read into an array of about the
|
|
138
|
+
* size of the line.
|
|
138
139
|
*/
|
|
139
140
|
maxLineLength?: number | undefined;
|
|
140
141
|
/**
|
|
@@ -159,6 +160,16 @@ export interface ImapFlowOptions {
|
|
|
159
160
|
* no further input is parsed.
|
|
160
161
|
*/
|
|
161
162
|
maxResponseSize?: number | undefined;
|
|
163
|
+
/**
|
|
164
|
+
* Maximum number of tokens (atoms, strings, literals, lists and other parse tree nodes) in a
|
|
165
|
+
* single IMAP response. The byte limits bound what a server sends, this bounds what parsing it
|
|
166
|
+
* costs: each token takes around a hundred bytes of heap while the response is parsed, so a
|
|
167
|
+
* response of many small tokens costs about fifty times its size. Defaults to 1000000, far
|
|
168
|
+
* above any legitimate response (a SEARCH answer of plain numbers is not tokenized and does
|
|
169
|
+
* not count). Lower it in a memory-limited runtime. `Infinity` disables the limit. Exceeding it
|
|
170
|
+
* is terminal: the connection fails with error code `TooManyTokens`.
|
|
171
|
+
*/
|
|
172
|
+
maxResponseTokens?: number | undefined;
|
|
162
173
|
/**
|
|
163
174
|
* Threshold in milliseconds for warning that a mailbox lock has been held
|
|
164
175
|
* for a long time (diagnostic for forgotten release() calls). Defaults to
|
|
@@ -681,9 +692,12 @@ export type DownloadManyResult = {
|
|
|
681
692
|
export interface AppendResponseObject {
|
|
682
693
|
/** Full mailbox path where the message was uploaded to */
|
|
683
694
|
destination: string;
|
|
684
|
-
/** Mailbox UIDVALIDITY if server has UIDPLUS extension enabled */
|
|
695
|
+
/** Mailbox UIDVALIDITY if server has UIDPLUS extension enabled, only set together with the `uid` it reported */
|
|
685
696
|
uidValidity?: bigint | undefined;
|
|
686
|
-
/**
|
|
697
|
+
/**
|
|
698
|
+
* UID of the uploaded message if server has UIDPLUS extension enabled. Without a usable APPENDUID,
|
|
699
|
+
* an append to the selected mailbox looks it up by sequence number instead, and `uidValidity` is not set
|
|
700
|
+
*/
|
|
687
701
|
uid?: number | undefined;
|
|
688
702
|
/** Sequence number of the uploaded message if path is currently selected mailbox */
|
|
689
703
|
seq?: number | undefined;
|
|
@@ -141,11 +141,14 @@ export default async function append(connection, destination, content, flags, id
|
|
|
141
141
|
// message was already stored and a retrying caller would duplicate it.
|
|
142
142
|
let uidValidity = parseBigIntValue(section[1] && section[1].value, MAX_UINT32_DIGITS);
|
|
143
143
|
let uid = parseUintValue(section[2] && section[2].value, MAX_UINT32_DIGITS);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
// UIDVALIDITY is only reported next to the UID it was sent with: kept without one,
|
|
145
|
+
// it would end up paired with the UID the SEARCH fallback below guesses from the
|
|
146
|
+
// sequence number, and the pair would pass for a server-confirmed identity.
|
|
147
147
|
if (uid) {
|
|
148
148
|
map.uid = uid;
|
|
149
|
+
if (uidValidity) {
|
|
150
|
+
map.uidValidity = uidValidity;
|
|
151
|
+
}
|
|
149
152
|
}
|
|
150
153
|
}
|
|
151
154
|
}
|
package/dist/esm/errors.d.ts
CHANGED
|
@@ -35,6 +35,7 @@ export declare const ImapFlowErrorCode: {
|
|
|
35
35
|
readonly LineTooLarge: "LineTooLarge";
|
|
36
36
|
readonly LiteralTooLarge: "LiteralTooLarge";
|
|
37
37
|
readonly ResponseTooLarge: "ResponseTooLarge";
|
|
38
|
+
readonly TooManyTokens: "TooManyTokens";
|
|
38
39
|
readonly TooManyResponses: "TooManyResponses";
|
|
39
40
|
readonly InvalidStringValue: "InvalidStringValue";
|
|
40
41
|
readonly InvalidTokenValue: "InvalidTokenValue";
|
|
@@ -117,7 +118,7 @@ export interface ImapFlowError extends Error {
|
|
|
117
118
|
lineLength?: number | undefined;
|
|
118
119
|
/** The size of a rejected response */
|
|
119
120
|
responseSize?: number | undefined;
|
|
120
|
-
/** The bound that was exceeded */
|
|
121
|
+
/** The bound that was exceeded (bytes, or tokens for TooManyTokens) */
|
|
121
122
|
maxSize?: number | undefined;
|
|
122
123
|
/** OAuth error details from the server, for XOAUTH2 authentication failures */
|
|
123
124
|
oauthError?: any;
|
package/dist/esm/errors.js
CHANGED
|
@@ -39,6 +39,7 @@ export const ImapFlowErrorCode = {
|
|
|
39
39
|
LineTooLarge: 'LineTooLarge',
|
|
40
40
|
LiteralTooLarge: 'LiteralTooLarge',
|
|
41
41
|
ResponseTooLarge: 'ResponseTooLarge',
|
|
42
|
+
TooManyTokens: 'TooManyTokens',
|
|
42
43
|
// one command received more untagged responses than are kept, see MAX_COLLECTED_RESPONSES
|
|
43
44
|
TooManyResponses: 'TooManyResponses',
|
|
44
45
|
// invalid values in a command
|
|
@@ -3,8 +3,8 @@ import { ParserInstance } from './parser-instance.js';
|
|
|
3
3
|
import { createImapError } from '../errors.js';
|
|
4
4
|
import { boundedInput } from './limits.js';
|
|
5
5
|
// The codes the parser raises on purpose: the numbered ParserErrorN codes, ParserErrorExchange,
|
|
6
|
-
// the nesting limit
|
|
7
|
-
const PARSER_ERROR_CODES = /^(?:ParserError\d+|ParserErrorExchange|MAX_IMAP_NESTING_REACHED|LiteralTooLarge)$/;
|
|
6
|
+
// the nesting limit, the inline literal size limit and the token limit
|
|
7
|
+
const PARSER_ERROR_CODES = /^(?:ParserError\d+|ParserErrorExchange|MAX_IMAP_NESTING_REACHED|LiteralTooLarge|TooManyTokens)$/;
|
|
8
8
|
/**
|
|
9
9
|
* Anything the parser throws other than one of its own coded errors - a TypeError from a parser
|
|
10
10
|
* bug, a RangeError from runaway recursion or a bad Buffer offset (which carries a Node code), a
|
|
@@ -75,6 +75,39 @@ function readSearchNumbers(str) {
|
|
|
75
75
|
numbers.push(value);
|
|
76
76
|
return numbers;
|
|
77
77
|
}
|
|
78
|
+
// The CONDSTORE list that ends a SEARCH answer to a MODSEQ query (RFC 7162 section 3.1.5)
|
|
79
|
+
const RE_SEARCH_MODSEQ = /^ \(MODSEQ (\d{1,19})\) *$/i;
|
|
80
|
+
/**
|
|
81
|
+
* Reads an untagged SEARCH response without the tokenizer, see readSearchNumbers(): the numbers,
|
|
82
|
+
* and the MODSEQ value of a trailing CONDSTORE list. A MODSEQ query on a large mailbox matches as
|
|
83
|
+
* many messages as any other, so its answer must not fall back to the tokenizer either.
|
|
84
|
+
*
|
|
85
|
+
* @param str - The rest of the line after the command
|
|
86
|
+
* @returns The numbers, with the MODSEQ list as the attributes the tokenizer would have made of
|
|
87
|
+
* it, or null when the line is left to the tokenizer
|
|
88
|
+
*/
|
|
89
|
+
function readSearchResponse(str) {
|
|
90
|
+
// the last character tells whether there is a list to look for, without scanning the line
|
|
91
|
+
if (!str.trimEnd().endsWith(')')) {
|
|
92
|
+
const numbers = readSearchNumbers(str);
|
|
93
|
+
return numbers && { numbers };
|
|
94
|
+
}
|
|
95
|
+
const open = str.lastIndexOf(' (');
|
|
96
|
+
const match = open < 0 ? null : RE_SEARCH_MODSEQ.exec(str.slice(open));
|
|
97
|
+
if (!match) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const numbers = readSearchNumbers(str.slice(0, open));
|
|
101
|
+
return (numbers && {
|
|
102
|
+
numbers,
|
|
103
|
+
attributes: [
|
|
104
|
+
[
|
|
105
|
+
{ type: 'ATOM', value: 'MODSEQ' },
|
|
106
|
+
{ type: 'ATOM', value: match[1] }
|
|
107
|
+
]
|
|
108
|
+
]
|
|
109
|
+
});
|
|
110
|
+
}
|
|
78
111
|
/**
|
|
79
112
|
* Parses a raw IMAP command or response buffer into a structured object.
|
|
80
113
|
* Handles edge cases such as null-byte-padded responses from buggy servers and
|
|
@@ -130,10 +163,9 @@ export default async function parser(command, options) {
|
|
|
130
163
|
response.command += ' ' + (await parserInstance.getElement(imapFormalSyntax.command()));
|
|
131
164
|
}
|
|
132
165
|
if (response.tag === '*' && (response.command || '').toUpperCase() === 'SEARCH') {
|
|
133
|
-
const
|
|
134
|
-
if (
|
|
135
|
-
response
|
|
136
|
-
return response;
|
|
166
|
+
const search = readSearchResponse(parserInstance.remainder);
|
|
167
|
+
if (search) {
|
|
168
|
+
return Object.assign(response, search);
|
|
137
169
|
}
|
|
138
170
|
}
|
|
139
171
|
if (parserInstance.remainder.trim().length) {
|
|
@@ -2,6 +2,7 @@ import { type ImapFlowError } from '../errors.js';
|
|
|
2
2
|
export declare const MAX_LITERAL_SIZE: number;
|
|
3
3
|
export declare const MAX_LINE_SIZE: number;
|
|
4
4
|
export declare const MAX_RESPONSE_SIZE: number;
|
|
5
|
+
export declare const MAX_RESPONSE_TOKENS = 1000000;
|
|
5
6
|
export declare const ERROR_CONTEXT_LENGTH = 1024;
|
|
6
7
|
/**
|
|
7
8
|
* The input a parse error carries: a bounded prefix with the full length.
|
|
@@ -18,6 +18,13 @@ export const MAX_LINE_SIZE = MAX_LITERAL_SIZE;
|
|
|
18
18
|
// of exactly the maximum permitted size impossible to receive. Configuring both limits calls for
|
|
19
19
|
// the same headroom - set maxResponseSize above maxLiteralSize, not equal to it.
|
|
20
20
|
export const MAX_RESPONSE_SIZE = 2 * MAX_LITERAL_SIZE;
|
|
21
|
+
// Default maximum number of tokens (atoms, strings, literals, lists and the other parse tree
|
|
22
|
+
// nodes) in a single response. The byte limits bound what a server sends, not what parsing it
|
|
23
|
+
// costs: every token becomes a tree node and an attribute object, around a hundred bytes of heap
|
|
24
|
+
// for a two-byte "1 ", so a line of many small tokens costs fifty times its size. A legitimate
|
|
25
|
+
// response stays far below this (the long one, a SEARCH answer of plain numbers, is read without
|
|
26
|
+
// the tokenizer, see parser()), so the bound only stops a server that floods one response.
|
|
27
|
+
export const MAX_RESPONSE_TOKENS = 1000000;
|
|
21
28
|
// How much of the offending input a parse error carries along. The error travels whole into log
|
|
22
29
|
// entries (pino copies every property of a logged error) and into the rejection of the command
|
|
23
30
|
// the line belonged to, and the line can be as long as the configured line cap.
|
|
@@ -7,7 +7,8 @@ import type { ParserInstance } from './parser-instance.js';
|
|
|
7
7
|
* (ATOM, string, LITERAL, SEQUENCE, LIST, SECTION, PARTIAL).
|
|
8
8
|
*/
|
|
9
9
|
export interface TokenNode {
|
|
10
|
-
|
|
10
|
+
/** Created with the first child, a leaf token does not carry an empty array */
|
|
11
|
+
childNodes?: TokenNode[] | undefined;
|
|
11
12
|
type: string | false;
|
|
12
13
|
value: string | Buffer;
|
|
13
14
|
isClosed: boolean;
|
|
@@ -44,6 +45,9 @@ export declare class TokenParser {
|
|
|
44
45
|
options: ParserOptions;
|
|
45
46
|
parent: TokenParserParent | ParserInstance;
|
|
46
47
|
maxLiteralSize: number;
|
|
48
|
+
maxResponseTokens: number;
|
|
49
|
+
/** Nodes created so far, checked against maxResponseTokens */
|
|
50
|
+
tokenCount: number;
|
|
47
51
|
tree: TokenNode;
|
|
48
52
|
currentNode: TokenNode;
|
|
49
53
|
pos: number;
|
|
@@ -62,6 +66,7 @@ export declare class TokenParser {
|
|
|
62
66
|
* @param options.literals - Pre-parsed literal values from the input stream.
|
|
63
67
|
* @param options.maxLiteralSize - Maximum size (in bytes) of a literal parsed inline
|
|
64
68
|
* from the input, i.e. when no pre-parsed literal buffers were supplied. Defaults to 1GB.
|
|
69
|
+
* @param options.maxResponseTokens - Maximum number of parse tree nodes, see MAX_RESPONSE_TOKENS.
|
|
65
70
|
*/
|
|
66
71
|
constructor(parent: TokenParserParent | ParserInstance, startPos?: number | undefined, str?: string | null | undefined, options?: ParserOptions | undefined);
|
|
67
72
|
/**
|
|
@@ -93,7 +98,8 @@ export declare class TokenParser {
|
|
|
93
98
|
* @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
|
|
94
99
|
* @param startPos - The starting position of this node in the original input string.
|
|
95
100
|
* @returns The newly created node with childNodes, type, value, and isClosed properties.
|
|
96
|
-
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item
|
|
101
|
+
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item, or
|
|
102
|
+
* the response has more than maxResponseTokens nodes.
|
|
97
103
|
*/
|
|
98
104
|
createNode(parentNode?: TokenNode | undefined, startPos?: number | undefined): TokenNode;
|
|
99
105
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint new-cap: 0 */
|
|
2
2
|
import imapFormalSyntax from './imap-formal-syntax.js';
|
|
3
|
-
import { MAX_LITERAL_SIZE, ERROR_CONTEXT_LENGTH, boundedInput, normalizeLimit, createLiteralTooLargeError } from './limits.js';
|
|
3
|
+
import { MAX_LITERAL_SIZE, MAX_RESPONSE_TOKENS, ERROR_CONTEXT_LENGTH, boundedInput, normalizeLimit, createLiteralTooLargeError } from './limits.js';
|
|
4
4
|
import { createImapError } from '../errors.js';
|
|
5
5
|
const STATE_ATOM = 0x001;
|
|
6
6
|
const STATE_LITERAL = 0x002;
|
|
@@ -39,6 +39,7 @@ export class TokenParser {
|
|
|
39
39
|
* @param options.literals - Pre-parsed literal values from the input stream.
|
|
40
40
|
* @param options.maxLiteralSize - Maximum size (in bytes) of a literal parsed inline
|
|
41
41
|
* from the input, i.e. when no pre-parsed literal buffers were supplied. Defaults to 1GB.
|
|
42
|
+
* @param options.maxResponseTokens - Maximum number of parse tree nodes, see MAX_RESPONSE_TOKENS.
|
|
42
43
|
*/
|
|
43
44
|
constructor(parent, startPos, str, options) {
|
|
44
45
|
this.str = (str || '').toString();
|
|
@@ -47,6 +48,8 @@ export class TokenParser {
|
|
|
47
48
|
// Same normalization and default as the streaming parser, so a direct user of this parser
|
|
48
49
|
// gets the same bound (an explicit 0 means "reject any non-empty inline literal").
|
|
49
50
|
this.maxLiteralSize = normalizeLimit(this.options.maxLiteralSize, MAX_LITERAL_SIZE);
|
|
51
|
+
this.maxResponseTokens = normalizeLimit(this.options.maxResponseTokens, MAX_RESPONSE_TOKENS);
|
|
52
|
+
this.tokenCount = 0;
|
|
50
53
|
this.truncatedAttributes = [];
|
|
51
54
|
this.tree = this.currentNode = this.createNode();
|
|
52
55
|
this.pos = startPos || 0;
|
|
@@ -83,7 +86,9 @@ export class TokenParser {
|
|
|
83
86
|
await this.processString();
|
|
84
87
|
const attributes = [];
|
|
85
88
|
let branch = attributes;
|
|
86
|
-
|
|
89
|
+
// Synchronous on purpose: the recursion is bounded by MAX_NODE_DEPTH, and an async walk
|
|
90
|
+
// allocated a promise per token, which a line of many small tokens multiplies
|
|
91
|
+
let walk = (node) => {
|
|
87
92
|
let curBranch = branch;
|
|
88
93
|
let elm;
|
|
89
94
|
let partial;
|
|
@@ -140,12 +145,16 @@ export class TokenParser {
|
|
|
140
145
|
branch[branch.length - 1].partial = partial;
|
|
141
146
|
break;
|
|
142
147
|
}
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
// Each child is released once it is converted, so the tree and the attributes built
|
|
149
|
+
// from it are not both held whole at the peak of a line of many tokens
|
|
150
|
+
let childNodes = node.childNodes;
|
|
151
|
+
for (let i = 0; childNodes && i < childNodes.length; i++) {
|
|
152
|
+
walk(childNodes[i]);
|
|
153
|
+
childNodes[i] = null;
|
|
145
154
|
}
|
|
146
155
|
branch = curBranch;
|
|
147
156
|
};
|
|
148
|
-
|
|
157
|
+
walk(this.tree);
|
|
149
158
|
return attributes;
|
|
150
159
|
}
|
|
151
160
|
/**
|
|
@@ -156,11 +165,20 @@ export class TokenParser {
|
|
|
156
165
|
* @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
|
|
157
166
|
* @param startPos - The starting position of this node in the original input string.
|
|
158
167
|
* @returns The newly created node with childNodes, type, value, and isClosed properties.
|
|
159
|
-
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item
|
|
168
|
+
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item, or
|
|
169
|
+
* the response has more than maxResponseTokens nodes.
|
|
160
170
|
*/
|
|
161
171
|
createNode(parentNode, startPos) {
|
|
172
|
+
// The root node is not a token of the response
|
|
173
|
+
if (parentNode && ++this.tokenCount > this.maxResponseTokens) {
|
|
174
|
+
throw createImapError(`Response has more than ${this.maxResponseTokens} tokens`, 'TooManyTokens', {
|
|
175
|
+
maxSize: this.maxResponseTokens,
|
|
176
|
+
parserContext: { ...boundedInput(this.str), pos: startPos }
|
|
177
|
+
});
|
|
178
|
+
}
|
|
162
179
|
let node = {
|
|
163
|
-
|
|
180
|
+
// declared up front, so a node keeps one shape when its first child arrives
|
|
181
|
+
childNodes: undefined,
|
|
164
182
|
type: false,
|
|
165
183
|
value: '',
|
|
166
184
|
isClosed: true,
|
|
@@ -193,7 +211,7 @@ export class TokenParser {
|
|
|
193
211
|
node.startPos = startPos;
|
|
194
212
|
}
|
|
195
213
|
if (parentNode) {
|
|
196
|
-
parentNode.childNodes.push(node);
|
|
214
|
+
(parentNode.childNodes ||= []).push(node);
|
|
197
215
|
}
|
|
198
216
|
return node;
|
|
199
217
|
}
|
|
@@ -205,7 +223,7 @@ export class TokenParser {
|
|
|
205
223
|
*/
|
|
206
224
|
isFetchResponse() {
|
|
207
225
|
let command = this.parent.command;
|
|
208
|
-
let first = this.tree.childNodes[0];
|
|
226
|
+
let first = this.tree.childNodes?.[0];
|
|
209
227
|
return (typeof command === 'string' &&
|
|
210
228
|
/^\d+$/.test(command) &&
|
|
211
229
|
!!first &&
|
|
@@ -231,7 +249,7 @@ export class TokenParser {
|
|
|
231
249
|
// The item is the last child of the list while it is being parsed, so its key is the
|
|
232
250
|
// child before it. A key is an atom (BODYSTRUCTURE, ENVELOPE, BODY); anything else means
|
|
233
251
|
// the row itself is malformed, and the key is reported as unknown.
|
|
234
|
-
let siblings = item.parentNode
|
|
252
|
+
let siblings = item.parentNode?.childNodes || [];
|
|
235
253
|
let key = siblings[siblings.length - 2];
|
|
236
254
|
this.truncatedAttributes.push(key && key.type === 'ATOM' && typeof key.value === 'string' ? key.value.toUpperCase() : '?');
|
|
237
255
|
}
|
|
@@ -259,13 +277,26 @@ export class TokenParser {
|
|
|
259
277
|
// character, which is quadratic in the token length, and the server decides that length:
|
|
260
278
|
// a 600 KB ESEARCH result took seconds of event loop time
|
|
261
279
|
let tokenLength = 0;
|
|
280
|
+
// Where the token starts in the input, and whether its value still is that run of the
|
|
281
|
+
// input. A token is read as a run of characters, so its value is sliced from the input
|
|
282
|
+
// instead of grown with +=: each += of a long token adds a cons string of some twenty
|
|
283
|
+
// bytes per character, which the server multiplies with a long token
|
|
284
|
+
let tokenStart = 0;
|
|
285
|
+
let tokenSliced = true;
|
|
286
|
+
// Where the quoted string being read starts, and whether it holds an escape
|
|
287
|
+
let stringStart = 0;
|
|
288
|
+
let stringEscaped = false;
|
|
262
289
|
let tokenLast = '';
|
|
263
290
|
let tokenPrev = '';
|
|
264
291
|
let tokenDigitsOnly = true;
|
|
265
292
|
let tokenHasStar = false;
|
|
266
293
|
let tokenHasDot = false;
|
|
267
294
|
const appendToToken = (c) => {
|
|
268
|
-
|
|
295
|
+
if (!tokenLength) {
|
|
296
|
+
tokenStart = i;
|
|
297
|
+
}
|
|
298
|
+
tokenSliced = tokenSliced && i === tokenStart + tokenLength && this.str.charAt(i) === c;
|
|
299
|
+
this.currentNode.value = tokenSliced ? this.str.slice(tokenStart, i + 1) : this.currentNode.value + c;
|
|
269
300
|
tokenLength++;
|
|
270
301
|
tokenPrev = tokenLast;
|
|
271
302
|
tokenLast = c;
|
|
@@ -289,6 +320,7 @@ export class TokenParser {
|
|
|
289
320
|
const startToken = (first) => {
|
|
290
321
|
this.currentNode.value = '';
|
|
291
322
|
tokenLength = 0;
|
|
323
|
+
tokenSliced = true;
|
|
292
324
|
tokenLast = tokenPrev = '';
|
|
293
325
|
tokenDigitsOnly = true;
|
|
294
326
|
tokenHasStar = tokenHasDot = false;
|
|
@@ -299,11 +331,14 @@ export class TokenParser {
|
|
|
299
331
|
// ImapStream supplies one buffer per literal marker it framed, {0} included. A marker
|
|
300
332
|
// without a buffer means the line and its literals do not match up, so fail with a parser
|
|
301
333
|
// error instead of reading a missing buffer or shifting every later literal by one
|
|
334
|
+
// Read by index: shift() moves every remaining buffer, which made a response of many
|
|
335
|
+
// literals quadratic to parse
|
|
336
|
+
let literalIndex = 0;
|
|
302
337
|
const takeLiteral = (literals) => {
|
|
303
|
-
if (
|
|
338
|
+
if (literalIndex >= literals.length) {
|
|
304
339
|
throw this.parserError(`Literal without data at position ${this.pos + i} [E35]`, 'ParserError35', this.pos + i, chr);
|
|
305
340
|
}
|
|
306
|
-
return literals
|
|
341
|
+
return literals[literalIndex++];
|
|
307
342
|
};
|
|
308
343
|
// Any ATOM supported char starts a new Atom sequence, otherwise throw an error
|
|
309
344
|
// Allow \ as the first char for atom to support system flags
|
|
@@ -330,6 +365,8 @@ export class TokenParser {
|
|
|
330
365
|
this.currentNode = this.createNode(this.currentNode, this.pos + i);
|
|
331
366
|
this.currentNode.type = 'string';
|
|
332
367
|
this.state = STATE_STRING;
|
|
368
|
+
stringStart = i + 1;
|
|
369
|
+
stringEscaped = false;
|
|
333
370
|
this.currentNode.isClosed = false;
|
|
334
371
|
break;
|
|
335
372
|
// ( starts a new list
|
|
@@ -560,8 +597,11 @@ export class TokenParser {
|
|
|
560
597
|
appendToToken(chr);
|
|
561
598
|
break;
|
|
562
599
|
case STATE_STRING:
|
|
563
|
-
// DQUOTE ends the string sequence
|
|
600
|
+
// DQUOTE ends the string sequence. Its value is taken from the input in one
|
|
601
|
+
// piece, for the same reason tokens are sliced, see appendToToken()
|
|
564
602
|
if (chr === '"') {
|
|
603
|
+
let value = this.str.slice(stringStart, i);
|
|
604
|
+
this.currentNode.value = stringEscaped ? value.replace(/\\([\s\S])/g, '$1') : value;
|
|
565
605
|
this.currentNode.endPos = this.pos + i;
|
|
566
606
|
this.currentNode.isClosed = true;
|
|
567
607
|
this.currentNode = this.currentNode.parentNode;
|
|
@@ -569,15 +609,14 @@ export class TokenParser {
|
|
|
569
609
|
checkSP();
|
|
570
610
|
break;
|
|
571
611
|
}
|
|
572
|
-
// \ Escapes the following char
|
|
612
|
+
// \ Escapes the following char, which is skipped here so it can not end the string
|
|
573
613
|
if (chr === '\\') {
|
|
574
614
|
i++;
|
|
575
615
|
if (i >= len) {
|
|
576
616
|
throw this.parserError(`Unexpected end of input at position ${this.pos + i} [E18]`, 'ParserError18', this.pos + i);
|
|
577
617
|
}
|
|
578
|
-
|
|
618
|
+
stringEscaped = true;
|
|
579
619
|
}
|
|
580
|
-
this.currentNode.value += chr;
|
|
581
620
|
break;
|
|
582
621
|
case STATE_PARTIAL:
|
|
583
622
|
if (chr === '>') {
|
|
@@ -38,8 +38,9 @@ export interface ImapResponse {
|
|
|
38
38
|
/** Parsed attributes of the response */
|
|
39
39
|
attributes?: ImapAttributeList | undefined;
|
|
40
40
|
/**
|
|
41
|
-
* The numbers of an untagged SEARCH response (`* SEARCH 2 5 9`), set
|
|
42
|
-
*
|
|
41
|
+
* The numbers of an untagged SEARCH response (`* SEARCH 2 5 9`), set when the line holds
|
|
42
|
+
* nothing else but a CONDSTORE `(MODSEQ n)` list, which is then the only entry of
|
|
43
|
+
* `attributes`, see parser()
|
|
43
44
|
*/
|
|
44
45
|
numbers?: number[] | undefined;
|
|
45
46
|
/** Number of leading NUL bytes stripped from the line, if any */
|
|
@@ -79,6 +80,8 @@ export interface ParserOptions {
|
|
|
79
80
|
literals?: Buffer[] | undefined;
|
|
80
81
|
/** Maximum size in bytes of a literal parsed inline from the input */
|
|
81
82
|
maxLiteralSize?: number | undefined;
|
|
83
|
+
/** Maximum number of tokens in the response, see MAX_RESPONSE_TOKENS */
|
|
84
|
+
maxResponseTokens?: number | undefined;
|
|
82
85
|
}
|
|
83
86
|
/**
|
|
84
87
|
* One assembled response as pushed by ImapStream: the line payload, its literals and the
|
package/dist/esm/imap-flow.js
CHANGED
|
@@ -827,9 +827,17 @@ export class ImapFlow extends EventEmitter {
|
|
|
827
827
|
async handleResponse(data) {
|
|
828
828
|
let parsed;
|
|
829
829
|
try {
|
|
830
|
-
parsed = await parser(data.payload, { literals: data.literals });
|
|
830
|
+
parsed = await parser(data.payload, { literals: data.literals, maxResponseTokens: this.options.maxResponseTokens });
|
|
831
831
|
}
|
|
832
832
|
catch (err) {
|
|
833
|
+
if (err.code === 'TooManyTokens') {
|
|
834
|
+
// A size limit, so as terminal as the ones ImapStream enforces: the server is
|
|
835
|
+
// flooding responses, and skipping the line would only invite the next one
|
|
836
|
+
this.log.error({ msg: 'Response exceeded the token limit', payloadBytes: data.payload.length, err, cid: this.id });
|
|
837
|
+
this.rejectCurrentRequest(err);
|
|
838
|
+
this.failProtocol(err);
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
833
841
|
// can not make sense of this. The payload can be up to the configured line
|
|
834
842
|
// cap (1GB by default), so log only a bounded prefix: a server looping
|
|
835
843
|
// unparseable garbage would otherwise turn this error log into a disk filler.
|
package/dist/esm/package-info.js
CHANGED
package/dist/esm/types.d.ts
CHANGED
|
@@ -133,8 +133,9 @@ export interface ImapFlowOptions {
|
|
|
133
133
|
* whatever is left of `maxResponseSize`, so lowering that also bounds line buffering.
|
|
134
134
|
* Exceeding it is terminal: the connection fails with error code `LineTooLarge` and
|
|
135
135
|
* no further input is parsed. The limit bounds the bytes of a line, not the memory its parse
|
|
136
|
-
* takes, which is several times the line size for a line of many small tokens
|
|
137
|
-
* answer of plain numbers is read into an array of about the
|
|
136
|
+
* takes, which is several times the line size for a line of many small tokens, see
|
|
137
|
+
* `maxResponseTokens`. A SEARCH answer of plain numbers is read into an array of about the
|
|
138
|
+
* size of the line.
|
|
138
139
|
*/
|
|
139
140
|
maxLineLength?: number | undefined;
|
|
140
141
|
/**
|
|
@@ -159,6 +160,16 @@ export interface ImapFlowOptions {
|
|
|
159
160
|
* no further input is parsed.
|
|
160
161
|
*/
|
|
161
162
|
maxResponseSize?: number | undefined;
|
|
163
|
+
/**
|
|
164
|
+
* Maximum number of tokens (atoms, strings, literals, lists and other parse tree nodes) in a
|
|
165
|
+
* single IMAP response. The byte limits bound what a server sends, this bounds what parsing it
|
|
166
|
+
* costs: each token takes around a hundred bytes of heap while the response is parsed, so a
|
|
167
|
+
* response of many small tokens costs about fifty times its size. Defaults to 1000000, far
|
|
168
|
+
* above any legitimate response (a SEARCH answer of plain numbers is not tokenized and does
|
|
169
|
+
* not count). Lower it in a memory-limited runtime. `Infinity` disables the limit. Exceeding it
|
|
170
|
+
* is terminal: the connection fails with error code `TooManyTokens`.
|
|
171
|
+
*/
|
|
172
|
+
maxResponseTokens?: number | undefined;
|
|
162
173
|
/**
|
|
163
174
|
* Threshold in milliseconds for warning that a mailbox lock has been held
|
|
164
175
|
* for a long time (diagnostic for forgotten release() calls). Defaults to
|
|
@@ -681,9 +692,12 @@ export type DownloadManyResult = {
|
|
|
681
692
|
export interface AppendResponseObject {
|
|
682
693
|
/** Full mailbox path where the message was uploaded to */
|
|
683
694
|
destination: string;
|
|
684
|
-
/** Mailbox UIDVALIDITY if server has UIDPLUS extension enabled */
|
|
695
|
+
/** Mailbox UIDVALIDITY if server has UIDPLUS extension enabled, only set together with the `uid` it reported */
|
|
685
696
|
uidValidity?: bigint | undefined;
|
|
686
|
-
/**
|
|
697
|
+
/**
|
|
698
|
+
* UID of the uploaded message if server has UIDPLUS extension enabled. Without a usable APPENDUID,
|
|
699
|
+
* an append to the selected mailbox looks it up by sequence number instead, and `uidValidity` is not set
|
|
700
|
+
*/
|
|
687
701
|
uid?: number | undefined;
|
|
688
702
|
/** Sequence number of the uploaded message if path is currently selected mailbox */
|
|
689
703
|
seq?: number | undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.9",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cjs/imap-flow.js",
|
|
@@ -93,8 +93,8 @@
|
|
|
93
93
|
"tsx": "4.23.15",
|
|
94
94
|
"types-node-legacy": "npm:@types/node@20.0.0",
|
|
95
95
|
"typescript": "6.0.3",
|
|
96
|
-
"typescript-eslint": "8.
|
|
97
|
-
"wrangler": "4.151.
|
|
96
|
+
"typescript-eslint": "8.72.0",
|
|
97
|
+
"wrangler": "4.151.3"
|
|
98
98
|
},
|
|
99
99
|
"dependencies": {
|
|
100
100
|
"@zone-eu/mailsplit": "5.4.27",
|