imapflow 2.4.3 → 2.5.1
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/README.md +59 -1
- 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 +2 -0
- package/dist/cjs/errors.js +4 -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.d.ts +9 -9
- package/dist/cjs/imap-flow.js +90 -14
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/cjs/sockets-transport.d.ts +44 -0
- package/dist/cjs/sockets-transport.js +89 -0
- package/dist/cjs/streams-transport.d.ts +84 -0
- package/dist/cjs/streams-transport.js +281 -0
- package/dist/cjs/tools.d.ts +30 -0
- package/dist/cjs/tools.js +130 -52
- package/dist/cjs/types.d.ts +78 -2
- 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 +2 -0
- package/dist/esm/errors.js +4 -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.d.ts +9 -9
- package/dist/esm/imap-flow.js +88 -14
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/dist/esm/sockets-transport.d.ts +44 -0
- package/dist/esm/sockets-transport.js +86 -0
- package/dist/esm/streams-transport.d.ts +84 -0
- package/dist/esm/streams-transport.js +278 -0
- package/dist/esm/tools.d.ts +30 -0
- package/dist/esm/tools.js +126 -51
- package/dist/esm/types.d.ts +78 -2
- package/package.json +16 -5
package/dist/cjs/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConnectionOptions, CipherNameAndProtocol } from 'node:tls';
|
|
2
|
-
import type { Readable } from 'node:stream';
|
|
2
|
+
import type { Duplex, Readable } from 'node:stream';
|
|
3
3
|
/**
|
|
4
4
|
* Logger interface. Any object with `debug`, `info`, `warn` and `error` methods can be used,
|
|
5
5
|
* for example a pino or bunyan instance. `trace` and `fatal` are used when present.
|
|
@@ -95,6 +95,14 @@ export interface ImapFlowOptions {
|
|
|
95
95
|
* Node as-is. Proxy DNS and negotiation run inside `connectionTimeout`.
|
|
96
96
|
*/
|
|
97
97
|
proxy?: string | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* Opens the connection through this transport instead of `node:net` and `node:tls`, for
|
|
100
|
+
* runtimes without those modules. `createSocketsTransport()` builds one from the
|
|
101
|
+
* `connect()` of the `cloudflare:sockets` API (celld, Cloudflare Workers),
|
|
102
|
+
* `createStreamsTransport()` from any socket API built on web streams. Can not be combined
|
|
103
|
+
* with `proxy`.
|
|
104
|
+
*/
|
|
105
|
+
transport?: ImapTransport | undefined;
|
|
98
106
|
/** If true, then use QRESYNC instead of CONDSTORE. EXPUNGE notifications will include UID instead of sequence number */
|
|
99
107
|
qresync?: boolean | undefined;
|
|
100
108
|
/** If set, then breaks and restarts IDLE every maxIdleTime ms */
|
|
@@ -162,7 +170,8 @@ export interface ImapFlowOptions {
|
|
|
162
170
|
* when the server offers STARTTLS and otherwise continues in cleartext with a warning logged:
|
|
163
171
|
* the capability list that decides this was itself received in cleartext, so an attacker on
|
|
164
172
|
* the path can strip it. Set `doSTARTTLS: true` whenever the credentials must never cross the
|
|
165
|
-
* wire unencrypted.
|
|
173
|
+
* wire unencrypted. STARTTLS is only possible before authentication, so a server greeting
|
|
174
|
+
* with PREAUTH is a connection failure when the upgrade is required.
|
|
166
175
|
*/
|
|
167
176
|
doSTARTTLS?: boolean | undefined;
|
|
168
177
|
/** Custom instance ID string for logs */
|
|
@@ -882,3 +891,70 @@ export interface ImapFlowEvents {
|
|
|
882
891
|
/** The server sent an ALERT: text meant for the user, such as a planned shutdown or an account problem */
|
|
883
892
|
alert: [alert: AlertEvent];
|
|
884
893
|
}
|
|
894
|
+
/** Where {@link ImapTransport} connects to, and the `tls` options of the client */
|
|
895
|
+
export interface ImapTransportOptions {
|
|
896
|
+
host: string;
|
|
897
|
+
port: number;
|
|
898
|
+
/** The SNI name, absent for an IP literal host without the `servername` option */
|
|
899
|
+
servername?: string | undefined;
|
|
900
|
+
/** The `tls` option of the client */
|
|
901
|
+
tls?: ConnectionOptions | undefined;
|
|
902
|
+
}
|
|
903
|
+
/** What {@link ImapTransport.connect} gets: the destination, and whether to open a TLS connection */
|
|
904
|
+
export interface ImapTransportConnectOptions extends ImapTransportOptions {
|
|
905
|
+
/** Open a TLS connection (the `secure` option), otherwise a cleartext one */
|
|
906
|
+
secure: boolean;
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* The socket a {@link ImapTransport} returns: a Duplex stream of Buffers that emits `error`,
|
|
910
|
+
* `end` and `close`, and `timeout` once `setTimeout(ms)` was called and `ms` passed without
|
|
911
|
+
* traffic (0 disables it, like `net.Socket#setTimeout()`), the watchdog behind the
|
|
912
|
+
* `socketTimeout` option. The rest of what a `net.Socket` has is optional; ImapFlow uses it when
|
|
913
|
+
* it is there (the addresses for logging, the cipher of a TLS connection for `client.tls`).
|
|
914
|
+
*/
|
|
915
|
+
export type ImapTransportSocket = Duplex & {
|
|
916
|
+
setTimeout(ms: number): unknown;
|
|
917
|
+
setKeepAlive?: ((enable?: boolean, initialDelay?: number) => unknown) | undefined;
|
|
918
|
+
remoteAddress?: string | undefined;
|
|
919
|
+
remotePort?: number | undefined;
|
|
920
|
+
localAddress?: string | undefined;
|
|
921
|
+
localPort?: number | undefined;
|
|
922
|
+
getCipher?: (() => CipherNameAndProtocol | null) | undefined;
|
|
923
|
+
authorized?: boolean | undefined;
|
|
924
|
+
};
|
|
925
|
+
/**
|
|
926
|
+
* Opens and upgrades the socket carrying the session, see the `transport` option.
|
|
927
|
+
* `createStreamsTransport()` builds one over any socket API built on web streams,
|
|
928
|
+
* `createSocketsTransport()` over `cloudflare:sockets`.
|
|
929
|
+
*/
|
|
930
|
+
export interface ImapTransport {
|
|
931
|
+
/**
|
|
932
|
+
* Opens a connection, cleartext or TLS. Connection errors are emitted on the returned
|
|
933
|
+
* socket; a connection refused up front (an option the transport can not honor) may throw.
|
|
934
|
+
*
|
|
935
|
+
* @param options - Destination, TLS options and whether to open a TLS connection
|
|
936
|
+
* @param onConnect - Called once the connection (and the TLS handshake) is established
|
|
937
|
+
*/
|
|
938
|
+
connect(options: ImapTransportConnectOptions, onConnect: () => void): ImapTransportSocket;
|
|
939
|
+
/**
|
|
940
|
+
* Called right before the STARTTLS command is sent on the cleartext socket, so a transport
|
|
941
|
+
* that can only start TLS without a read in flight can stop reading once it is answered.
|
|
942
|
+
* STARTTLS is the only command in flight then: the next tagged response line (one that does
|
|
943
|
+
* not start with `* ` or `+ `) answers it, and if it is an OK, the server sends nothing more
|
|
944
|
+
* before the TLS handshake.
|
|
945
|
+
*
|
|
946
|
+
* @param socket - The cleartext socket returned by connect()
|
|
947
|
+
*/
|
|
948
|
+
prepareUpgrade?: ((socket: ImapTransportSocket) => void) | undefined;
|
|
949
|
+
/**
|
|
950
|
+
* Starts TLS on a connected cleartext socket after STARTTLS. May throw if the upgrade is
|
|
951
|
+
* refused before any handshake, handshake errors are emitted on the returned socket. A
|
|
952
|
+
* transport that can not start TLS leaves it out: ImapFlow then treats the server as one
|
|
953
|
+
* without STARTTLS (see the `doSTARTTLS` option).
|
|
954
|
+
*
|
|
955
|
+
* @param socket - The cleartext socket returned by connect()
|
|
956
|
+
* @param options - Destination and TLS options
|
|
957
|
+
* @param onSecure - Called once the TLS handshake is complete
|
|
958
|
+
*/
|
|
959
|
+
upgrade?: ((socket: ImapTransportSocket, options: ImapTransportOptions, onSecure: () => void) => ImapTransportSocket) | undefined;
|
|
960
|
+
}
|
|
@@ -1,4 +1,33 @@
|
|
|
1
1
|
import { getStatusCode, getErrorText, isServerRefusal } from '../tools.js';
|
|
2
|
+
import { createImapError } from '../errors.js';
|
|
3
|
+
// The OAuth SASL payloads are framed with \x01 (RFC 7628 section 3.1, and the XOAUTH2 format
|
|
4
|
+
// copies it), so a control character in a value ends the field early and the rest of the value
|
|
5
|
+
// becomes fields of its own, auth= included. RFC 7628 allows only VCHAR, SP, HTAB, CR and LF in
|
|
6
|
+
// a value, and none of the controls has a place in a username or a bearer token.
|
|
7
|
+
const RE_CONTROL_CHARS = /[\x00-\x1f\x7f]/;
|
|
8
|
+
/**
|
|
9
|
+
* Refuses a username or token that could not be framed as one SASL field.
|
|
10
|
+
*
|
|
11
|
+
* @param value - The value about to be placed in the payload
|
|
12
|
+
* @param what - What the value is, for the error message
|
|
13
|
+
* @throws If the value contains a control character
|
|
14
|
+
*/
|
|
15
|
+
function assertSaslValue(value, what) {
|
|
16
|
+
if (RE_CONTROL_CHARS.test(value)) {
|
|
17
|
+
throw createImapError(`The ${what} contains control characters`, 'InvalidStringValue');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Escapes a value for the authzid of a GS2 header (RFC 5801 section 4): "," and "=" delimit
|
|
22
|
+
* the header, so they are written as =2C and =3D. "=" goes first, so the "=" the escaping
|
|
23
|
+
* itself introduces is not escaped again.
|
|
24
|
+
*
|
|
25
|
+
* @param value - The saslname
|
|
26
|
+
* @returns The escaped saslname
|
|
27
|
+
*/
|
|
28
|
+
function escapeSaslName(value) {
|
|
29
|
+
return value.replace(/[=]/g, '=3D').replace(/,/g, '=2C');
|
|
30
|
+
}
|
|
2
31
|
/**
|
|
3
32
|
* Handles authentication errors by enriching the error object with server response details.
|
|
4
33
|
*
|
|
@@ -34,6 +63,8 @@ async function authOauth(connection, username, accessToken) {
|
|
|
34
63
|
let oauthbearer;
|
|
35
64
|
let command;
|
|
36
65
|
let breaker;
|
|
66
|
+
assertSaslValue(username, 'username');
|
|
67
|
+
assertSaslValue(accessToken, 'access token');
|
|
37
68
|
if (connection.capabilities.has('AUTH=OAUTHBEARER')) {
|
|
38
69
|
// OAUTHBEARER payload per RFC 7628: fields separated by \x01 (SASL GS2 framing).
|
|
39
70
|
// Format: "n,a=<user>," \x01 "host=..." \x01 "port=..." \x01 "auth=Bearer <token>" \x01 \x01
|
|
@@ -44,7 +75,7 @@ async function authOauth(connection, username, accessToken) {
|
|
|
44
75
|
// A server validating either field rejects with status `invalid_request` - a permanent
|
|
45
76
|
// failure that refreshing the access token can never clear.
|
|
46
77
|
oauthbearer = [
|
|
47
|
-
`n,a=${username},`,
|
|
78
|
+
`n,a=${escapeSaslName(username)},`,
|
|
48
79
|
`host=${connection.servername || connection.host}`,
|
|
49
80
|
`port=${connection.port}`,
|
|
50
81
|
`auth=Bearer ${accessToken}`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { formatMessageResponse, isRev2Active, getSelectedMailbox } from '../tools.js';
|
|
1
|
+
import { formatMessageResponse, isRev2Active, getSelectedMailbox, MAX_COLLECTED_RESPONSES } from '../tools.js';
|
|
2
|
+
import { createImapError } from '../errors.js';
|
|
2
3
|
/**
|
|
3
4
|
* Fetches emails from the server.
|
|
4
5
|
*
|
|
@@ -35,9 +36,10 @@ export default async function fetch(connection, range, query, options) {
|
|
|
35
36
|
count: 0,
|
|
36
37
|
list: []
|
|
37
38
|
};
|
|
38
|
-
// The first error the onUntaggedFetch consumer reported through next(err)
|
|
39
|
-
// by untagged handlers are only logged by the
|
|
40
|
-
// the command once the FETCH completes; later
|
|
39
|
+
// The first error the onUntaggedFetch consumer reported through next(err), or the row
|
|
40
|
+
// limit being reached. Errors thrown by untagged handlers are only logged by the
|
|
41
|
+
// connection, so it is kept here and fails the command once the FETCH completes; later
|
|
42
|
+
// messages are no longer handed to the consumer or collected.
|
|
41
43
|
let consumerError = null;
|
|
42
44
|
let response;
|
|
43
45
|
try {
|
|
@@ -232,6 +234,11 @@ export default async function fetch(connection, range, query, options) {
|
|
|
232
234
|
consumerError = err;
|
|
233
235
|
}
|
|
234
236
|
}
|
|
237
|
+
else if (messages.list.length >= MAX_COLLECTED_RESPONSES) {
|
|
238
|
+
// The rows are the server's to send and the memory holding them is
|
|
239
|
+
// this process's, see MAX_COLLECTED_RESPONSES
|
|
240
|
+
consumerError = createImapError(`FETCH returned more than ${MAX_COLLECTED_RESPONSES} messages`, 'TooManyResponses');
|
|
241
|
+
}
|
|
235
242
|
else {
|
|
236
243
|
messages.list.push(formatted);
|
|
237
244
|
}
|
|
@@ -37,6 +37,12 @@ async function runIdle(connection) {
|
|
|
37
37
|
// connection, so a newer IDLE session that already installed its own is left alone.
|
|
38
38
|
let ownPreCheck = null;
|
|
39
39
|
let releaseIdling = claimIdling(connection);
|
|
40
|
+
// The deadline for the server's "+" continuation, see the onSend handler below
|
|
41
|
+
let continuationTimer = null;
|
|
42
|
+
let clearContinuationTimer = () => {
|
|
43
|
+
clearTimer(continuationTimer);
|
|
44
|
+
continuationTimer = null;
|
|
45
|
+
};
|
|
40
46
|
try {
|
|
41
47
|
// State flags for the IDLE lifecycle:
|
|
42
48
|
// - doneRequested: someone wants to break IDLE (e.g., to run another command)
|
|
@@ -102,6 +108,7 @@ async function runIdle(connection) {
|
|
|
102
108
|
// After this, the server will push untagged responses for mailbox changes.
|
|
103
109
|
// We can now safely send DONE if a break was already requested.
|
|
104
110
|
onPlusTag: async () => {
|
|
111
|
+
clearContinuationTimer();
|
|
105
112
|
connection.log.debug({
|
|
106
113
|
msg: `Initiated IDLE, waiting for server input`,
|
|
107
114
|
lockId: connection.currentLock ? connection.currentLock.lockId : undefined,
|
|
@@ -118,7 +125,29 @@ async function runIdle(connection) {
|
|
|
118
125
|
}
|
|
119
126
|
}
|
|
120
127
|
},
|
|
121
|
-
|
|
128
|
+
// The server has socketTimeout from the moment IDLE is on the wire to acknowledge it
|
|
129
|
+
// with the "+". The socket watchdog only covers a server that goes quiet: one that
|
|
130
|
+
// keeps sending untagged lines without ever answering "+" kept the IDLE in flight for
|
|
131
|
+
// good, and every later command queued behind it. DONE can not be sent before the
|
|
132
|
+
// "+" (RFC 2177 section 3), so there is no way to break such an IDLE and fall back
|
|
133
|
+
// to polling; the connection is failed the way the watchdog fails a dead peer.
|
|
134
|
+
onSend: () => {
|
|
135
|
+
continuationTimer = unrefTimer(setTimeout(() => {
|
|
136
|
+
continuationTimer = null;
|
|
137
|
+
if (canEnd || connection.isClosed) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
connection.log.error({
|
|
141
|
+
msg: 'Server did not acknowledge IDLE, closing the connection',
|
|
142
|
+
timeout: connection.socketTimeout,
|
|
143
|
+
cid: connection.id
|
|
144
|
+
});
|
|
145
|
+
connection.emitError(createImapError('IDLE was not acknowledged within the socket timeout', 'ETIMEOUT', {
|
|
146
|
+
command: 'IDLE',
|
|
147
|
+
details: { socketTimeout: connection.socketTimeout }
|
|
148
|
+
}));
|
|
149
|
+
}, connection.socketTimeout));
|
|
150
|
+
}
|
|
122
151
|
});
|
|
123
152
|
response.next();
|
|
124
153
|
return;
|
|
@@ -150,6 +179,7 @@ async function runIdle(connection) {
|
|
|
150
179
|
// (including a server-terminated IDLE, where preCheck never ran), rejected command,
|
|
151
180
|
// parser failure and connection close. `idling` drives socket-timeout handling, so it
|
|
152
181
|
// must always describe reality rather than being left over from a previous state.
|
|
182
|
+
clearContinuationTimer();
|
|
153
183
|
releaseIdling();
|
|
154
184
|
if (connection.preCheck === ownPreCheck) {
|
|
155
185
|
connection.preCheck = false;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { decodePath, encodePath, normalizePath, enhanceCommandError, hasCapability, isRev2Active, buildStatusQueryAttributes, getStringList, getStringValue } from '../tools.js';
|
|
1
|
+
import { decodePath, encodePath, normalizePath, enhanceCommandError, hasCapability, isRev2Active, buildStatusQueryAttributes, getStringList, getStringValue, MAX_COLLECTED_RESPONSES } from '../tools.js';
|
|
2
|
+
import { createImapError } from '../errors.js';
|
|
2
3
|
import { parseStatusList } from './status-fields.js';
|
|
3
4
|
import { specialUse } from '../special-use.js';
|
|
4
5
|
/**
|
|
@@ -137,6 +138,8 @@ export default async function list(connection, reference, mailbox, options) {
|
|
|
137
138
|
// namespace prefix was used (INBOX may live outside the namespace).
|
|
138
139
|
let runList = async (reference, mailbox, returnArgs) => {
|
|
139
140
|
const cmdArgs = [encodePath(connection, reference), encodePath(connection, mailbox)];
|
|
141
|
+
// Set once the entry limit is reached, fails the command when it completes
|
|
142
|
+
let overflow = null;
|
|
140
143
|
if (returnArgs.length) {
|
|
141
144
|
cmdArgs.push({ type: 'ATOM', value: 'RETURN' }, returnArgs);
|
|
142
145
|
}
|
|
@@ -196,6 +199,14 @@ export default async function list(connection, reference, mailbox, options) {
|
|
|
196
199
|
if (specialUseFlag && (!isNameSource(flagSource) || !entry.flags.has('\\NonExistent'))) {
|
|
197
200
|
addSpecialUseMatch(entry, specialUseFlag, flagSource);
|
|
198
201
|
}
|
|
202
|
+
if (entries.length >= MAX_COLLECTED_RESPONSES) {
|
|
203
|
+
// The entries are the server's to send and the memory holding them
|
|
204
|
+
// is this process's, see MAX_COLLECTED_RESPONSES. A throw from an
|
|
205
|
+
// untagged handler is only logged, so the error is raised below,
|
|
206
|
+
// once the command completes
|
|
207
|
+
overflow = overflow || createImapError(`LIST returned more than ${MAX_COLLECTED_RESPONSES} mailboxes`, 'TooManyResponses');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
199
210
|
entries.push(entry);
|
|
200
211
|
},
|
|
201
212
|
// Inline STATUS response from LIST-STATUS extension (RFC 5819).
|
|
@@ -215,6 +226,9 @@ export default async function list(connection, reference, mailbox, options) {
|
|
|
215
226
|
}
|
|
216
227
|
});
|
|
217
228
|
response.next();
|
|
229
|
+
if (overflow) {
|
|
230
|
+
throw overflow;
|
|
231
|
+
}
|
|
218
232
|
};
|
|
219
233
|
let normalizedReference = normalizePath(connection, reference || '');
|
|
220
234
|
let normalizedMailbox = normalizePath(connection, mailbox || '', true);
|
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 TooManyResponses: "TooManyResponses";
|
|
38
39
|
readonly InvalidStringValue: "InvalidStringValue";
|
|
39
40
|
readonly InvalidTokenValue: "InvalidTokenValue";
|
|
40
41
|
readonly InvalidTextValue: "InvalidTextValue";
|
|
@@ -47,6 +48,7 @@ export declare const ImapFlowErrorCode: {
|
|
|
47
48
|
readonly EPROXY: "EPROXY";
|
|
48
49
|
readonly UnsupportedProxyAddress: "UnsupportedProxyAddress";
|
|
49
50
|
readonly ERR_INVALID_URL: "ERR_INVALID_URL";
|
|
51
|
+
readonly UnsupportedTransportOption: "UnsupportedTransportOption";
|
|
50
52
|
readonly InstanceReused: "InstanceReused";
|
|
51
53
|
};
|
|
52
54
|
/** One of the {@link ImapFlowErrorCode} values */
|
package/dist/esm/errors.js
CHANGED
|
@@ -39,6 +39,8 @@ export const ImapFlowErrorCode = {
|
|
|
39
39
|
LineTooLarge: 'LineTooLarge',
|
|
40
40
|
LiteralTooLarge: 'LiteralTooLarge',
|
|
41
41
|
ResponseTooLarge: 'ResponseTooLarge',
|
|
42
|
+
// one command received more untagged responses than are kept, see MAX_COLLECTED_RESPONSES
|
|
43
|
+
TooManyResponses: 'TooManyResponses',
|
|
42
44
|
// invalid values in a command
|
|
43
45
|
InvalidStringValue: 'InvalidStringValue',
|
|
44
46
|
InvalidTokenValue: 'InvalidTokenValue',
|
|
@@ -54,6 +56,8 @@ export const ImapFlowErrorCode = {
|
|
|
54
56
|
EPROXY: 'EPROXY',
|
|
55
57
|
UnsupportedProxyAddress: 'UnsupportedProxyAddress',
|
|
56
58
|
ERR_INVALID_URL: 'ERR_INVALID_URL',
|
|
59
|
+
// the transport option
|
|
60
|
+
UnsupportedTransportOption: 'UnsupportedTransportOption',
|
|
57
61
|
// API misuse
|
|
58
62
|
InstanceReused: 'InstanceReused'
|
|
59
63
|
};
|
|
@@ -81,6 +81,9 @@ export default async function parser(command, options) {
|
|
|
81
81
|
if (parserInstance.remainder.trim().length) {
|
|
82
82
|
await parserInstance.getSpace();
|
|
83
83
|
response.attributes = await parserInstance.getAttributes();
|
|
84
|
+
if (parserInstance.truncatedAttributes && parserInstance.truncatedAttributes.length) {
|
|
85
|
+
response.truncatedAttributes = parserInstance.truncatedAttributes;
|
|
86
|
+
}
|
|
84
87
|
}
|
|
85
88
|
if (parserInstance.humanReadable) {
|
|
86
89
|
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
|
*
|
|
@@ -211,6 +211,8 @@ export class ParserInstance {
|
|
|
211
211
|
});
|
|
212
212
|
}
|
|
213
213
|
const tokenParser = new TokenParser(this, this.pos, this.remainder, this.options);
|
|
214
|
-
|
|
214
|
+
let attributes = await tokenParser.getAttributes();
|
|
215
|
+
this.truncatedAttributes = tokenParser.truncatedAttributes;
|
|
216
|
+
return attributes;
|
|
215
217
|
}
|
|
216
218
|
}
|
|
@@ -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)
|
|
@@ -9,14 +9,23 @@ const STATE_PARTIAL = 0x004;
|
|
|
9
9
|
const STATE_SEQUENCE = 0x005;
|
|
10
10
|
const STATE_STRING = 0x006;
|
|
11
11
|
const RE_SINGLE_DIGIT = /^\d$/;
|
|
12
|
-
// Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...))))))
|
|
13
|
-
|
|
12
|
+
// Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...)))))).
|
|
13
|
+
// The tokenizer itself is iterative; what recurses is the getAttributes() walk and the
|
|
14
|
+
// consumers of its output (the body structure parser, the compiler used for logging), and
|
|
15
|
+
// a hundred frames is nothing to any of them. The old bound of 25 was reached by a message
|
|
16
|
+
// with 22 nested multiparts, which an external sender can produce, and the whole FETCH row
|
|
17
|
+
// then failed to parse and was dropped silently.
|
|
18
|
+
const MAX_NODE_DEPTH = 100;
|
|
19
|
+
// The depth at which the data items of a FETCH response sit: TREE (0) > the item list (1) >
|
|
20
|
+
// a key or value (2). See createNode() for what happens when nesting overflows below one.
|
|
21
|
+
const FETCH_DATA_ITEM_DEPTH = 2;
|
|
14
22
|
/**
|
|
15
23
|
* Tokenizes an IMAP attribute string into a tree of typed nodes.
|
|
16
24
|
* Handles all IMAP data types: atoms, quoted strings, literals (including literal8),
|
|
17
25
|
* sequences, lists (parenthesized groups), sections (bracketed groups), and partial ranges.
|
|
18
26
|
* Enforces a maximum nesting depth of {@link MAX_NODE_DEPTH} to prevent stack overflow
|
|
19
|
-
* from malicious input
|
|
27
|
+
* from malicious input: a FETCH data item that nests deeper is replaced by NIL and listed in
|
|
28
|
+
* `truncatedAttributes`, any other input that nests deeper fails to parse.
|
|
20
29
|
*/
|
|
21
30
|
export class TokenParser {
|
|
22
31
|
/**
|
|
@@ -38,6 +47,7 @@ export class TokenParser {
|
|
|
38
47
|
// Same normalization and default as the streaming parser, so a direct user of this parser
|
|
39
48
|
// gets the same bound (an explicit 0 means "reject any non-empty inline literal").
|
|
40
49
|
this.maxLiteralSize = normalizeLimit(this.options.maxLiteralSize, MAX_LITERAL_SIZE);
|
|
50
|
+
this.truncatedAttributes = [];
|
|
41
51
|
this.tree = this.currentNode = this.createNode();
|
|
42
52
|
this.pos = startPos || 0;
|
|
43
53
|
this.currentNode.type = 'TREE';
|
|
@@ -86,6 +96,16 @@ export class TokenParser {
|
|
|
86
96
|
throw this.parserError(`Unexpected end of input at position ${this.pos + this.str.length - 1} [E9]`, 'ParserError9', this.pos + this.str.length - 1);
|
|
87
97
|
}
|
|
88
98
|
let type = (node.type || '').toString().toUpperCase();
|
|
99
|
+
if (node.truncated) {
|
|
100
|
+
// The item nested deeper than MAX_NODE_DEPTH, see createNode(). Its subtree is
|
|
101
|
+
// not walked (that is what keeps this recursion bounded). A list is replaced by
|
|
102
|
+
// NIL so the data items keep pairing up as key and value; a section is attached
|
|
103
|
+
// to the item before it, so it is dropped whole.
|
|
104
|
+
if (type === 'LIST') {
|
|
105
|
+
branch.push(null);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
89
109
|
switch (type) {
|
|
90
110
|
case 'LITERAL':
|
|
91
111
|
case 'STRING':
|
|
@@ -136,7 +156,7 @@ export class TokenParser {
|
|
|
136
156
|
* @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
|
|
137
157
|
* @param startPos - The starting position of this node in the original input string.
|
|
138
158
|
* @returns The newly created node with childNodes, type, value, and isClosed properties.
|
|
139
|
-
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH.
|
|
159
|
+
* @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item.
|
|
140
160
|
*/
|
|
141
161
|
createNode(parentNode, startPos) {
|
|
142
162
|
let node = {
|
|
@@ -154,8 +174,20 @@ export class TokenParser {
|
|
|
154
174
|
node.depth = 0;
|
|
155
175
|
}
|
|
156
176
|
if (node.depth > MAX_NODE_DEPTH) {
|
|
157
|
-
|
|
158
|
-
|
|
177
|
+
if (!this.isFetchResponse()) {
|
|
178
|
+
// A bounded prefix only, the error travels whole into logs and rejections
|
|
179
|
+
throw createImapError('Too much nesting in IMAP string', 'MAX_IMAP_NESTING_REACHED', { _imapStr: this.str.slice(0, ERROR_CONTEXT_LENGTH) });
|
|
180
|
+
}
|
|
181
|
+
// A FETCH row carries the data of one message, and the item nesting this deep (a
|
|
182
|
+
// BODYSTRUCTURE of a message with a hundred nested multiparts, which the sender of
|
|
183
|
+
// that message controls) is one of several. Failing the line would drop the whole
|
|
184
|
+
// row, and with it the message, from the FETCH result. The data item is marked
|
|
185
|
+
// instead, which getAttributes() turns into NIL, and the tokenizer carries on: it
|
|
186
|
+
// keeps no stack, so the depth costs it nothing. Marked on the first crossing only,
|
|
187
|
+
// every node below that one is already inside a marked item.
|
|
188
|
+
if (parentNode.depth === MAX_NODE_DEPTH) {
|
|
189
|
+
this.truncateFetchDataItem(parentNode);
|
|
190
|
+
}
|
|
159
191
|
}
|
|
160
192
|
if (typeof startPos === 'number') {
|
|
161
193
|
node.startPos = startPos;
|
|
@@ -165,6 +197,44 @@ export class TokenParser {
|
|
|
165
197
|
}
|
|
166
198
|
return node;
|
|
167
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Whether the line being parsed is a message data response (`* 12 FETCH (...)`): a numeric
|
|
202
|
+
* command with FETCH as its first attribute. Only those get the per-item nesting fallback.
|
|
203
|
+
*
|
|
204
|
+
* @returns True for an untagged FETCH response
|
|
205
|
+
*/
|
|
206
|
+
isFetchResponse() {
|
|
207
|
+
let command = this.parent.command;
|
|
208
|
+
let first = this.tree.childNodes[0];
|
|
209
|
+
return (typeof command === 'string' &&
|
|
210
|
+
/^\d+$/.test(command) &&
|
|
211
|
+
!!first &&
|
|
212
|
+
first.type === 'ATOM' &&
|
|
213
|
+
typeof first.value === 'string' &&
|
|
214
|
+
first.value.toUpperCase() === 'FETCH');
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Marks the FETCH data item a too deeply nested node belongs to as truncated and records
|
|
218
|
+
* its key (the item before it in the list, e.g. BODYSTRUCTURE) for the caller to report.
|
|
219
|
+
*
|
|
220
|
+
* @param node - The node at MAX_NODE_DEPTH whose child would exceed the limit
|
|
221
|
+
*/
|
|
222
|
+
truncateFetchDataItem(node) {
|
|
223
|
+
let item = node;
|
|
224
|
+
while (item.depth > FETCH_DATA_ITEM_DEPTH && item.parentNode) {
|
|
225
|
+
item = item.parentNode;
|
|
226
|
+
}
|
|
227
|
+
if (item.truncated) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
item.truncated = true;
|
|
231
|
+
// The item is the last child of the list while it is being parsed, so its key is the
|
|
232
|
+
// child before it. A key is an atom (BODYSTRUCTURE, ENVELOPE, BODY); anything else means
|
|
233
|
+
// the row itself is malformed, and the key is reported as unknown.
|
|
234
|
+
let siblings = item.parentNode ? item.parentNode.childNodes : [];
|
|
235
|
+
let key = siblings[siblings.length - 2];
|
|
236
|
+
this.truncatedAttributes.push(key && key.type === 'ATOM' && typeof key.value === 'string' ? key.value.toUpperCase() : '?');
|
|
237
|
+
}
|
|
168
238
|
/**
|
|
169
239
|
* Processes the entire input string character by character using a state machine.
|
|
170
240
|
* 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/esm/imap-flow.d.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module imapflow
|
|
3
3
|
*/
|
|
4
|
-
import tls from 'node:tls';
|
|
5
|
-
import net from 'node:net';
|
|
6
4
|
import { EventEmitter } from 'node:events';
|
|
7
5
|
import { PassThrough, type Readable } from 'node:stream';
|
|
8
6
|
import { AuthenticationFailure } from './errors.js';
|
|
9
|
-
import type { AppendResponseObject, CopyResponseObject, DownloadManyOptions, DownloadManyResult, DownloadObject, DownloadNotFound, DownloadOptions, ESearchResult, FetchMessageObject, FetchOptions, FetchQueryObject, IdInfoObject, ImapFlowEvents, ImapFlowOptions, InternalLogger, ListOptions, ListResponse, ListTreeResponse, MailboxCreateResponse, MailboxDeleteResponse, MailboxLockObject, MailboxLockOptions, MailboxObject, MailboxOpenOptions, MailboxRenameResponse, MessageRange, MessageRangeOptions, NamespaceObject, NamespacesObject, QuotaResponse, SearchObject, SearchOptions, SearchReturnOption, SequenceString, StatusObject, StatusQuery, StoreOptions, TlsInfo } from './types.js';
|
|
7
|
+
import type { AppendResponseObject, CopyResponseObject, DownloadManyOptions, DownloadManyResult, DownloadObject, DownloadNotFound, DownloadOptions, ESearchResult, FetchMessageObject, FetchOptions, FetchQueryObject, IdInfoObject, ImapFlowEvents, ImapFlowOptions, InternalLogger, ListOptions, ListResponse, ListTreeResponse, MailboxCreateResponse, MailboxDeleteResponse, MailboxLockObject, MailboxLockOptions, MailboxObject, MailboxOpenOptions, MailboxRenameResponse, MessageRange, MessageRangeOptions, NamespaceObject, NamespacesObject, QuotaResponse, SearchObject, SearchOptions, SearchReturnOption, SequenceString, StatusObject, StatusQuery, StoreOptions, TlsInfo, ImapTransportSocket } from './types.js';
|
|
10
8
|
export type * from './types.js';
|
|
11
9
|
export type { ImapFlowError } from './errors.js';
|
|
12
10
|
export { AuthenticationFailure, ImapFlowErrorCode } from './errors.js';
|
|
11
|
+
export { createSocketsTransport } from './sockets-transport.js';
|
|
12
|
+
export type { SocketsApiSocket, SocketsConnect } from './sockets-transport.js';
|
|
13
|
+
export { createStreamsTransport } from './streams-transport.js';
|
|
14
|
+
export type { StreamsReader, StreamsSocket, StreamsSocketInfo, StreamsTlsInfo, StreamsTransportApi, StreamsWriter } from './streams-transport.js';
|
|
13
15
|
export type { ImapAttribute, ImapAttributeList, ImapAttributeNode, ImapResponse } from './handler/types.js';
|
|
14
16
|
declare const stateValues: {
|
|
15
17
|
readonly NOT_AUTHENTICATED: 1;
|
|
@@ -27,13 +29,11 @@ export type ConnectionStates = {
|
|
|
27
29
|
readonly [K in keyof typeof stateValues]: ConnectionState;
|
|
28
30
|
};
|
|
29
31
|
/**
|
|
30
|
-
* The socket carrying the IMAP session
|
|
31
|
-
*
|
|
32
|
+
* The socket carrying the IMAP session: a `net.Socket` or `tls.TLSSocket` on Node.js, or what a
|
|
33
|
+
* transport returns. Typed as {@link ImapTransportSocket}, so the client only relies on what
|
|
34
|
+
* every transport provides and checks for the optional members at runtime.
|
|
32
35
|
*/
|
|
33
|
-
export type ImapSocket =
|
|
34
|
-
getCipher?: (() => tls.CipherNameAndProtocol) | undefined;
|
|
35
|
-
authorized?: boolean | undefined;
|
|
36
|
-
};
|
|
36
|
+
export type ImapSocket = ImapTransportSocket;
|
|
37
37
|
/**
|
|
38
38
|
* Where outgoing bytes are written: the socket itself, or the PassThrough that feeds the
|
|
39
39
|
* DEFLATE stream once compression is active
|