imapflow 1.6.4 → 1.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/lib/commands/append.js +26 -4
- package/lib/commands/compress.js +29 -18
- package/lib/commands/copyuid-parser.js +4 -2
- package/lib/commands/expunge.js +5 -2
- package/lib/commands/fetch.js +9 -3
- package/lib/commands/list.js +18 -38
- package/lib/commands/namespace.js +2 -2
- package/lib/commands/quota.js +10 -2
- package/lib/commands/search.js +54 -14
- package/lib/commands/select.js +81 -70
- package/lib/commands/status-fields.js +68 -0
- package/lib/commands/status.js +23 -61
- package/lib/handler/imap-compiler.js +91 -60
- package/lib/handler/imap-parser.js +7 -0
- package/lib/handler/imap-stream.js +78 -12
- package/lib/handler/limits.js +16 -4
- package/lib/imap-flow.d.ts +22 -2
- package/lib/imap-flow.js +209 -94
- package/lib/jp-decoder.js +30 -5
- package/lib/limited-passthrough.js +19 -1
- package/lib/search-compiler.js +24 -16
- package/lib/tools.js +190 -39
- package/package.json +4 -4
- package/test/commands-branches-test.js +4 -0
- package/test/commands-integration-test.js +780 -5
- package/test/copyuid-parser-test.js +20 -0
- package/test/idle-polling-test.js +81 -0
- package/test/imap-compiler-test.js +74 -4
- package/test/imap-flow-coverage-test.js +4 -2
- package/test/imap-flow-fetch-download-test.js +26 -0
- package/test/imap-flow-internals-test.js +134 -0
- package/test/imap-flow-methods-test.js +92 -0
- package/test/imap-flow-secure-test.js +133 -116
- package/test/imap-flow-server-test.js +126 -0
- package/test/imap-parser-test.js +25 -0
- package/test/imap-stream-edge-cases-test.js +163 -3
- package/test/integration/rev2-live-test.js +30 -0
- package/test/jp-decoder-test.js +57 -0
- package/test/limited-passthrough-test.js +24 -0
- package/test/parser-limits-test.js +18 -0
- package/test/reliability-improvements-test.js +3 -3
- package/test/search-compiler-test.js +90 -3
- package/test/timer-policy-test.js +27 -1
- package/test/tools-test.js +151 -2
|
@@ -75,37 +75,48 @@ const lineReader = (sock, onLine) => {
|
|
|
75
75
|
|
|
76
76
|
const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
|
|
77
77
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
// Builds a STARTTLS mock server. Defaults give the happy path; options tweak a
|
|
79
|
+
// phase without another copy of the upgrade scaffold:
|
|
80
|
+
// - preTlsCaps / postTlsCaps: capability list advertised before / after the upgrade
|
|
81
|
+
// - startTlsOk: tag => string, overrides the response line to the STARTTLS command
|
|
82
|
+
// - onTlsLine: (tlsSocket, line) => boolean, intercepts post-upgrade lines; return
|
|
83
|
+
// true when the line was handled
|
|
84
|
+
const createStartTlsServer = (opts = {}) =>
|
|
85
|
+
net.createServer(rawSocket => {
|
|
84
86
|
rawSocket.on('error', () => {});
|
|
85
87
|
|
|
88
|
+
let preTlsCaps = opts.preTlsCaps || `${CAPS} STARTTLS`;
|
|
89
|
+
let postTlsCaps = opts.postTlsCaps || CAPS;
|
|
90
|
+
|
|
86
91
|
let detachPlain;
|
|
87
92
|
detachPlain = lineReader(rawSocket, line => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
93
|
+
let parts = line.split(' ');
|
|
94
|
+
let tag = parts[0];
|
|
95
|
+
let cmd = (parts[1] || '').toUpperCase();
|
|
96
|
+
|
|
97
|
+
if (cmd === 'STARTTLS') {
|
|
98
|
+
rawSocket.write(opts.startTlsOk ? opts.startTlsOk(tag) : `${tag} OK Begin TLS\r\n`);
|
|
99
|
+
detachPlain();
|
|
100
|
+
let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
|
|
101
|
+
tlsSocket.on('error', () => {});
|
|
102
|
+
lineReader(tlsSocket, l => {
|
|
103
|
+
if (opts.onTlsLine && opts.onTlsLine(tlsSocket, l)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
handleLine(tlsSocket, l, null, postTlsCaps);
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
handleLine(rawSocket, line, null, preTlsCaps);
|
|
102
112
|
});
|
|
103
113
|
|
|
104
|
-
rawSocket.write(`* OK [CAPABILITY ${
|
|
114
|
+
rawSocket.write(`* OK [CAPABILITY ${preTlsCaps}] ready\r\n`);
|
|
105
115
|
});
|
|
106
116
|
|
|
107
|
-
|
|
108
|
-
|
|
117
|
+
// The client options shared by every STARTTLS test.
|
|
118
|
+
const makeStartTlsClient = (port, overrides = {}) =>
|
|
119
|
+
new ImapFlow({
|
|
109
120
|
host: '127.0.0.1',
|
|
110
121
|
port,
|
|
111
122
|
secure: false,
|
|
@@ -115,8 +126,30 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
|
|
|
115
126
|
disableAutoIdle: true,
|
|
116
127
|
disableCompression: true,
|
|
117
128
|
logger: false,
|
|
118
|
-
auth: { user: 'test', pass: 'secret' }
|
|
129
|
+
auth: { user: 'test', pass: 'secret' },
|
|
130
|
+
...overrides
|
|
119
131
|
});
|
|
132
|
+
|
|
133
|
+
// Every terminal upgrade path must leave no upgrade state behind
|
|
134
|
+
const assertUpgradeSettled = (test, client) => {
|
|
135
|
+
test.equal(client.upgrading, false, 'upgrading flag cleared');
|
|
136
|
+
test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
|
|
137
|
+
test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// STARTTLS happy path
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
|
|
145
|
+
// post-TLS phase advertises a different capability set
|
|
146
|
+
let server = createStartTlsServer({
|
|
147
|
+
preTlsCaps: `${CAPS} STARTTLS PRETLS-ONLY`,
|
|
148
|
+
postTlsCaps: `${CAPS} POSTTLS-ONLY`
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
let port = await listen(server);
|
|
152
|
+
let client = makeStartTlsClient(port);
|
|
120
153
|
client.on('error', () => {});
|
|
121
154
|
|
|
122
155
|
await client.connect();
|
|
@@ -141,43 +174,15 @@ module.exports['Secure: STARTTLS discards capabilities even when the OK carries
|
|
|
141
174
|
// a discard conditioned on "an update is still pending" would keep exactly the
|
|
142
175
|
// pre-TLS list an attacker controls - the list that then chooses the AUTH
|
|
143
176
|
// mechanism and answers LOGINDISABLED. RFC 9051 6.2.1 makes the discard mandatory.
|
|
144
|
-
let server =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
let parts = line.split(' ');
|
|
150
|
-
let tag = parts[0];
|
|
151
|
-
let cmd = (parts[1] || '').toUpperCase();
|
|
152
|
-
|
|
153
|
-
if (cmd === 'STARTTLS') {
|
|
154
|
-
rawSocket.write(`${tag} OK [CAPABILITY ${CAPS} PRETLS-ONLY] Begin TLS\r\n`);
|
|
155
|
-
detachPlain();
|
|
156
|
-
let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
|
|
157
|
-
tlsSocket.on('error', () => {});
|
|
158
|
-
lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, `${CAPS} POSTTLS-ONLY`));
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
handleLine(rawSocket, line, null, `${CAPS} STARTTLS PRETLS-ONLY`);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS PRETLS-ONLY] ready\r\n`);
|
|
177
|
+
let server = createStartTlsServer({
|
|
178
|
+
preTlsCaps: `${CAPS} STARTTLS PRETLS-ONLY`,
|
|
179
|
+
postTlsCaps: `${CAPS} POSTTLS-ONLY`,
|
|
180
|
+
// the OK itself carries a (pre-TLS, attacker-rewritable) CAPABILITY code
|
|
181
|
+
startTlsOk: tag => `${tag} OK [CAPABILITY ${CAPS} PRETLS-ONLY] Begin TLS\r\n`
|
|
166
182
|
});
|
|
167
183
|
|
|
168
184
|
let port = await listen(server);
|
|
169
|
-
let client =
|
|
170
|
-
host: '127.0.0.1',
|
|
171
|
-
port,
|
|
172
|
-
secure: false,
|
|
173
|
-
doSTARTTLS: true,
|
|
174
|
-
servername: 'localhost',
|
|
175
|
-
tls: { rejectUnauthorized: false },
|
|
176
|
-
disableAutoIdle: true,
|
|
177
|
-
disableCompression: true,
|
|
178
|
-
logger: false,
|
|
179
|
-
auth: { user: 'test', pass: 'secret' }
|
|
180
|
-
});
|
|
185
|
+
let client = makeStartTlsClient(port);
|
|
181
186
|
client.on('error', () => {});
|
|
182
187
|
|
|
183
188
|
await client.connect();
|
|
@@ -191,23 +196,40 @@ module.exports['Secure: STARTTLS discards capabilities even when the OK carries
|
|
|
191
196
|
test.done();
|
|
192
197
|
};
|
|
193
198
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
199
|
+
module.exports['Secure: pre-TLS rawCapabilities do not survive a failed re-fetch'] = async test => {
|
|
200
|
+
// capabilities/authCapabilities are discarded at the upgrade, but rawCapabilities
|
|
201
|
+
// is public surface external consumers read. If the post-TLS CAPABILITY re-fetch
|
|
202
|
+
// fails, the pre-TLS list - the one an active attacker can rewrite - must not
|
|
203
|
+
// linger there either.
|
|
204
|
+
let server = createStartTlsServer({
|
|
205
|
+
preTlsCaps: `${CAPS} STARTTLS PRETLS-ONLY`,
|
|
206
|
+
postTlsCaps: `${CAPS} POSTTLS-ONLY`,
|
|
207
|
+
onTlsLine: (tlsSocket, line) => {
|
|
208
|
+
let parts = line.split(' ');
|
|
209
|
+
if ((parts[1] || '').toUpperCase() === 'CAPABILITY') {
|
|
210
|
+
// the re-fetch over TLS fails
|
|
211
|
+
tlsSocket.write(`${parts[0]} NO CAPABILITY not available\r\n`);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
198
217
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
detachPlain();
|
|
203
|
-
let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
|
|
204
|
-
tlsSocket.on('error', () => {});
|
|
205
|
-
lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, CAPS));
|
|
206
|
-
});
|
|
207
|
-
});
|
|
218
|
+
let port = await listen(server);
|
|
219
|
+
let client = makeStartTlsClient(port);
|
|
220
|
+
client.on('error', () => {});
|
|
208
221
|
|
|
209
|
-
|
|
210
|
-
|
|
222
|
+
await client.connect();
|
|
223
|
+
test.ok(client.secureConnection, 'connection upgraded to TLS');
|
|
224
|
+
test.ok(!client.capabilities.has('PRETLS-ONLY'), 'pre-TLS capabilities were discarded');
|
|
225
|
+
let raw = [].concat(client.rawCapabilities || []);
|
|
226
|
+
test.ok(!raw.some(entry => entry && /PRETLS-ONLY/.test(entry.value || entry)), 'pre-TLS rawCapabilities were discarded');
|
|
227
|
+
|
|
228
|
+
await client.logout();
|
|
229
|
+
client.close();
|
|
230
|
+
server.close();
|
|
231
|
+
test.done();
|
|
232
|
+
};
|
|
211
233
|
|
|
212
234
|
// Records every socket that went through configureSocket(), which is the single place the
|
|
213
235
|
// transport options (keepalive + inactivity watchdog) are applied.
|
|
@@ -227,19 +249,7 @@ module.exports['Secure: STARTTLS session keeps the inactivity watchdog'] = async
|
|
|
227
249
|
// and a dead connection was never noticed.
|
|
228
250
|
let server = createStartTlsServer();
|
|
229
251
|
let port = await listen(server);
|
|
230
|
-
let client =
|
|
231
|
-
host: '127.0.0.1',
|
|
232
|
-
port,
|
|
233
|
-
secure: false,
|
|
234
|
-
doSTARTTLS: true,
|
|
235
|
-
servername: 'localhost',
|
|
236
|
-
tls: { rejectUnauthorized: false },
|
|
237
|
-
socketTimeout: 200,
|
|
238
|
-
disableAutoIdle: true,
|
|
239
|
-
disableCompression: true,
|
|
240
|
-
logger: false,
|
|
241
|
-
auth: { user: 'test', pass: 'secret' }
|
|
242
|
-
});
|
|
252
|
+
let client = makeStartTlsClient(port, { socketTimeout: 200 });
|
|
243
253
|
|
|
244
254
|
let errors = [];
|
|
245
255
|
client.on('error', err => errors.push(err));
|
|
@@ -271,25 +281,12 @@ module.exports['Secure: STARTTLS leaves no upgrade state behind on success'] = a
|
|
|
271
281
|
// a successful handshake no timer, rejector, flag or temporary handler survives.
|
|
272
282
|
let server = createStartTlsServer();
|
|
273
283
|
let port = await listen(server);
|
|
274
|
-
let client =
|
|
275
|
-
host: '127.0.0.1',
|
|
276
|
-
port,
|
|
277
|
-
secure: false,
|
|
278
|
-
doSTARTTLS: true,
|
|
279
|
-
servername: 'localhost',
|
|
280
|
-
tls: { rejectUnauthorized: false },
|
|
281
|
-
disableAutoIdle: true,
|
|
282
|
-
disableCompression: true,
|
|
283
|
-
logger: false,
|
|
284
|
-
auth: { user: 'test', pass: 'secret' }
|
|
285
|
-
});
|
|
284
|
+
let client = makeStartTlsClient(port);
|
|
286
285
|
client.on('error', () => {});
|
|
287
286
|
|
|
288
287
|
await client.connect();
|
|
289
288
|
|
|
290
|
-
test
|
|
291
|
-
test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
|
|
292
|
-
test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
|
|
289
|
+
assertUpgradeSettled(test, client);
|
|
293
290
|
// The handshake-only handler is gone, leaving the generic socket error handler as the single
|
|
294
291
|
// error path (during the handshake it is the other way round, which is what prevents a
|
|
295
292
|
// handshake error from firing two handlers at once).
|
|
@@ -314,18 +311,7 @@ module.exports['Secure: close() during a STARTTLS upgrade settles the upgrade']
|
|
|
314
311
|
rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
|
|
315
312
|
});
|
|
316
313
|
let port = await listen(server);
|
|
317
|
-
let client =
|
|
318
|
-
host: '127.0.0.1',
|
|
319
|
-
port,
|
|
320
|
-
secure: false,
|
|
321
|
-
doSTARTTLS: true,
|
|
322
|
-
servername: 'localhost',
|
|
323
|
-
tls: { rejectUnauthorized: false },
|
|
324
|
-
disableAutoIdle: true,
|
|
325
|
-
disableCompression: true,
|
|
326
|
-
logger: false,
|
|
327
|
-
auth: { user: 'test', pass: 'secret' }
|
|
328
|
-
});
|
|
314
|
+
let client = makeStartTlsClient(port);
|
|
329
315
|
client.on('error', () => {});
|
|
330
316
|
|
|
331
317
|
let connectResult = client.connect().then(
|
|
@@ -337,15 +323,46 @@ module.exports['Secure: close() during a STARTTLS upgrade settles the upgrade']
|
|
|
337
323
|
while (!client.upgrading) {
|
|
338
324
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
339
325
|
}
|
|
326
|
+
// A late socket event would invoke the same settle helper the rejector exposes -
|
|
327
|
+
// capture it before close() consumes it
|
|
328
|
+
let settle = client._upgradeReject;
|
|
340
329
|
client.close();
|
|
341
330
|
|
|
342
331
|
let err = await connectResult;
|
|
343
332
|
test.ok(err, 'connect rejected rather than hanging on the abandoned upgrade');
|
|
344
333
|
test.ok(['ClosedAfterConnectText', 'ClosedAfterConnectTLS', 'NoConnection'].includes(err.code), `connect rejected with ${err.code}`);
|
|
345
|
-
test
|
|
346
|
-
|
|
347
|
-
|
|
334
|
+
assertUpgradeSettled(test, client);
|
|
335
|
+
|
|
336
|
+
// The late event lands after the upgrade already settled - it must be a no-op:
|
|
337
|
+
// a settled upgrade neither claims the error as a TLS failure nor re-arms any state
|
|
338
|
+
let lateErr = new Error('late socket error');
|
|
339
|
+
settle(lateErr);
|
|
340
|
+
test.ok(!lateErr.tlsFailed, 'the late error was not marked as a TLS upgrade failure');
|
|
341
|
+
assertUpgradeSettled(test, client);
|
|
342
|
+
|
|
343
|
+
server.close();
|
|
344
|
+
test.done();
|
|
345
|
+
};
|
|
348
346
|
|
|
347
|
+
module.exports['Secure: STARTTLS handshake failure rejects connect'] = async test => {
|
|
348
|
+
let server = createStartTlsServer();
|
|
349
|
+
let port = await listen(server);
|
|
350
|
+
// The tls option is erased entirely, not merely relaxed: certificate validation
|
|
351
|
+
// stays on, so the mock server's self-signed certificate must fail the handshake,
|
|
352
|
+
// and the upgrade builds its TLS options from the no-options fallback
|
|
353
|
+
let client = makeStartTlsClient(port, { tls: undefined });
|
|
354
|
+
client.on('error', () => {});
|
|
355
|
+
|
|
356
|
+
let err = await client.connect().then(
|
|
357
|
+
() => null,
|
|
358
|
+
connectErr => connectErr
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
test.ok(err, 'connect rejected on the failed handshake');
|
|
362
|
+
test.ok(err.tlsFailed, 'the error is marked as a TLS upgrade failure');
|
|
363
|
+
assertUpgradeSettled(test, client);
|
|
364
|
+
|
|
365
|
+
client.close();
|
|
349
366
|
server.close();
|
|
350
367
|
test.done();
|
|
351
368
|
};
|
|
@@ -1346,3 +1346,129 @@ module.exports['Server: an unparseable tagged completion fails the command inste
|
|
|
1346
1346
|
server.close();
|
|
1347
1347
|
test.done();
|
|
1348
1348
|
};
|
|
1349
|
+
|
|
1350
|
+
module.exports['Server: a NUL-padded unparseable completion still fails the command'] = async test => {
|
|
1351
|
+
// Buggy servers pad lines with leading NUL bytes; the parser strips them before
|
|
1352
|
+
// reading the tag. The unparsed-completion recovery has to see the same tag the
|
|
1353
|
+
// parser saw, or the command hangs for exactly the server class the NUL
|
|
1354
|
+
// workaround exists for.
|
|
1355
|
+
let server = createServer({
|
|
1356
|
+
handlers: {
|
|
1357
|
+
SELECT(ctx) {
|
|
1358
|
+
ctx.write(`\x00\x00${ctx.tag} OK [\x01BAD-CODE] SELECT completed\r\n`);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
let port = await listen(server);
|
|
1363
|
+
let client = makeClient(port);
|
|
1364
|
+
client.on('error', () => {});
|
|
1365
|
+
|
|
1366
|
+
await client.connect();
|
|
1367
|
+
|
|
1368
|
+
let selectErr = null;
|
|
1369
|
+
try {
|
|
1370
|
+
await client.mailboxOpen('INBOX');
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
selectErr = err;
|
|
1373
|
+
}
|
|
1374
|
+
test.ok(selectErr, 'the command whose completion could not be parsed must reject');
|
|
1375
|
+
|
|
1376
|
+
let folders = await client.list();
|
|
1377
|
+
test.ok(Array.isArray(folders) && folders.length, 'later commands still run');
|
|
1378
|
+
|
|
1379
|
+
await client.logout();
|
|
1380
|
+
client.close();
|
|
1381
|
+
server.close();
|
|
1382
|
+
test.done();
|
|
1383
|
+
};
|
|
1384
|
+
|
|
1385
|
+
module.exports['Server: a sequence-shaped token in a server response does not fail the connection'] = async test => {
|
|
1386
|
+
// The incoming token parser accepts sequence-shaped tokens the strict outgoing
|
|
1387
|
+
// grammar rejects ("1:2:3"). Every parsed response is re-compiled for the log, so
|
|
1388
|
+
// that pass must skip the validation - one quirky but parseable server line would
|
|
1389
|
+
// otherwise tear down the whole connection.
|
|
1390
|
+
let server = createServer({
|
|
1391
|
+
handlers: {
|
|
1392
|
+
NOOP(ctx) {
|
|
1393
|
+
ctx.write(`${ctx.tag} OK [XDATA 1:2:3] NOOP completed\r\n`);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
});
|
|
1397
|
+
let port = await listen(server);
|
|
1398
|
+
let client = makeClient(port);
|
|
1399
|
+
client.on('error', () => {});
|
|
1400
|
+
|
|
1401
|
+
await client.connect();
|
|
1402
|
+
await client.noop();
|
|
1403
|
+
test.ok(client.usable, 'connection must stay usable');
|
|
1404
|
+
|
|
1405
|
+
let folders = await client.list();
|
|
1406
|
+
test.ok(Array.isArray(folders) && folders.length, 'later commands still run');
|
|
1407
|
+
|
|
1408
|
+
await client.logout();
|
|
1409
|
+
client.close();
|
|
1410
|
+
server.close();
|
|
1411
|
+
test.done();
|
|
1412
|
+
};
|
|
1413
|
+
|
|
1414
|
+
module.exports['Server: an invalid range rejects the command without wedging the queue'] = async test => {
|
|
1415
|
+
// The compiler refuses invalid sequence sets before anything reaches the wire.
|
|
1416
|
+
// The dispatch layer has to treat that as the command's own failure: the request
|
|
1417
|
+
// must reject (even when queued behind an in-flight command) and the queue must
|
|
1418
|
+
// keep moving instead of waiting forever on a response that can never arrive.
|
|
1419
|
+
let server = createServer();
|
|
1420
|
+
let port = await listen(server);
|
|
1421
|
+
let client = makeClient(port);
|
|
1422
|
+
client.on('error', () => {});
|
|
1423
|
+
|
|
1424
|
+
await client.connect();
|
|
1425
|
+
await client.mailboxOpen('INBOX');
|
|
1426
|
+
|
|
1427
|
+
let err = null;
|
|
1428
|
+
try {
|
|
1429
|
+
await client.fetchOne('1;2', { uid: true });
|
|
1430
|
+
} catch (e) {
|
|
1431
|
+
err = e;
|
|
1432
|
+
}
|
|
1433
|
+
test.ok(err, 'the invalid range must reject');
|
|
1434
|
+
test.equal(err && err.code, 'InvalidSequenceSet');
|
|
1435
|
+
|
|
1436
|
+
// Queued variant: the invalid command sits behind an in-flight one; every promise
|
|
1437
|
+
// must settle and the command behind the invalid one must still run
|
|
1438
|
+
let results = await Promise.allSettled([client.noop(), client.fetchOne('3;4', { uid: true }), client.noop()]);
|
|
1439
|
+
test.equal(results[0].status, 'fulfilled', 'command before the invalid one succeeds');
|
|
1440
|
+
test.equal(results[1].status, 'rejected', 'queued invalid command must reject, not hang');
|
|
1441
|
+
test.equal(results[2].status, 'fulfilled', 'command after the invalid one still runs');
|
|
1442
|
+
|
|
1443
|
+
await client.logout();
|
|
1444
|
+
client.close();
|
|
1445
|
+
server.close();
|
|
1446
|
+
test.done();
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
module.exports['Server: a throwing response listener does not fail the command'] = async test => {
|
|
1450
|
+
// 'response' is emitted after a tagged completion parses successfully. A listener
|
|
1451
|
+
// throwing synchronously used to be caught by the parse-failure path, which
|
|
1452
|
+
// rejected the in-flight command with a bogus ParserError even though the server
|
|
1453
|
+
// had executed it.
|
|
1454
|
+
let server = createServer();
|
|
1455
|
+
let port = await listen(server);
|
|
1456
|
+
let client = makeClient(port);
|
|
1457
|
+
client.on('error', () => {});
|
|
1458
|
+
|
|
1459
|
+
await client.connect();
|
|
1460
|
+
client.on('response', payload => {
|
|
1461
|
+
if (payload.response === 'OK') {
|
|
1462
|
+
throw new Error('listener bug');
|
|
1463
|
+
}
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
await client.noop();
|
|
1467
|
+
let mailbox = await client.mailboxOpen('INBOX');
|
|
1468
|
+
test.ok(mailbox, 'commands succeed despite the throwing listener');
|
|
1469
|
+
|
|
1470
|
+
await client.logout();
|
|
1471
|
+
client.close();
|
|
1472
|
+
server.close();
|
|
1473
|
+
test.done();
|
|
1474
|
+
};
|
package/test/imap-parser-test.js
CHANGED
|
@@ -1447,3 +1447,28 @@ module.exports['IMAP Parser: balanced brackets inside a response code are still
|
|
|
1447
1447
|
test.equal(text, 'Flags permitted.');
|
|
1448
1448
|
test.equal(parsed.attributes[0].section[0].value, 'PERMANENTFLAGS');
|
|
1449
1449
|
});
|
|
1450
|
+
|
|
1451
|
+
module.exports['IMAP Parser: a parse failure after the tag exposes the parsed tag'] = test =>
|
|
1452
|
+
asyncWrapper(test, async test => {
|
|
1453
|
+
// The connection settles the in-flight command from an unparseable tagged
|
|
1454
|
+
// completion using this tag - re-deriving it from the raw bytes instead would
|
|
1455
|
+
// bypass the leading-NUL workaround and strand the command
|
|
1456
|
+
let err = null;
|
|
1457
|
+
try {
|
|
1458
|
+
await parser(Buffer.from('A5 OK [\x01BAD-CODE] done', 'binary'));
|
|
1459
|
+
} catch (e) {
|
|
1460
|
+
err = e;
|
|
1461
|
+
}
|
|
1462
|
+
test.ok(err, 'the line must fail to parse');
|
|
1463
|
+
test.equal(err && err.parsedTag, 'A5');
|
|
1464
|
+
|
|
1465
|
+
// The NUL-padding workaround is inherited: the exposed tag is the stripped one
|
|
1466
|
+
err = null;
|
|
1467
|
+
try {
|
|
1468
|
+
await parser(Buffer.from('\x00\x00A6 OK [\x01BAD-CODE] done', 'binary'));
|
|
1469
|
+
} catch (e) {
|
|
1470
|
+
err = e;
|
|
1471
|
+
}
|
|
1472
|
+
test.ok(err, 'the padded line must fail to parse');
|
|
1473
|
+
test.equal(err && err.parsedTag, 'A6');
|
|
1474
|
+
});
|
|
@@ -475,15 +475,39 @@ module.exports['Literal marker scan stays linear on a long digit run'] = test =>
|
|
|
475
475
|
test.done();
|
|
476
476
|
};
|
|
477
477
|
|
|
478
|
-
module.exports['Literal marker
|
|
478
|
+
module.exports['Literal marker fails the stream on an oversized digit run'] = test => {
|
|
479
|
+
// An impossible size is still a syntactically valid marker. Treating it as an
|
|
480
|
+
// ordinary line instead would feed the announced literal body to the line parser
|
|
481
|
+
// and desynchronize the session, so the stream must end with LiteralTooLarge.
|
|
479
482
|
const stream = new ImapStream({ cid: 'test' });
|
|
480
|
-
|
|
483
|
+
let streamErr = null;
|
|
484
|
+
stream.on('error', err => {
|
|
485
|
+
streamErr = err;
|
|
486
|
+
});
|
|
481
487
|
stream.resume();
|
|
482
488
|
|
|
483
|
-
// Longer than any number64 can be, so it cannot be a valid marker
|
|
484
489
|
const line = Buffer.concat([Buffer.from('* OK {'), Buffer.from('1'.repeat(40)), Buffer.from('}\r\n')]);
|
|
485
490
|
|
|
486
491
|
test.equal(stream.checkLiteralMarker(line), false, 'an impossible size must not start literal mode');
|
|
492
|
+
test.ok(stream.destroyed, 'the stream must fail closed instead of continuing as if the marker were text');
|
|
493
|
+
setImmediate(() => {
|
|
494
|
+
test.ok(streamErr && streamErr.code === 'LiteralTooLarge', 'must fail with LiteralTooLarge');
|
|
495
|
+
test.done();
|
|
496
|
+
});
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
module.exports['Literal marker accepts a zero-padded size'] = test => {
|
|
500
|
+
// The RFC "number" production is 1*DIGIT, so leading zeros are legal - a long
|
|
501
|
+
// digit run can still denote a small size and must be consumed as a literal
|
|
502
|
+
const stream = new ImapStream({ cid: 'test' });
|
|
503
|
+
stream.on('error', () => {});
|
|
504
|
+
stream.resume();
|
|
505
|
+
|
|
506
|
+
const line = Buffer.from(`* 1 FETCH (BODY[] {${'0'.repeat(21)}123}\r\n`);
|
|
507
|
+
|
|
508
|
+
test.equal(stream.checkLiteralMarker(line), true, 'a zero-padded marker is a valid literal marker');
|
|
509
|
+
test.equal(stream.literalWaiting, 123, 'the padded size must parse to its numeric value');
|
|
510
|
+
stream.destroy();
|
|
487
511
|
test.done();
|
|
488
512
|
};
|
|
489
513
|
|
|
@@ -504,3 +528,139 @@ module.exports['Literal marker still accepts sizes at the digit-length bound'] =
|
|
|
504
528
|
test.equal(ok.literalWaiting, 1024);
|
|
505
529
|
test.done();
|
|
506
530
|
};
|
|
531
|
+
|
|
532
|
+
module.exports['Response assembly enforces the cumulative size cap'] = test => {
|
|
533
|
+
// The per-line and per-literal caps alone cannot stop a response spread across many
|
|
534
|
+
// tokens: under a 40-byte response budget, a 25-byte marker line plus a declared
|
|
535
|
+
// 10-byte literal fits (35), but the next marker line (16 bytes + 10 declared) must
|
|
536
|
+
// trip the cap - before the second literal's bytes are even read
|
|
537
|
+
const stream = new ImapStream({ cid: 'test', maxResponseSize: 40 });
|
|
538
|
+
let streamErr = null;
|
|
539
|
+
stream.on('error', err => {
|
|
540
|
+
streamErr = err;
|
|
541
|
+
});
|
|
542
|
+
stream.resume();
|
|
543
|
+
|
|
544
|
+
stream.write(Buffer.from('* 1 FETCH (BODY[1] {10}\r\n'));
|
|
545
|
+
stream.write(Buffer.from('0123456789'));
|
|
546
|
+
stream.write(Buffer.from(' BODY[2] {10}\r\n0123456789)\r\n'));
|
|
547
|
+
|
|
548
|
+
setTimeout(() => {
|
|
549
|
+
test.ok(streamErr, 'an oversized cumulative response must fail the stream');
|
|
550
|
+
test.equal(streamErr && streamErr.code, 'ResponseTooLarge');
|
|
551
|
+
test.ok(stream.destroyed, 'the stream must fail closed instead of parsing the rejected payload');
|
|
552
|
+
test.done();
|
|
553
|
+
}, 100);
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// Resolves once the stream has settled - on its first 'error', or on 'end' when it is
|
|
557
|
+
// consumed to completion. Waiting for the real signal keeps these tests off fixed sleeps.
|
|
558
|
+
const settle = stream =>
|
|
559
|
+
new Promise(resolve => {
|
|
560
|
+
stream.once('error', err => resolve(err));
|
|
561
|
+
stream.once('end', () => resolve(null));
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
module.exports['Response size budget resets between responses'] = async test => {
|
|
565
|
+
// The counter tracks a single response, not the whole session. Each response here fits
|
|
566
|
+
// the 40-byte budget on its own but the two together do not, so a counter that failed to
|
|
567
|
+
// reset would trip the cap on the second one - which is what makes this test detect the
|
|
568
|
+
// regression rather than merely pass alongside it.
|
|
569
|
+
const line = '* OK ' + 'a'.repeat(23) + '\r\n'; // 30 bytes, two of them exceed the budget
|
|
570
|
+
const stream = new ImapStream({ cid: 'test', maxResponseSize: 40 });
|
|
571
|
+
let streamErr = null;
|
|
572
|
+
let count = 0;
|
|
573
|
+
stream.on('error', err => {
|
|
574
|
+
streamErr = err;
|
|
575
|
+
});
|
|
576
|
+
stream.on('data', cmd => {
|
|
577
|
+
count++;
|
|
578
|
+
cmd.next();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test.ok(line.length <= 40 && line.length * 2 > 40, 'each response fits the budget, the pair does not');
|
|
582
|
+
|
|
583
|
+
stream.write(Buffer.from(line + line));
|
|
584
|
+
stream.end();
|
|
585
|
+
|
|
586
|
+
await settle(stream);
|
|
587
|
+
test.ifError(streamErr);
|
|
588
|
+
test.equal(count, 2);
|
|
589
|
+
test.done();
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
module.exports['Response size cap defaults above the literal cap'] = test => {
|
|
593
|
+
// The response total also carries the literal marker line and the rest of the framing, so
|
|
594
|
+
// a default equal to the literal cap would make a literal of exactly the maximum permitted
|
|
595
|
+
// size impossible to receive
|
|
596
|
+
const stream = new ImapStream({ cid: 'test' });
|
|
597
|
+
test.equal(stream.maxResponseSize, 2 * 1024 * 1024 * 1024);
|
|
598
|
+
test.ok(stream.maxResponseSize > stream.maxLiteralSize, 'the response cap must leave headroom above the literal cap');
|
|
599
|
+
test.done();
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
module.exports['A literal of exactly maxLiteralSize is accepted when the response cap leaves headroom'] = async test => {
|
|
603
|
+
const stream = new ImapStream({ cid: 'test', maxLiteralSize: 100, maxResponseSize: 200 });
|
|
604
|
+
let streamErr = null;
|
|
605
|
+
let received = null;
|
|
606
|
+
stream.on('error', err => {
|
|
607
|
+
streamErr = err;
|
|
608
|
+
});
|
|
609
|
+
stream.on('data', cmd => {
|
|
610
|
+
received = cmd;
|
|
611
|
+
cmd.next();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
stream.write(Buffer.from('* 1 FETCH (BODY[] {100}\r\n'));
|
|
615
|
+
stream.write(Buffer.from('x'.repeat(100)));
|
|
616
|
+
stream.write(Buffer.from(')\r\n'));
|
|
617
|
+
stream.end();
|
|
618
|
+
|
|
619
|
+
await settle(stream);
|
|
620
|
+
test.ifError(streamErr);
|
|
621
|
+
test.ok(received, 'a literal at exactly the configured maximum must be delivered');
|
|
622
|
+
test.equal(received.literals.length, 1);
|
|
623
|
+
test.equal(received.literals[0].length, 100);
|
|
624
|
+
test.done();
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
module.exports['An unterminated line is bounded by the response budget'] = async test => {
|
|
628
|
+
// maxResponseSize is only committed when a line completes, so an in-progress line has to
|
|
629
|
+
// be measured against the remaining budget separately - otherwise a response cap lowered
|
|
630
|
+
// to bound parser memory buys nothing while a server streams a line that never ends
|
|
631
|
+
const stream = new ImapStream({ cid: 'test', maxResponseSize: 64 });
|
|
632
|
+
stream.resume();
|
|
633
|
+
|
|
634
|
+
stream.write(Buffer.from('x'.repeat(1024))); // no line terminator anywhere
|
|
635
|
+
|
|
636
|
+
let err = await settle(stream);
|
|
637
|
+
test.ok(err, 'an unterminated line beyond the response budget must fail the stream');
|
|
638
|
+
test.equal(err.code, 'ResponseTooLarge');
|
|
639
|
+
test.ok(stream.lineBytes <= 64, 'no more than the budget may stay buffered');
|
|
640
|
+
test.done();
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
module.exports['Infinity disables a parser size cap'] = test => {
|
|
644
|
+
// A cap that cannot be disabled forces a caller who knows their server onto the default
|
|
645
|
+
const stream = new ImapStream({ cid: 'test', maxResponseSize: Infinity, maxLiteralSize: Infinity, maxLineLength: Infinity });
|
|
646
|
+
test.equal(stream.maxResponseSize, Infinity);
|
|
647
|
+
test.equal(stream.maxLiteralSize, Infinity);
|
|
648
|
+
test.equal(stream.maxLineLength, Infinity);
|
|
649
|
+
test.done();
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
module.exports['A marker line that fits the budget can still be refused for its literal'] = async test => {
|
|
653
|
+
// The line is measured against the budget as it is assembled, but the declared literal is
|
|
654
|
+
// only charged once the marker line completes - so the cap has to be enforced in both places
|
|
655
|
+
const stream = new ImapStream({ cid: 'test', maxResponseSize: 40 });
|
|
656
|
+
stream.resume();
|
|
657
|
+
|
|
658
|
+
// 25-byte marker line fits on its own; the 30 declared literal bytes push the total past 40
|
|
659
|
+
stream.write(Buffer.from('* 1 FETCH (BODY[1] {30}\r\n'));
|
|
660
|
+
|
|
661
|
+
let err = await settle(stream);
|
|
662
|
+
test.ok(err, 'the declared literal must be charged before its bytes arrive');
|
|
663
|
+
test.equal(err.code, 'ResponseTooLarge');
|
|
664
|
+
test.equal(err.responseSize, 55);
|
|
665
|
+
test.done();
|
|
666
|
+
};
|