imapflow 2.5.0 → 2.5.2
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 +15 -0
- package/dist/cjs/commands/authenticate.js +32 -1
- package/dist/cjs/commands/fetch.js +10 -3
- package/dist/cjs/commands/idle.js +31 -1
- package/dist/cjs/commands/list.js +14 -0
- package/dist/cjs/errors.d.ts +1 -0
- package/dist/cjs/errors.js +2 -0
- package/dist/cjs/handler/imap-parser.js +3 -0
- package/dist/cjs/handler/parser-instance.d.ts +2 -0
- package/dist/cjs/handler/parser-instance.js +3 -1
- package/dist/cjs/handler/token-parser.d.ts +21 -2
- package/dist/cjs/handler/token-parser.js +76 -6
- package/dist/cjs/handler/types.d.ts +5 -0
- package/dist/cjs/imap-flow.js +37 -5
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/cjs/tools.d.ts +21 -0
- package/dist/cjs/tools.js +118 -52
- package/dist/cjs/types.d.ts +2 -1
- package/dist/esm/commands/authenticate.js +32 -1
- package/dist/esm/commands/fetch.js +11 -4
- package/dist/esm/commands/idle.js +31 -1
- package/dist/esm/commands/list.js +15 -1
- package/dist/esm/errors.d.ts +1 -0
- package/dist/esm/errors.js +2 -0
- package/dist/esm/handler/imap-parser.js +3 -0
- package/dist/esm/handler/parser-instance.d.ts +2 -0
- package/dist/esm/handler/parser-instance.js +3 -1
- package/dist/esm/handler/token-parser.d.ts +21 -2
- package/dist/esm/handler/token-parser.js +76 -6
- package/dist/esm/handler/types.d.ts +5 -0
- package/dist/esm/imap-flow.js +38 -6
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/dist/esm/tools.d.ts +21 -0
- package/dist/esm/tools.js +115 -51
- package/dist/esm/types.d.ts +2 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.5.2](https://github.com/postalsys/imapflow/compare/v2.5.1...v2.5.2) (2026-10-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* bump @zone-eu/mailsplit to 5.4.23 ([e758c81](https://github.com/postalsys/imapflow/commit/e758c816a608a75a9553f34d8790abaf7004a328))
|
|
9
|
+
* bump libmime to 5.4.9 and libqp to 2.1.3 ([0ea7730](https://github.com/postalsys/imapflow/commit/0ea77302e898bc5b1b62651bf32c97383df7de62))
|
|
10
|
+
|
|
11
|
+
## [2.5.1](https://github.com/postalsys/imapflow/compare/v2.5.0...v2.5.1) (2026-10-16)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* bound what a hostile server can make the client buffer or wait for, and keep a tagged BYE from passing as success ([9b969a7](https://github.com/postalsys/imapflow/commit/9b969a75090b84e3cbdea408ac4f36035eb2e71b))
|
|
17
|
+
|
|
3
18
|
## [2.5.0](https://github.com/postalsys/imapflow/compare/v2.4.3...v2.5.0) (2026-10-16)
|
|
4
19
|
|
|
5
20
|
|
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.default = authenticate;
|
|
4
4
|
const tools_js_1 = require("../tools.js");
|
|
5
|
+
const errors_js_1 = require("../errors.js");
|
|
6
|
+
// The OAuth SASL payloads are framed with \x01 (RFC 7628 section 3.1, and the XOAUTH2 format
|
|
7
|
+
// copies it), so a control character in a value ends the field early and the rest of the value
|
|
8
|
+
// becomes fields of its own, auth= included. RFC 7628 allows only VCHAR, SP, HTAB, CR and LF in
|
|
9
|
+
// a value, and none of the controls has a place in a username or a bearer token.
|
|
10
|
+
const RE_CONTROL_CHARS = /[\x00-\x1f\x7f]/;
|
|
11
|
+
/**
|
|
12
|
+
* Refuses a username or token that could not be framed as one SASL field.
|
|
13
|
+
*
|
|
14
|
+
* @param value - The value about to be placed in the payload
|
|
15
|
+
* @param what - What the value is, for the error message
|
|
16
|
+
* @throws If the value contains a control character
|
|
17
|
+
*/
|
|
18
|
+
function assertSaslValue(value, what) {
|
|
19
|
+
if (RE_CONTROL_CHARS.test(value)) {
|
|
20
|
+
throw (0, errors_js_1.createImapError)(`The ${what} contains control characters`, 'InvalidStringValue');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Escapes a value for the authzid of a GS2 header (RFC 5801 section 4): "," and "=" delimit
|
|
25
|
+
* the header, so they are written as =2C and =3D. "=" goes first, so the "=" the escaping
|
|
26
|
+
* itself introduces is not escaped again.
|
|
27
|
+
*
|
|
28
|
+
* @param value - The saslname
|
|
29
|
+
* @returns The escaped saslname
|
|
30
|
+
*/
|
|
31
|
+
function escapeSaslName(value) {
|
|
32
|
+
return value.replace(/[=]/g, '=3D').replace(/,/g, '=2C');
|
|
33
|
+
}
|
|
5
34
|
/**
|
|
6
35
|
* Handles authentication errors by enriching the error object with server response details.
|
|
7
36
|
*
|
|
@@ -37,6 +66,8 @@ async function authOauth(connection, username, accessToken) {
|
|
|
37
66
|
let oauthbearer;
|
|
38
67
|
let command;
|
|
39
68
|
let breaker;
|
|
69
|
+
assertSaslValue(username, 'username');
|
|
70
|
+
assertSaslValue(accessToken, 'access token');
|
|
40
71
|
if (connection.capabilities.has('AUTH=OAUTHBEARER')) {
|
|
41
72
|
// OAUTHBEARER payload per RFC 7628: fields separated by \x01 (SASL GS2 framing).
|
|
42
73
|
// Format: "n,a=<user>," \x01 "host=..." \x01 "port=..." \x01 "auth=Bearer <token>" \x01 \x01
|
|
@@ -47,7 +78,7 @@ async function authOauth(connection, username, accessToken) {
|
|
|
47
78
|
// A server validating either field rejects with status `invalid_request` - a permanent
|
|
48
79
|
// failure that refreshing the access token can never clear.
|
|
49
80
|
oauthbearer = [
|
|
50
|
-
`n,a=${username},`,
|
|
81
|
+
`n,a=${escapeSaslName(username)},`,
|
|
51
82
|
`host=${connection.servername || connection.host}`,
|
|
52
83
|
`port=${connection.port}`,
|
|
53
84
|
`auth=Bearer ${accessToken}`,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.default = fetch;
|
|
4
4
|
const tools_js_1 = require("../tools.js");
|
|
5
|
+
const errors_js_1 = require("../errors.js");
|
|
5
6
|
/**
|
|
6
7
|
* Fetches emails from the server.
|
|
7
8
|
*
|
|
@@ -38,9 +39,10 @@ async function fetch(connection, range, query, options) {
|
|
|
38
39
|
count: 0,
|
|
39
40
|
list: []
|
|
40
41
|
};
|
|
41
|
-
// The first error the onUntaggedFetch consumer reported through next(err)
|
|
42
|
-
// by untagged handlers are only logged by the
|
|
43
|
-
// the command once the FETCH completes; later
|
|
42
|
+
// The first error the onUntaggedFetch consumer reported through next(err), or the row
|
|
43
|
+
// limit being reached. Errors thrown by untagged handlers are only logged by the
|
|
44
|
+
// connection, so it is kept here and fails the command once the FETCH completes; later
|
|
45
|
+
// messages are no longer handed to the consumer or collected.
|
|
44
46
|
let consumerError = null;
|
|
45
47
|
let response;
|
|
46
48
|
try {
|
|
@@ -235,6 +237,11 @@ async function fetch(connection, range, query, options) {
|
|
|
235
237
|
consumerError = err;
|
|
236
238
|
}
|
|
237
239
|
}
|
|
240
|
+
else if (messages.list.length >= tools_js_1.MAX_COLLECTED_RESPONSES) {
|
|
241
|
+
// The rows are the server's to send and the memory holding them is
|
|
242
|
+
// this process's, see MAX_COLLECTED_RESPONSES
|
|
243
|
+
consumerError = (0, errors_js_1.createImapError)(`FETCH returned more than ${tools_js_1.MAX_COLLECTED_RESPONSES} messages`, 'TooManyResponses');
|
|
244
|
+
}
|
|
238
245
|
else {
|
|
239
246
|
messages.list.push(formatted);
|
|
240
247
|
}
|
|
@@ -40,6 +40,12 @@ async function runIdle(connection) {
|
|
|
40
40
|
// connection, so a newer IDLE session that already installed its own is left alone.
|
|
41
41
|
let ownPreCheck = null;
|
|
42
42
|
let releaseIdling = claimIdling(connection);
|
|
43
|
+
// The deadline for the server's "+" continuation, see the onSend handler below
|
|
44
|
+
let continuationTimer = null;
|
|
45
|
+
let clearContinuationTimer = () => {
|
|
46
|
+
(0, tools_js_1.clearTimer)(continuationTimer);
|
|
47
|
+
continuationTimer = null;
|
|
48
|
+
};
|
|
43
49
|
try {
|
|
44
50
|
// State flags for the IDLE lifecycle:
|
|
45
51
|
// - doneRequested: someone wants to break IDLE (e.g., to run another command)
|
|
@@ -105,6 +111,7 @@ async function runIdle(connection) {
|
|
|
105
111
|
// After this, the server will push untagged responses for mailbox changes.
|
|
106
112
|
// We can now safely send DONE if a break was already requested.
|
|
107
113
|
onPlusTag: async () => {
|
|
114
|
+
clearContinuationTimer();
|
|
108
115
|
connection.log.debug({
|
|
109
116
|
msg: `Initiated IDLE, waiting for server input`,
|
|
110
117
|
lockId: connection.currentLock ? connection.currentLock.lockId : undefined,
|
|
@@ -121,7 +128,29 @@ async function runIdle(connection) {
|
|
|
121
128
|
}
|
|
122
129
|
}
|
|
123
130
|
},
|
|
124
|
-
|
|
131
|
+
// The server has socketTimeout from the moment IDLE is on the wire to acknowledge it
|
|
132
|
+
// with the "+". The socket watchdog only covers a server that goes quiet: one that
|
|
133
|
+
// keeps sending untagged lines without ever answering "+" kept the IDLE in flight for
|
|
134
|
+
// good, and every later command queued behind it. DONE can not be sent before the
|
|
135
|
+
// "+" (RFC 2177 section 3), so there is no way to break such an IDLE and fall back
|
|
136
|
+
// to polling; the connection is failed the way the watchdog fails a dead peer.
|
|
137
|
+
onSend: () => {
|
|
138
|
+
continuationTimer = (0, tools_js_1.unrefTimer)(setTimeout(() => {
|
|
139
|
+
continuationTimer = null;
|
|
140
|
+
if (canEnd || connection.isClosed) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
connection.log.error({
|
|
144
|
+
msg: 'Server did not acknowledge IDLE, closing the connection',
|
|
145
|
+
timeout: connection.socketTimeout,
|
|
146
|
+
cid: connection.id
|
|
147
|
+
});
|
|
148
|
+
connection.emitError((0, errors_js_1.createImapError)('IDLE was not acknowledged within the socket timeout', 'ETIMEOUT', {
|
|
149
|
+
command: 'IDLE',
|
|
150
|
+
details: { socketTimeout: connection.socketTimeout }
|
|
151
|
+
}));
|
|
152
|
+
}, connection.socketTimeout));
|
|
153
|
+
}
|
|
125
154
|
});
|
|
126
155
|
response.next();
|
|
127
156
|
return;
|
|
@@ -153,6 +182,7 @@ async function runIdle(connection) {
|
|
|
153
182
|
// (including a server-terminated IDLE, where preCheck never ran), rejected command,
|
|
154
183
|
// parser failure and connection close. `idling` drives socket-timeout handling, so it
|
|
155
184
|
// must always describe reality rather than being left over from a previous state.
|
|
185
|
+
clearContinuationTimer();
|
|
156
186
|
releaseIdling();
|
|
157
187
|
if (connection.preCheck === ownPreCheck) {
|
|
158
188
|
connection.preCheck = false;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.default = list;
|
|
4
4
|
const tools_js_1 = require("../tools.js");
|
|
5
|
+
const errors_js_1 = require("../errors.js");
|
|
5
6
|
const status_fields_js_1 = require("./status-fields.js");
|
|
6
7
|
const special_use_js_1 = require("../special-use.js");
|
|
7
8
|
/**
|
|
@@ -140,6 +141,8 @@ async function list(connection, reference, mailbox, options) {
|
|
|
140
141
|
// namespace prefix was used (INBOX may live outside the namespace).
|
|
141
142
|
let runList = async (reference, mailbox, returnArgs) => {
|
|
142
143
|
const cmdArgs = [(0, tools_js_1.encodePath)(connection, reference), (0, tools_js_1.encodePath)(connection, mailbox)];
|
|
144
|
+
// Set once the entry limit is reached, fails the command when it completes
|
|
145
|
+
let overflow = null;
|
|
143
146
|
if (returnArgs.length) {
|
|
144
147
|
cmdArgs.push({ type: 'ATOM', value: 'RETURN' }, returnArgs);
|
|
145
148
|
}
|
|
@@ -199,6 +202,14 @@ async function list(connection, reference, mailbox, options) {
|
|
|
199
202
|
if (specialUseFlag && (!isNameSource(flagSource) || !entry.flags.has('\\NonExistent'))) {
|
|
200
203
|
addSpecialUseMatch(entry, specialUseFlag, flagSource);
|
|
201
204
|
}
|
|
205
|
+
if (entries.length >= tools_js_1.MAX_COLLECTED_RESPONSES) {
|
|
206
|
+
// The entries are the server's to send and the memory holding them
|
|
207
|
+
// is this process's, see MAX_COLLECTED_RESPONSES. A throw from an
|
|
208
|
+
// untagged handler is only logged, so the error is raised below,
|
|
209
|
+
// once the command completes
|
|
210
|
+
overflow = overflow || (0, errors_js_1.createImapError)(`LIST returned more than ${tools_js_1.MAX_COLLECTED_RESPONSES} mailboxes`, 'TooManyResponses');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
202
213
|
entries.push(entry);
|
|
203
214
|
},
|
|
204
215
|
// Inline STATUS response from LIST-STATUS extension (RFC 5819).
|
|
@@ -218,6 +229,9 @@ async function list(connection, reference, mailbox, options) {
|
|
|
218
229
|
}
|
|
219
230
|
});
|
|
220
231
|
response.next();
|
|
232
|
+
if (overflow) {
|
|
233
|
+
throw overflow;
|
|
234
|
+
}
|
|
221
235
|
};
|
|
222
236
|
let normalizedReference = (0, tools_js_1.normalizePath)(connection, reference || '');
|
|
223
237
|
let normalizedMailbox = (0, tools_js_1.normalizePath)(connection, mailbox || '', true);
|
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 TooManyResponses: "TooManyResponses";
|
|
38
39
|
readonly InvalidStringValue: "InvalidStringValue";
|
|
39
40
|
readonly InvalidTokenValue: "InvalidTokenValue";
|
|
40
41
|
readonly InvalidTextValue: "InvalidTextValue";
|
package/dist/cjs/errors.js
CHANGED
|
@@ -43,6 +43,8 @@ exports.ImapFlowErrorCode = {
|
|
|
43
43
|
LineTooLarge: 'LineTooLarge',
|
|
44
44
|
LiteralTooLarge: 'LiteralTooLarge',
|
|
45
45
|
ResponseTooLarge: 'ResponseTooLarge',
|
|
46
|
+
// one command received more untagged responses than are kept, see MAX_COLLECTED_RESPONSES
|
|
47
|
+
TooManyResponses: 'TooManyResponses',
|
|
46
48
|
// invalid values in a command
|
|
47
49
|
InvalidStringValue: 'InvalidStringValue',
|
|
48
50
|
InvalidTokenValue: 'InvalidTokenValue',
|
|
@@ -87,6 +87,9 @@ async function parser(command, options) {
|
|
|
87
87
|
if (parserInstance.remainder.trim().length) {
|
|
88
88
|
await parserInstance.getSpace();
|
|
89
89
|
response.attributes = await parserInstance.getAttributes();
|
|
90
|
+
if (parserInstance.truncatedAttributes && parserInstance.truncatedAttributes.length) {
|
|
91
|
+
response.truncatedAttributes = parserInstance.truncatedAttributes;
|
|
92
|
+
}
|
|
90
93
|
}
|
|
91
94
|
if (parserInstance.humanReadable) {
|
|
92
95
|
response.attributes = (response.attributes || []).concat({
|
|
@@ -12,6 +12,8 @@ export declare class ParserInstance {
|
|
|
12
12
|
tag?: string | undefined;
|
|
13
13
|
command?: string | undefined;
|
|
14
14
|
humanReadable?: string | undefined;
|
|
15
|
+
/** The keys of the FETCH data items the token parser dropped for exceeding the nesting limit */
|
|
16
|
+
truncatedAttributes?: string[] | undefined;
|
|
15
17
|
/**
|
|
16
18
|
* Creates a new ParserInstance for parsing an IMAP response line.
|
|
17
19
|
*
|
|
@@ -217,7 +217,9 @@ class ParserInstance {
|
|
|
217
217
|
});
|
|
218
218
|
}
|
|
219
219
|
const tokenParser = new token_parser_js_1.TokenParser(this, this.pos, this.remainder, this.options);
|
|
220
|
-
|
|
220
|
+
let attributes = await tokenParser.getAttributes();
|
|
221
|
+
this.truncatedAttributes = tokenParser.truncatedAttributes;
|
|
222
|
+
return attributes;
|
|
221
223
|
}
|
|
222
224
|
}
|
|
223
225
|
exports.ParserInstance = ParserInstance;
|
|
@@ -22,6 +22,8 @@ export interface TokenNode {
|
|
|
22
22
|
started?: boolean | undefined;
|
|
23
23
|
chBuffer?: Buffer | undefined;
|
|
24
24
|
chPos?: number | undefined;
|
|
25
|
+
/** Set on a FETCH data item whose nesting exceeded MAX_NODE_DEPTH, see createNode() */
|
|
26
|
+
truncated?: boolean | undefined;
|
|
25
27
|
}
|
|
26
28
|
/**
|
|
27
29
|
* The parent object a TokenParser reads the parsed command from
|
|
@@ -34,7 +36,8 @@ export interface TokenParserParent {
|
|
|
34
36
|
* Handles all IMAP data types: atoms, quoted strings, literals (including literal8),
|
|
35
37
|
* sequences, lists (parenthesized groups), sections (bracketed groups), and partial ranges.
|
|
36
38
|
* Enforces a maximum nesting depth of {@link MAX_NODE_DEPTH} to prevent stack overflow
|
|
37
|
-
* from malicious input
|
|
39
|
+
* from malicious input: a FETCH data item that nests deeper is replaced by NIL and listed in
|
|
40
|
+
* `truncatedAttributes`, any other input that nests deeper fails to parse.
|
|
38
41
|
*/
|
|
39
42
|
export declare class TokenParser {
|
|
40
43
|
str: string;
|
|
@@ -45,6 +48,8 @@ export declare class TokenParser {
|
|
|
45
48
|
currentNode: TokenNode;
|
|
46
49
|
pos: number;
|
|
47
50
|
state: number;
|
|
51
|
+
/** The keys of the FETCH data items that were dropped for exceeding MAX_NODE_DEPTH */
|
|
52
|
+
truncatedAttributes: string[];
|
|
48
53
|
expectedLiteralType?: string | false | undefined;
|
|
49
54
|
/**
|
|
50
55
|
* Creates a new TokenParser.
|
|
@@ -88,9 +93,23 @@ export declare class TokenParser {
|
|
|
88
93
|
* @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
|
|
89
94
|
* @param startPos - The starting position of this node in the original input string.
|
|
90
95
|
* @returns The newly created node with childNodes, type, value, and isClosed properties.
|
|
91
|
-
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH.
|
|
96
|
+
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item.
|
|
92
97
|
*/
|
|
93
98
|
createNode(parentNode?: TokenNode | undefined, startPos?: number | undefined): TokenNode;
|
|
99
|
+
/**
|
|
100
|
+
* Whether the line being parsed is a message data response (`* 12 FETCH (...)`): a numeric
|
|
101
|
+
* command with FETCH as its first attribute. Only those get the per-item nesting fallback.
|
|
102
|
+
*
|
|
103
|
+
* @returns True for an untagged FETCH response
|
|
104
|
+
*/
|
|
105
|
+
isFetchResponse(): boolean;
|
|
106
|
+
/**
|
|
107
|
+
* Marks the FETCH data item a too deeply nested node belongs to as truncated and records
|
|
108
|
+
* its key (the item before it in the list, e.g. BODYSTRUCTURE) for the caller to report.
|
|
109
|
+
*
|
|
110
|
+
* @param node - The node at MAX_NODE_DEPTH whose child would exceed the limit
|
|
111
|
+
*/
|
|
112
|
+
truncateFetchDataItem(node: TokenNode): void;
|
|
94
113
|
/**
|
|
95
114
|
* Processes the entire input string character by character using a state machine.
|
|
96
115
|
* Transitions between states (NORMAL, ATOM, STRING, LITERAL, SEQUENCE, PARTIAL, TEXT)
|
|
@@ -15,14 +15,23 @@ const STATE_PARTIAL = 0x004;
|
|
|
15
15
|
const STATE_SEQUENCE = 0x005;
|
|
16
16
|
const STATE_STRING = 0x006;
|
|
17
17
|
const RE_SINGLE_DIGIT = /^\d$/;
|
|
18
|
-
// Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...))))))
|
|
19
|
-
|
|
18
|
+
// Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...)))))).
|
|
19
|
+
// The tokenizer itself is iterative; what recurses is the getAttributes() walk and the
|
|
20
|
+
// consumers of its output (the body structure parser, the compiler used for logging), and
|
|
21
|
+
// a hundred frames is nothing to any of them. The old bound of 25 was reached by a message
|
|
22
|
+
// with 22 nested multiparts, which an external sender can produce, and the whole FETCH row
|
|
23
|
+
// then failed to parse and was dropped silently.
|
|
24
|
+
const MAX_NODE_DEPTH = 100;
|
|
25
|
+
// The depth at which the data items of a FETCH response sit: TREE (0) > the item list (1) >
|
|
26
|
+
// a key or value (2). See createNode() for what happens when nesting overflows below one.
|
|
27
|
+
const FETCH_DATA_ITEM_DEPTH = 2;
|
|
20
28
|
/**
|
|
21
29
|
* Tokenizes an IMAP attribute string into a tree of typed nodes.
|
|
22
30
|
* Handles all IMAP data types: atoms, quoted strings, literals (including literal8),
|
|
23
31
|
* sequences, lists (parenthesized groups), sections (bracketed groups), and partial ranges.
|
|
24
32
|
* Enforces a maximum nesting depth of {@link MAX_NODE_DEPTH} to prevent stack overflow
|
|
25
|
-
* from malicious input
|
|
33
|
+
* from malicious input: a FETCH data item that nests deeper is replaced by NIL and listed in
|
|
34
|
+
* `truncatedAttributes`, any other input that nests deeper fails to parse.
|
|
26
35
|
*/
|
|
27
36
|
class TokenParser {
|
|
28
37
|
/**
|
|
@@ -44,6 +53,7 @@ class TokenParser {
|
|
|
44
53
|
// Same normalization and default as the streaming parser, so a direct user of this parser
|
|
45
54
|
// gets the same bound (an explicit 0 means "reject any non-empty inline literal").
|
|
46
55
|
this.maxLiteralSize = (0, limits_js_1.normalizeLimit)(this.options.maxLiteralSize, limits_js_1.MAX_LITERAL_SIZE);
|
|
56
|
+
this.truncatedAttributes = [];
|
|
47
57
|
this.tree = this.currentNode = this.createNode();
|
|
48
58
|
this.pos = startPos || 0;
|
|
49
59
|
this.currentNode.type = 'TREE';
|
|
@@ -92,6 +102,16 @@ class TokenParser {
|
|
|
92
102
|
throw this.parserError(`Unexpected end of input at position ${this.pos + this.str.length - 1} [E9]`, 'ParserError9', this.pos + this.str.length - 1);
|
|
93
103
|
}
|
|
94
104
|
let type = (node.type || '').toString().toUpperCase();
|
|
105
|
+
if (node.truncated) {
|
|
106
|
+
// The item nested deeper than MAX_NODE_DEPTH, see createNode(). Its subtree is
|
|
107
|
+
// not walked (that is what keeps this recursion bounded). A list is replaced by
|
|
108
|
+
// NIL so the data items keep pairing up as key and value; a section is attached
|
|
109
|
+
// to the item before it, so it is dropped whole.
|
|
110
|
+
if (type === 'LIST') {
|
|
111
|
+
branch.push(null);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
95
115
|
switch (type) {
|
|
96
116
|
case 'LITERAL':
|
|
97
117
|
case 'STRING':
|
|
@@ -142,7 +162,7 @@ class TokenParser {
|
|
|
142
162
|
* @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
|
|
143
163
|
* @param startPos - The starting position of this node in the original input string.
|
|
144
164
|
* @returns The newly created node with childNodes, type, value, and isClosed properties.
|
|
145
|
-
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH.
|
|
165
|
+
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item.
|
|
146
166
|
*/
|
|
147
167
|
createNode(parentNode, startPos) {
|
|
148
168
|
let node = {
|
|
@@ -160,8 +180,20 @@ class TokenParser {
|
|
|
160
180
|
node.depth = 0;
|
|
161
181
|
}
|
|
162
182
|
if (node.depth > MAX_NODE_DEPTH) {
|
|
163
|
-
|
|
164
|
-
|
|
183
|
+
if (!this.isFetchResponse()) {
|
|
184
|
+
// A bounded prefix only, the error travels whole into logs and rejections
|
|
185
|
+
throw (0, errors_js_1.createImapError)('Too much nesting in IMAP string', 'MAX_IMAP_NESTING_REACHED', { _imapStr: this.str.slice(0, limits_js_1.ERROR_CONTEXT_LENGTH) });
|
|
186
|
+
}
|
|
187
|
+
// A FETCH row carries the data of one message, and the item nesting this deep (a
|
|
188
|
+
// BODYSTRUCTURE of a message with a hundred nested multiparts, which the sender of
|
|
189
|
+
// that message controls) is one of several. Failing the line would drop the whole
|
|
190
|
+
// row, and with it the message, from the FETCH result. The data item is marked
|
|
191
|
+
// instead, which getAttributes() turns into NIL, and the tokenizer carries on: it
|
|
192
|
+
// keeps no stack, so the depth costs it nothing. Marked on the first crossing only,
|
|
193
|
+
// every node below that one is already inside a marked item.
|
|
194
|
+
if (parentNode.depth === MAX_NODE_DEPTH) {
|
|
195
|
+
this.truncateFetchDataItem(parentNode);
|
|
196
|
+
}
|
|
165
197
|
}
|
|
166
198
|
if (typeof startPos === 'number') {
|
|
167
199
|
node.startPos = startPos;
|
|
@@ -171,6 +203,44 @@ class TokenParser {
|
|
|
171
203
|
}
|
|
172
204
|
return node;
|
|
173
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Whether the line being parsed is a message data response (`* 12 FETCH (...)`): a numeric
|
|
208
|
+
* command with FETCH as its first attribute. Only those get the per-item nesting fallback.
|
|
209
|
+
*
|
|
210
|
+
* @returns True for an untagged FETCH response
|
|
211
|
+
*/
|
|
212
|
+
isFetchResponse() {
|
|
213
|
+
let command = this.parent.command;
|
|
214
|
+
let first = this.tree.childNodes[0];
|
|
215
|
+
return (typeof command === 'string' &&
|
|
216
|
+
/^\d+$/.test(command) &&
|
|
217
|
+
!!first &&
|
|
218
|
+
first.type === 'ATOM' &&
|
|
219
|
+
typeof first.value === 'string' &&
|
|
220
|
+
first.value.toUpperCase() === 'FETCH');
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Marks the FETCH data item a too deeply nested node belongs to as truncated and records
|
|
224
|
+
* its key (the item before it in the list, e.g. BODYSTRUCTURE) for the caller to report.
|
|
225
|
+
*
|
|
226
|
+
* @param node - The node at MAX_NODE_DEPTH whose child would exceed the limit
|
|
227
|
+
*/
|
|
228
|
+
truncateFetchDataItem(node) {
|
|
229
|
+
let item = node;
|
|
230
|
+
while (item.depth > FETCH_DATA_ITEM_DEPTH && item.parentNode) {
|
|
231
|
+
item = item.parentNode;
|
|
232
|
+
}
|
|
233
|
+
if (item.truncated) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
item.truncated = true;
|
|
237
|
+
// The item is the last child of the list while it is being parsed, so its key is the
|
|
238
|
+
// child before it. A key is an atom (BODYSTRUCTURE, ENVELOPE, BODY); anything else means
|
|
239
|
+
// the row itself is malformed, and the key is reported as unknown.
|
|
240
|
+
let siblings = item.parentNode ? item.parentNode.childNodes : [];
|
|
241
|
+
let key = siblings[siblings.length - 2];
|
|
242
|
+
this.truncatedAttributes.push(key && key.type === 'ATOM' && typeof key.value === 'string' ? key.value.toUpperCase() : '?');
|
|
243
|
+
}
|
|
174
244
|
/**
|
|
175
245
|
* Processes the entire input string character by character using a state machine.
|
|
176
246
|
* Transitions between states (NORMAL, ATOM, STRING, LITERAL, SEQUENCE, PARTIAL, TEXT)
|
|
@@ -39,6 +39,11 @@ export interface ImapResponse {
|
|
|
39
39
|
attributes?: ImapAttributeList | undefined;
|
|
40
40
|
/** Number of leading NUL bytes stripped from the line, if any */
|
|
41
41
|
nullBytesRemoved?: number | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Keys of the FETCH data items (e.g. BODYSTRUCTURE) that were replaced by NIL because they
|
|
44
|
+
* nested deeper than the parser's limit, if any
|
|
45
|
+
*/
|
|
46
|
+
truncatedAttributes?: string[] | undefined;
|
|
42
47
|
}
|
|
43
48
|
/**
|
|
44
49
|
* What the command compiler accepts as a node: parsed tokens and lists, and also bare
|
package/dist/cjs/imap-flow.js
CHANGED
|
@@ -889,6 +889,16 @@ class ImapFlow extends node_events_1.EventEmitter {
|
|
|
889
889
|
}
|
|
890
890
|
return true;
|
|
891
891
|
}
|
|
892
|
+
if (parsed.truncatedAttributes) {
|
|
893
|
+
// The row is kept with the item missing rather than dropped with the whole line: the
|
|
894
|
+
// nesting is the message sender's doing, and the message must still reach the caller
|
|
895
|
+
this.log.warn({
|
|
896
|
+
msg: 'FETCH data item exceeded the nesting limit and was dropped',
|
|
897
|
+
seq: parsed.command,
|
|
898
|
+
attributes: parsed.truncatedAttributes,
|
|
899
|
+
cid: this.id
|
|
900
|
+
});
|
|
901
|
+
}
|
|
892
902
|
if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
|
|
893
903
|
let payload = { response: parsed.command };
|
|
894
904
|
if (parsed.attributes &&
|
|
@@ -1052,11 +1062,24 @@ class ImapFlow extends node_events_1.EventEmitter {
|
|
|
1052
1062
|
async settleRequest(request, parsed, hasTrailingData) {
|
|
1053
1063
|
switch ((parsed.command || '').toUpperCase()) {
|
|
1054
1064
|
case 'OK':
|
|
1055
|
-
case 'BYE':
|
|
1056
1065
|
// hasTrailingData is forwarded so STARTTLS can detect a plaintext
|
|
1057
1066
|
// injection (data buffered after the tagged OK, before the handshake).
|
|
1058
1067
|
await this.resolveParked(request, parsed, hasTrailingData);
|
|
1059
1068
|
break;
|
|
1069
|
+
case 'BYE': {
|
|
1070
|
+
// BYE is always untagged (RFC 9051 section 7.1.5), it is never the completion
|
|
1071
|
+
// result of a command. It used to resolve the command like an OK, so a server (or
|
|
1072
|
+
// a cleartext MITM) answering LOGIN with a tagged BYE marked the session
|
|
1073
|
+
// authenticated. The server says it is closing: the command fails as a lost
|
|
1074
|
+
// connection and the connection is closed, which for a LOGOUT is the outcome it
|
|
1075
|
+
// was after anyway.
|
|
1076
|
+
await this.serverBye(parsed);
|
|
1077
|
+
let err = this.createNoConnectionError(this.byeReason, { rejectedFrom: 'taggedBye', command: request.command });
|
|
1078
|
+
err.response = parsed;
|
|
1079
|
+
err.responseStatus = 'BYE';
|
|
1080
|
+
request.reject(err);
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1060
1083
|
case 'NO':
|
|
1061
1084
|
case 'BAD': {
|
|
1062
1085
|
let txt = (0, tools_js_1.getTextValues)(parsed.attributes)
|
|
@@ -1538,6 +1561,13 @@ class ImapFlow extends node_events_1.EventEmitter {
|
|
|
1538
1561
|
// STARTTLS explictly disabled by config
|
|
1539
1562
|
return false;
|
|
1540
1563
|
}
|
|
1564
|
+
if (this.state !== this.states.NOT_AUTHENTICATED) {
|
|
1565
|
+
// STARTTLS is only valid in the not-authenticated state (RFC 9051 section 6.2.1),
|
|
1566
|
+
// which a PREAUTH greeting skips, so there is no way left to protect this session.
|
|
1567
|
+
// Required STARTTLS fails here with that reason: the greeting arrived in cleartext,
|
|
1568
|
+
// and a MITM can answer with PREAUTH precisely to keep it that way.
|
|
1569
|
+
return this._failSTARTTLS('STARTTLS is not possible after a PREAUTH greeting');
|
|
1570
|
+
}
|
|
1541
1571
|
if (!this.capabilities.has('STARTTLS')) {
|
|
1542
1572
|
return this._failSTARTTLS();
|
|
1543
1573
|
}
|
|
@@ -1982,7 +2012,7 @@ class ImapFlow extends node_events_1.EventEmitter {
|
|
|
1982
2012
|
let tags = [];
|
|
1983
2013
|
let uids = false;
|
|
1984
2014
|
// A malformed VANISHED can carry no attributes at all, and one carrying only the
|
|
1985
|
-
// (EARLIER) tag leaves `uids` false -
|
|
2015
|
+
// (EARLIER) tag leaves `uids` false - iterateRange() handles that and yields nothing
|
|
1986
2016
|
if (!untagged.attributes || !untagged.attributes.length) {
|
|
1987
2017
|
return;
|
|
1988
2018
|
}
|
|
@@ -1993,14 +2023,16 @@ class ImapFlow extends node_events_1.EventEmitter {
|
|
|
1993
2023
|
if (untagged.attributes[0] && typeof untagged.attributes[0].value === 'string') {
|
|
1994
2024
|
uids = untagged.attributes[0].value;
|
|
1995
2025
|
}
|
|
1996
|
-
let uidList = (0, tools_js_1.expandRange)(uids);
|
|
1997
2026
|
let earlier = tags.includes('EARLIER');
|
|
1998
2027
|
// RFC 7162 section 3.2.10: unlike VANISHED (EARLIER), a plain VANISHED reports messages the
|
|
1999
2028
|
// client knows about and decrements the message count like the same number of EXPUNGEs would
|
|
2000
2029
|
if (!earlier) {
|
|
2001
|
-
mailbox.exists = Math.max(0, mailbox.exists -
|
|
2030
|
+
mailbox.exists = Math.max(0, mailbox.exists - (0, tools_js_1.countRange)(uids));
|
|
2002
2031
|
}
|
|
2003
|
-
|
|
2032
|
+
// The set is walked one UID at a time rather than expanded first: it is the server's to
|
|
2033
|
+
// choose, and a single "1:16777216" is a few bytes on the wire but sixteen million array
|
|
2034
|
+
// entries expanded, for every such line the server cares to send
|
|
2035
|
+
for (let uid of (0, tools_js_1.iterateRange)(uids)) {
|
|
2004
2036
|
let payload = {
|
|
2005
2037
|
path: mailbox.path,
|
|
2006
2038
|
uid,
|
package/dist/cjs/package-info.js
CHANGED
package/dist/cjs/tools.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { ImapAttributeList, ImapAttributeNode, ImapResponse } from './handl
|
|
|
5
5
|
import type { FetchMessageObject, ListResponse, ListTreeResponse, MailboxObject, MessageEnvelopeObject, MessageStructureObject, ImapFlowEvents, StatusQuery } from './types.js';
|
|
6
6
|
export { AuthenticationFailure } from './errors.js';
|
|
7
7
|
export declare const EXPANDED_RANGE_LIMIT = 16777216;
|
|
8
|
+
export declare const MAX_COLLECTED_RESPONSES = 1000000;
|
|
8
9
|
export declare const MAX_UINT32_DIGITS = 10;
|
|
9
10
|
export declare const noop: () => void;
|
|
10
11
|
/**
|
|
@@ -471,6 +472,26 @@ export declare function parseBigIntValue(value: unknown, maxDigits?: number): bi
|
|
|
471
472
|
* @returns The parsed value, or false when it is not usable.
|
|
472
473
|
*/
|
|
473
474
|
export declare function parseUintValue(value: unknown, maxDigits?: number): number | false;
|
|
475
|
+
/**
|
|
476
|
+
* Walks an IMAP sequence range string (e.g. "1:3,5,7:9") one number at a time, without
|
|
477
|
+
* materializing it. A single "1:16777216" is a few bytes on the wire but sixteen million
|
|
478
|
+
* entries expanded, so a consumer that only needs to visit each number (the VANISHED handler)
|
|
479
|
+
* iterates instead of calling expandRange().
|
|
480
|
+
*
|
|
481
|
+
* The same bound as expandRange() applies: at most EXPANDED_RANGE_LIMIT numbers in total are
|
|
482
|
+
* produced, over all entries of the set.
|
|
483
|
+
*
|
|
484
|
+
* @param range - IMAP sequence range string
|
|
485
|
+
* @returns The sequence numbers, in the order the set lists them
|
|
486
|
+
*/
|
|
487
|
+
export declare function iterateRange(range: unknown): Generator<number, void, undefined>;
|
|
488
|
+
/**
|
|
489
|
+
* Counts the numbers iterateRange() produces for a sequence range string, without walking it.
|
|
490
|
+
*
|
|
491
|
+
* @param range - IMAP sequence range string
|
|
492
|
+
* @returns The number of entries, capped at EXPANDED_RANGE_LIMIT like the expansion itself
|
|
493
|
+
*/
|
|
494
|
+
export declare function countRange(range: unknown): number;
|
|
474
495
|
/**
|
|
475
496
|
* Expands an IMAP sequence range string (e.g. "1:3,5,7:9") into an array of numbers.
|
|
476
497
|
*
|