imapflow 1.4.9 → 1.6.0
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/.github/workflows/test.yml +20 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +22 -0
- package/CLAUDE.md +3 -5
- package/lib/commands/fetch.js +18 -14
- package/lib/commands/idle.js +197 -104
- package/lib/commands/list.js +19 -8
- package/lib/commands/quota.js +3 -0
- package/lib/commands/select.js +5 -0
- package/lib/commands/status.js +10 -1
- package/lib/connection-deadline.js +98 -0
- package/lib/handler/imap-compiler.js +20 -14
- package/lib/handler/imap-stream.js +141 -50
- package/lib/handler/limits.js +43 -0
- package/lib/handler/token-parser.js +38 -1
- package/lib/imap-flow.d.ts +47 -5
- package/lib/imap-flow.js +594 -283
- package/lib/proxy-connection.js +393 -98
- package/lib/special-use.js +660 -51
- package/lib/tools.js +52 -3
- package/package.json +2 -2
- package/test/commands-branches-test.js +17 -1
- package/test/commands-integration-test.js +353 -2
- package/test/connection-edge-cases-test.js +4 -40
- package/test/fixtures/fake-timers.js +115 -0
- package/test/handler-branches-test.js +4 -28
- package/test/idle-polling-test.js +349 -0
- package/test/imap-compiler-test.js +85 -0
- package/test/imap-flow-compress-test.js +12 -0
- package/test/imap-flow-coverage-test.js +3 -3
- package/test/imap-flow-fetch-download-test.js +56 -0
- package/test/imap-flow-internals-test.js +23 -0
- package/test/imap-flow-proxy-paths-test.js +151 -0
- package/test/imap-flow-secure-test.js +182 -9
- package/test/imap-flow-server-test.js +229 -0
- package/test/imap-parser-test.js +112 -1
- package/test/imap-stream-test.js +46 -0
- package/test/integration/README.md +17 -5
- package/test/integration/rev2-live-test.js +125 -0
- package/test/integration/run-rev2-tests.sh +14 -0
- package/test/parser-limits-test.js +274 -0
- package/test/proxy-connection-test.js +553 -442
- package/test/reliability-improvements-test.js +87 -0
- package/test/search-compiler-test.js +17 -0
- package/test/special-use-test.js +337 -0
- package/test/tag-correlation-test.js +333 -0
- package/test/timer-policy-test.js +214 -0
- package/test/tools-test.js +42 -4
|
@@ -300,6 +300,62 @@ module.exports['Download: base64 body part is decoded'] = async test => {
|
|
|
300
300
|
test.done();
|
|
301
301
|
};
|
|
302
302
|
|
|
303
|
+
module.exports['Download: binary-fetched part skips the client-side transfer decoding'] = async test => {
|
|
304
|
+
let client = makeClient();
|
|
305
|
+
// BINARY fetch (RFC 3516, FETCH side folded into IMAP4rev2): the server
|
|
306
|
+
// decodes the content-transfer-encoding itself, so the client gets raw bytes
|
|
307
|
+
// even though the MIME headers still declare base64 - decoding again would
|
|
308
|
+
// corrupt the data. formatMessageResponse records the parts that arrived via
|
|
309
|
+
// BINARY[...] in the binaryParts set, which the mock mirrors here.
|
|
310
|
+
let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
|
|
311
|
+
let decoded = Buffer.from('Hello World');
|
|
312
|
+
client.fetchOne = async () => {
|
|
313
|
+
let bodyParts = new Map();
|
|
314
|
+
bodyParts.set('2.mime', mime);
|
|
315
|
+
bodyParts.set('2', decoded);
|
|
316
|
+
return { uid: 1, size: decoded.length, bodyParts, binaryParts: new Set(['2']) };
|
|
317
|
+
};
|
|
318
|
+
let { meta, content } = await client.download('1', '2', { chunkSize: 1024, binary: true });
|
|
319
|
+
test.equal(meta.encoding, 'base64');
|
|
320
|
+
let data = await collect(content);
|
|
321
|
+
test.equal(data.toString(), 'Hello World');
|
|
322
|
+
test.done();
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
module.exports['DownloadMany: binary-fetched parts skip the client-side transfer decoding'] = async test => {
|
|
326
|
+
let client = makeClient();
|
|
327
|
+
let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
|
|
328
|
+
client.fetchOne = async () => {
|
|
329
|
+
let bodyParts = new Map();
|
|
330
|
+
bodyParts.set('2.mime', mime);
|
|
331
|
+
bodyParts.set('2', Buffer.from('part two'));
|
|
332
|
+
return { uid: 1, bodyParts, binaryParts: new Set(['2']) };
|
|
333
|
+
};
|
|
334
|
+
let res = await client.downloadMany('1', ['2'], { binary: true });
|
|
335
|
+
test.equal(res['2'].content.toString(), 'part two');
|
|
336
|
+
test.equal(res['2'].meta.encoding, 'base64');
|
|
337
|
+
test.done();
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
module.exports['Download: BODY-answered part is still decoded even when binary was requested'] = async test => {
|
|
341
|
+
let client = makeClient();
|
|
342
|
+
// RFC 3516 lets a server answer a BINARY request with a NO or a BODY response
|
|
343
|
+
// (e.g. [UNKNOWN-CTE]) - decoding must follow what actually arrived, so a part
|
|
344
|
+
// absent from binaryParts gets the client-side decoder despite options.binary
|
|
345
|
+
let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
|
|
346
|
+
let encoded = Buffer.from(libbase64.encode(Buffer.from('Hello World')));
|
|
347
|
+
client.fetchOne = async () => {
|
|
348
|
+
let bodyParts = new Map();
|
|
349
|
+
bodyParts.set('2.mime', mime);
|
|
350
|
+
bodyParts.set('2', encoded);
|
|
351
|
+
return { uid: 1, size: encoded.length, bodyParts };
|
|
352
|
+
};
|
|
353
|
+
let { content } = await client.download('1', '2', { chunkSize: 1024, binary: true });
|
|
354
|
+
let data = await collect(content);
|
|
355
|
+
test.equal(data.toString(), 'Hello World');
|
|
356
|
+
test.done();
|
|
357
|
+
};
|
|
358
|
+
|
|
303
359
|
module.exports['Download: quoted-printable inline text with charset + filename'] = async test => {
|
|
304
360
|
let client = makeClient();
|
|
305
361
|
let mime = Buffer.from(
|
|
@@ -38,16 +38,39 @@ module.exports['Internals: emitError routes to upgrade rejector while upgrading'
|
|
|
38
38
|
client.socket = { destroyed: true, destroy: () => {} };
|
|
39
39
|
client.upgrading = true;
|
|
40
40
|
let rejected = null;
|
|
41
|
+
// Stands in for the upgrade's settle() helper, which owns clearing `upgrading`,
|
|
42
|
+
// the upgrade timer and the temporary handshake handlers.
|
|
41
43
|
client._upgradeReject = err => {
|
|
42
44
|
rejected = err;
|
|
45
|
+
client.upgrading = false;
|
|
43
46
|
};
|
|
44
47
|
let err = new Error('tls boom');
|
|
45
48
|
client.emitError(err);
|
|
46
49
|
test.equal(rejected, err);
|
|
50
|
+
test.equal(client._upgradeReject, null, 'the rejector is consumed exactly once');
|
|
47
51
|
test.equal(client.upgrading, false);
|
|
48
52
|
test.done();
|
|
49
53
|
};
|
|
50
54
|
|
|
55
|
+
module.exports['Internals: emitError closes when an upgrade has no rejector'] = test => {
|
|
56
|
+
let client = makeClient();
|
|
57
|
+
client.socket = { destroyed: true, destroy: () => {} };
|
|
58
|
+
client.upgrading = true;
|
|
59
|
+
client._upgradeReject = null;
|
|
60
|
+
|
|
61
|
+
let emitted = false;
|
|
62
|
+
client.on('error', () => {
|
|
63
|
+
emitted = true;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
client.emitError(new Error('tls boom'));
|
|
67
|
+
|
|
68
|
+
test.equal(client.upgrading, false, 'the upgrade flag is cleared');
|
|
69
|
+
test.equal(emitted, false, 'no duplicate error event while the upgrade owns reporting');
|
|
70
|
+
client.close();
|
|
71
|
+
test.done();
|
|
72
|
+
};
|
|
73
|
+
|
|
51
74
|
module.exports['Internals: emitError rejects pending connect promise'] = test => {
|
|
52
75
|
let client = makeClient();
|
|
53
76
|
client.socket = { destroyed: true, destroy: () => {} };
|
|
@@ -8,6 +8,7 @@ const net = require('net');
|
|
|
8
8
|
const tls = require('tls');
|
|
9
9
|
const proxyquire = require('proxyquire');
|
|
10
10
|
const { cert, key } = require('./fixtures/test-tls');
|
|
11
|
+
const { ImapFlow } = require('../lib/imap-flow');
|
|
11
12
|
|
|
12
13
|
const CAPS = 'IMAP4rev1 ID ENABLE NAMESPACE';
|
|
13
14
|
|
|
@@ -213,3 +214,153 @@ module.exports['Proxy: proxyConnection throwing rejects connect'] = async test =
|
|
|
213
214
|
client.close();
|
|
214
215
|
test.done();
|
|
215
216
|
};
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// End to end through a real HTTP CONNECT proxy (ImapFlow's own CONNECT helper)
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
// Minimal HTTP CONNECT proxy. `onConnect(socket, headers)` may take over the exchange (used to
|
|
223
|
+
// simulate a proxy that never answers); otherwise the tunnel is established and piped. With
|
|
224
|
+
// `coalesce`, the 200 response is held back until the destination speaks and then written in the
|
|
225
|
+
// same segment as the destination's greeting - which is what a real proxy often does.
|
|
226
|
+
const createHttpProxy = (onConnect, { coalesce = false } = {}) =>
|
|
227
|
+
net.createServer(socket => {
|
|
228
|
+
socket.on('error', () => {});
|
|
229
|
+
let buf = '';
|
|
230
|
+
const onData = data => {
|
|
231
|
+
buf += data.toString('binary');
|
|
232
|
+
let idx = buf.indexOf('\r\n\r\n');
|
|
233
|
+
if (idx < 0) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
socket.removeListener('data', onData);
|
|
237
|
+
let headers = buf.slice(0, idx);
|
|
238
|
+
let rest = buf.slice(idx + 4);
|
|
239
|
+
let authority = (headers.split('\r\n')[0].match(/^CONNECT (\S+)/) || [])[1] || '';
|
|
240
|
+
|
|
241
|
+
if (onConnect && onConnect(socket, headers)) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let [, targetPort] = authority.split(':');
|
|
246
|
+
let upstream = net.connect(Number(targetPort), '127.0.0.1', () => {
|
|
247
|
+
if (rest) {
|
|
248
|
+
upstream.write(Buffer.from(rest, 'binary'));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const startTunnel = () => {
|
|
252
|
+
socket.pipe(upstream);
|
|
253
|
+
upstream.pipe(socket);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
if (!coalesce) {
|
|
257
|
+
socket.write('HTTP/1.1 200 Connection established\r\n\r\n');
|
|
258
|
+
return startTunnel();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Hold the CONNECT response until the destination sends something, then emit both
|
|
262
|
+
// in a single write so the client has to preserve the trailing bytes itself.
|
|
263
|
+
upstream.once('data', firstChunk => {
|
|
264
|
+
socket.write(Buffer.concat([Buffer.from('HTTP/1.1 200 Connection established\r\n\r\n'), firstChunk]));
|
|
265
|
+
startTunnel();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
upstream.on('error', () => socket.destroy());
|
|
269
|
+
};
|
|
270
|
+
socket.on('data', onData);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
module.exports['Proxy: session established through a real HTTP CONNECT proxy'] = async test => {
|
|
274
|
+
let imapServer = createServer();
|
|
275
|
+
let imapPort = await listen(imapServer);
|
|
276
|
+
let proxy = createHttpProxy();
|
|
277
|
+
let proxyPort = await listen(proxy);
|
|
278
|
+
|
|
279
|
+
let client = new ImapFlow({
|
|
280
|
+
host: '127.0.0.1',
|
|
281
|
+
port: imapPort,
|
|
282
|
+
secure: false,
|
|
283
|
+
proxy: `http://127.0.0.1:${proxyPort}`,
|
|
284
|
+
disableAutoIdle: true,
|
|
285
|
+
disableCompression: true,
|
|
286
|
+
logger: false,
|
|
287
|
+
auth: { user: 'test', pass: 'secret' }
|
|
288
|
+
});
|
|
289
|
+
client.on('error', () => {});
|
|
290
|
+
|
|
291
|
+
await client.connect();
|
|
292
|
+
test.ok(client.usable, 'session established through the CONNECT tunnel');
|
|
293
|
+
test.ok(client.authenticated);
|
|
294
|
+
|
|
295
|
+
await client.logout();
|
|
296
|
+
client.close();
|
|
297
|
+
proxy.close();
|
|
298
|
+
imapServer.close();
|
|
299
|
+
test.done();
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
module.exports['Proxy: a greeting coalesced with the CONNECT response is not lost'] = async test => {
|
|
303
|
+
// The proxy answers CONNECT and the destination greeting in one segment. The bytes after the
|
|
304
|
+
// header terminator belong to the tunnel, so they have to reach the parser - otherwise the
|
|
305
|
+
// greeting is swallowed and connect() only fails at the greeting timeout.
|
|
306
|
+
let imapServer = createServer();
|
|
307
|
+
let imapPort = await listen(imapServer);
|
|
308
|
+
let proxy = createHttpProxy(null, { coalesce: true });
|
|
309
|
+
let proxyPort = await listen(proxy);
|
|
310
|
+
|
|
311
|
+
let client = new ImapFlow({
|
|
312
|
+
host: '127.0.0.1',
|
|
313
|
+
port: imapPort,
|
|
314
|
+
secure: false,
|
|
315
|
+
proxy: `http://127.0.0.1:${proxyPort}`,
|
|
316
|
+
greetingTimeout: 1500,
|
|
317
|
+
disableAutoIdle: true,
|
|
318
|
+
disableCompression: true,
|
|
319
|
+
logger: false,
|
|
320
|
+
auth: { user: 'test', pass: 'secret' }
|
|
321
|
+
});
|
|
322
|
+
client.on('error', () => {});
|
|
323
|
+
|
|
324
|
+
await client.connect();
|
|
325
|
+
test.ok(client.usable, 'the coalesced greeting was parsed, so the session came up');
|
|
326
|
+
test.ok(client.capabilities.has('IMAP4rev1'), 'capabilities from the coalesced greeting are present');
|
|
327
|
+
|
|
328
|
+
await client.logout();
|
|
329
|
+
client.close();
|
|
330
|
+
proxy.close();
|
|
331
|
+
imapServer.close();
|
|
332
|
+
test.done();
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
module.exports['Proxy: a stalled proxy negotiation is bounded by connectionTimeout'] = async test => {
|
|
336
|
+
// The proxy accepts the CONNECT request and then goes silent
|
|
337
|
+
let proxy = createHttpProxy(() => true);
|
|
338
|
+
let proxyPort = await listen(proxy);
|
|
339
|
+
|
|
340
|
+
let client = new ImapFlow({
|
|
341
|
+
host: '127.0.0.1',
|
|
342
|
+
port: 143,
|
|
343
|
+
secure: false,
|
|
344
|
+
proxy: `http://127.0.0.1:${proxyPort}`,
|
|
345
|
+
connectionTimeout: 150,
|
|
346
|
+
disableAutoIdle: true,
|
|
347
|
+
disableCompression: true,
|
|
348
|
+
logger: false,
|
|
349
|
+
auth: { user: 'test', pass: 'secret' }
|
|
350
|
+
});
|
|
351
|
+
client.on('error', () => {});
|
|
352
|
+
|
|
353
|
+
let started = Date.now();
|
|
354
|
+
let err = await client.connect().then(
|
|
355
|
+
() => null,
|
|
356
|
+
e => e
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
test.ok(err, 'connect rejected instead of hanging in proxy negotiation');
|
|
360
|
+
test.equal(err.code, 'CONNECT_TIMEOUT', 'the documented connection timeout covers proxy negotiation');
|
|
361
|
+
test.ok(Date.now() - started < 3000, 'the deadline applied to the proxy phase, not only to the transport');
|
|
362
|
+
|
|
363
|
+
client.close();
|
|
364
|
+
proxy.close();
|
|
365
|
+
test.done();
|
|
366
|
+
};
|
|
@@ -13,13 +13,17 @@ const CAPS = 'IMAP4rev1 ID ENABLE NAMESPACE';
|
|
|
13
13
|
|
|
14
14
|
// Shared per-connection IMAP line handler used by both the plaintext and the
|
|
15
15
|
// upgraded TLS phases. Returns responses for the minimal session commands.
|
|
16
|
-
|
|
16
|
+
// `caps` is the full capability list to advertise for this phase - the STARTTLS
|
|
17
|
+
// test uses different pre- and post-TLS sets to prove the client discards the
|
|
18
|
+
// plaintext capabilities and re-fetches them over TLS (RFC 9051 6.2.1).
|
|
19
|
+
const handleLine = (sock, line, onStartTls, caps) => {
|
|
20
|
+
caps = caps || `${CAPS} STARTTLS`;
|
|
17
21
|
let parts = line.split(' ');
|
|
18
22
|
let tag = parts[0];
|
|
19
23
|
let cmd = (parts[1] || '').toUpperCase();
|
|
20
24
|
switch (cmd) {
|
|
21
25
|
case 'CAPABILITY':
|
|
22
|
-
sock.write(`* CAPABILITY ${
|
|
26
|
+
sock.write(`* CAPABILITY ${caps}\r\n${tag} OK CAPABILITY done\r\n`);
|
|
23
27
|
break;
|
|
24
28
|
case 'STARTTLS':
|
|
25
29
|
sock.write(`${tag} OK Begin TLS\r\n`);
|
|
@@ -79,21 +83,93 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
|
|
|
79
83
|
let server = net.createServer(rawSocket => {
|
|
80
84
|
rawSocket.on('error', () => {});
|
|
81
85
|
|
|
86
|
+
let detachPlain;
|
|
87
|
+
detachPlain = lineReader(rawSocket, line => {
|
|
88
|
+
handleLine(
|
|
89
|
+
rawSocket,
|
|
90
|
+
line,
|
|
91
|
+
() => {
|
|
92
|
+
// Upgrade: stop reading plaintext, wrap the socket in TLS
|
|
93
|
+
detachPlain();
|
|
94
|
+
let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
|
|
95
|
+
tlsSocket.on('error', () => {});
|
|
96
|
+
tlsSocket.on('secure', () => {});
|
|
97
|
+
// post-TLS phase advertises a different capability set
|
|
98
|
+
lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, `${CAPS} POSTTLS-ONLY`));
|
|
99
|
+
},
|
|
100
|
+
`${CAPS} STARTTLS PRETLS-ONLY`
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS PRETLS-ONLY] ready\r\n`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
let port = await listen(server);
|
|
108
|
+
let client = new ImapFlow({
|
|
109
|
+
host: '127.0.0.1',
|
|
110
|
+
port,
|
|
111
|
+
secure: false,
|
|
112
|
+
doSTARTTLS: true,
|
|
113
|
+
servername: 'localhost',
|
|
114
|
+
tls: { rejectUnauthorized: false },
|
|
115
|
+
disableAutoIdle: true,
|
|
116
|
+
disableCompression: true,
|
|
117
|
+
logger: false,
|
|
118
|
+
auth: { user: 'test', pass: 'secret' }
|
|
119
|
+
});
|
|
120
|
+
client.on('error', () => {});
|
|
121
|
+
|
|
122
|
+
await client.connect();
|
|
123
|
+
test.ok(client.secureConnection, 'connection upgraded to TLS');
|
|
124
|
+
test.ok(client.authenticated, 'authenticated over TLS');
|
|
125
|
+
test.ok(client.usable);
|
|
126
|
+
// RFC 9051 6.2.1: capabilities cached before STARTTLS MUST be discarded and
|
|
127
|
+
// re-fetched over the TLS channel
|
|
128
|
+
test.ok(client.capabilities.has('POSTTLS-ONLY'), 'post-TLS capabilities were re-fetched');
|
|
129
|
+
test.ok(!client.capabilities.has('PRETLS-ONLY'), 'pre-TLS capabilities were discarded');
|
|
130
|
+
|
|
131
|
+
await client.noop();
|
|
132
|
+
await client.logout();
|
|
133
|
+
client.close();
|
|
134
|
+
server.close();
|
|
135
|
+
test.done();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Builds the STARTTLS happy-path server used by the watchdog and cleanup tests below.
|
|
139
|
+
const createStartTlsServer = () =>
|
|
140
|
+
net.createServer(rawSocket => {
|
|
141
|
+
rawSocket.on('error', () => {});
|
|
142
|
+
|
|
82
143
|
let detachPlain;
|
|
83
144
|
detachPlain = lineReader(rawSocket, line => {
|
|
84
145
|
handleLine(rawSocket, line, () => {
|
|
85
|
-
// Upgrade: stop reading plaintext, wrap the socket in TLS
|
|
86
146
|
detachPlain();
|
|
87
147
|
let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
|
|
88
148
|
tlsSocket.on('error', () => {});
|
|
89
|
-
tlsSocket
|
|
90
|
-
lineReader(tlsSocket, l => handleLine(tlsSocket, l));
|
|
149
|
+
lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, CAPS));
|
|
91
150
|
});
|
|
92
151
|
});
|
|
93
152
|
|
|
94
153
|
rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
|
|
95
154
|
});
|
|
96
155
|
|
|
156
|
+
// Records every socket that went through configureSocket(), which is the single place the
|
|
157
|
+
// transport options (keepalive + inactivity watchdog) are applied.
|
|
158
|
+
const trackConfiguredSockets = client => {
|
|
159
|
+
let sockets = [];
|
|
160
|
+
let original = client.configureSocket.bind(client);
|
|
161
|
+
client.configureSocket = socket => {
|
|
162
|
+
sockets.push(socket);
|
|
163
|
+
return original(socket);
|
|
164
|
+
};
|
|
165
|
+
return sockets;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
module.exports['Secure: STARTTLS session keeps the inactivity watchdog'] = async test => {
|
|
169
|
+
// Regression: the inactivity timer was armed on the plain socket only, while the timeout
|
|
170
|
+
// listener was attached to the TLS socket, so an upgraded session had no watchdog at all
|
|
171
|
+
// and a dead connection was never noticed.
|
|
172
|
+
let server = createStartTlsServer();
|
|
97
173
|
let port = await listen(server);
|
|
98
174
|
let client = new ImapFlow({
|
|
99
175
|
host: '127.0.0.1',
|
|
@@ -102,25 +178,122 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
|
|
|
102
178
|
doSTARTTLS: true,
|
|
103
179
|
servername: 'localhost',
|
|
104
180
|
tls: { rejectUnauthorized: false },
|
|
181
|
+
socketTimeout: 200,
|
|
105
182
|
disableAutoIdle: true,
|
|
106
183
|
disableCompression: true,
|
|
107
184
|
logger: false,
|
|
108
185
|
auth: { user: 'test', pass: 'secret' }
|
|
109
186
|
});
|
|
110
|
-
|
|
187
|
+
|
|
188
|
+
let errors = [];
|
|
189
|
+
client.on('error', err => errors.push(err));
|
|
190
|
+
let closed = new Promise(resolve => client.once('close', resolve));
|
|
191
|
+
let configured = trackConfiguredSockets(client);
|
|
111
192
|
|
|
112
193
|
await client.connect();
|
|
113
194
|
test.ok(client.secureConnection, 'connection upgraded to TLS');
|
|
114
|
-
test.
|
|
115
|
-
test.
|
|
195
|
+
test.equal(configured.length, 2, 'both the plain socket and the upgraded socket are configured');
|
|
196
|
+
test.equal(client.socket.timeout, 200, 'the upgraded socket carries the configured inactivity timeout');
|
|
197
|
+
test.equal(configured[0].timeout, 0, 'the superseded plain-socket timer is cleared, not left armed');
|
|
198
|
+
|
|
199
|
+
// No traffic from here on: the watchdog has to fire and take the connection down.
|
|
200
|
+
await closed;
|
|
201
|
+
|
|
202
|
+
test.ok(
|
|
203
|
+
errors.some(err => err.code === 'ETIMEOUT'),
|
|
204
|
+
'the inactivity watchdog reported a socket timeout'
|
|
205
|
+
);
|
|
206
|
+
test.ok(client.isClosed, 'the connection closed after the timeout');
|
|
207
|
+
|
|
208
|
+
client.close();
|
|
209
|
+
server.close();
|
|
210
|
+
test.done();
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
module.exports['Secure: STARTTLS leaves no upgrade state behind on success'] = async test => {
|
|
214
|
+
// Item 8: every terminal path of the upgrade runs through one settlement helper, so after
|
|
215
|
+
// a successful handshake no timer, rejector, flag or temporary handler survives.
|
|
216
|
+
let server = createStartTlsServer();
|
|
217
|
+
let port = await listen(server);
|
|
218
|
+
let client = new ImapFlow({
|
|
219
|
+
host: '127.0.0.1',
|
|
220
|
+
port,
|
|
221
|
+
secure: false,
|
|
222
|
+
doSTARTTLS: true,
|
|
223
|
+
servername: 'localhost',
|
|
224
|
+
tls: { rejectUnauthorized: false },
|
|
225
|
+
disableAutoIdle: true,
|
|
226
|
+
disableCompression: true,
|
|
227
|
+
logger: false,
|
|
228
|
+
auth: { user: 'test', pass: 'secret' }
|
|
229
|
+
});
|
|
230
|
+
client.on('error', () => {});
|
|
231
|
+
|
|
232
|
+
await client.connect();
|
|
233
|
+
|
|
234
|
+
test.equal(client.upgrading, false, 'upgrading flag cleared');
|
|
235
|
+
test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
|
|
236
|
+
test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
|
|
237
|
+
// The handshake-only handler is gone, leaving the generic socket error handler as the single
|
|
238
|
+
// error path (during the handshake it is the other way round, which is what prevents a
|
|
239
|
+
// handshake error from firing two handlers at once).
|
|
240
|
+
test.equal(client.socket.listenerCount('error'), 1, 'the TLS socket keeps exactly one error path');
|
|
116
241
|
|
|
117
|
-
await client.noop();
|
|
118
242
|
await client.logout();
|
|
119
243
|
client.close();
|
|
120
244
|
server.close();
|
|
121
245
|
test.done();
|
|
122
246
|
};
|
|
123
247
|
|
|
248
|
+
module.exports['Secure: close() during a STARTTLS upgrade settles the upgrade'] = async test => {
|
|
249
|
+
// The server acknowledges STARTTLS but never performs a handshake. close() has to settle
|
|
250
|
+
// the pending upgrade instead of leaving the session promise dangling forever.
|
|
251
|
+
let server = net.createServer(rawSocket => {
|
|
252
|
+
rawSocket.on('error', () => {});
|
|
253
|
+
let detach = lineReader(rawSocket, line => {
|
|
254
|
+
handleLine(rawSocket, line, () => {
|
|
255
|
+
detach(); // silence: no ServerHello, no data at all
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
|
|
259
|
+
});
|
|
260
|
+
let port = await listen(server);
|
|
261
|
+
let client = new ImapFlow({
|
|
262
|
+
host: '127.0.0.1',
|
|
263
|
+
port,
|
|
264
|
+
secure: false,
|
|
265
|
+
doSTARTTLS: true,
|
|
266
|
+
servername: 'localhost',
|
|
267
|
+
tls: { rejectUnauthorized: false },
|
|
268
|
+
disableAutoIdle: true,
|
|
269
|
+
disableCompression: true,
|
|
270
|
+
logger: false,
|
|
271
|
+
auth: { user: 'test', pass: 'secret' }
|
|
272
|
+
});
|
|
273
|
+
client.on('error', () => {});
|
|
274
|
+
|
|
275
|
+
let connectResult = client.connect().then(
|
|
276
|
+
() => null,
|
|
277
|
+
err => err
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Wait until the upgrade is in flight, then close the connection under it.
|
|
281
|
+
while (!client.upgrading) {
|
|
282
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
283
|
+
}
|
|
284
|
+
client.close();
|
|
285
|
+
|
|
286
|
+
let err = await connectResult;
|
|
287
|
+
test.ok(err, 'connect rejected rather than hanging on the abandoned upgrade');
|
|
288
|
+
test.ok(['ClosedAfterConnectText', 'ClosedAfterConnectTLS', 'NoConnection'].includes(err.code), `connect rejected with ${err.code}`);
|
|
289
|
+
test.equal(client.upgrading, false, 'upgrading flag cleared');
|
|
290
|
+
test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
|
|
291
|
+
test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
|
|
292
|
+
|
|
293
|
+
server.close();
|
|
294
|
+
test.done();
|
|
295
|
+
};
|
|
296
|
+
|
|
124
297
|
// ---------------------------------------------------------------------------
|
|
125
298
|
// Direct TLS connection
|
|
126
299
|
// ---------------------------------------------------------------------------
|