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.
Files changed (46) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/lib/commands/append.js +26 -4
  4. package/lib/commands/compress.js +29 -18
  5. package/lib/commands/copyuid-parser.js +4 -2
  6. package/lib/commands/expunge.js +5 -2
  7. package/lib/commands/fetch.js +9 -3
  8. package/lib/commands/list.js +18 -38
  9. package/lib/commands/namespace.js +2 -2
  10. package/lib/commands/quota.js +10 -2
  11. package/lib/commands/search.js +54 -14
  12. package/lib/commands/select.js +81 -70
  13. package/lib/commands/status-fields.js +68 -0
  14. package/lib/commands/status.js +23 -61
  15. package/lib/handler/imap-compiler.js +91 -60
  16. package/lib/handler/imap-parser.js +7 -0
  17. package/lib/handler/imap-stream.js +78 -12
  18. package/lib/handler/limits.js +16 -4
  19. package/lib/imap-flow.d.ts +22 -2
  20. package/lib/imap-flow.js +209 -94
  21. package/lib/jp-decoder.js +30 -5
  22. package/lib/limited-passthrough.js +19 -1
  23. package/lib/search-compiler.js +24 -16
  24. package/lib/tools.js +190 -39
  25. package/package.json +4 -4
  26. package/test/commands-branches-test.js +4 -0
  27. package/test/commands-integration-test.js +780 -5
  28. package/test/copyuid-parser-test.js +20 -0
  29. package/test/idle-polling-test.js +81 -0
  30. package/test/imap-compiler-test.js +74 -4
  31. package/test/imap-flow-coverage-test.js +4 -2
  32. package/test/imap-flow-fetch-download-test.js +26 -0
  33. package/test/imap-flow-internals-test.js +134 -0
  34. package/test/imap-flow-methods-test.js +92 -0
  35. package/test/imap-flow-secure-test.js +133 -116
  36. package/test/imap-flow-server-test.js +126 -0
  37. package/test/imap-parser-test.js +25 -0
  38. package/test/imap-stream-edge-cases-test.js +163 -3
  39. package/test/integration/rev2-live-test.js +30 -0
  40. package/test/jp-decoder-test.js +57 -0
  41. package/test/limited-passthrough-test.js +24 -0
  42. package/test/parser-limits-test.js +18 -0
  43. package/test/reliability-improvements-test.js +3 -3
  44. package/test/search-compiler-test.js +90 -3
  45. package/test/timer-policy-test.js +27 -1
  46. package/test/tools-test.js +151 -2
@@ -212,6 +212,36 @@ module.exports['Live rev2: returnOptions search is answered via a real ESEARCH r
212
212
  test.done();
213
213
  };
214
214
 
215
+ module.exports['Live rev2: SEARCHRES saved result is usable as a sequence set'] = async test => {
216
+ // SEARCH RETURN (SAVE) stores the result server-side (RFC 5182) and '$'
217
+ // references it in later commands. '$' has to make it through the outgoing
218
+ // sequence-set validation for that to work at all.
219
+ const client = await connectClient();
220
+ try {
221
+ test.ok(client.capabilities.has('SEARCHRES'), 'Dovecot should advertise SEARCHRES');
222
+
223
+ await client.append('INBOX', Buffer.from('Subject: first\r\n\r\nfirst\r\n'));
224
+ await client.append('INBOX', Buffer.from('Subject: second\r\n\r\nsecond\r\n'));
225
+
226
+ await client.mailboxOpen('INBOX');
227
+ await client.search({ subject: 'second' }, { uid: true, returnOptions: ['SAVE'] });
228
+
229
+ let flagResult = await client.messageFlagsAdd('$', ['\\Flagged'], { uid: true });
230
+ test.ok(flagResult, 'STORE against the saved result must succeed');
231
+
232
+ let messages = [];
233
+ for await (let msg of client.fetch('$', { flags: true, envelope: true }, { uid: true })) {
234
+ messages.push(msg);
235
+ }
236
+ test.equal(messages.length, 1, 'the saved result references exactly the matched message');
237
+ test.equal(messages[0].envelope.subject, 'second');
238
+ test.ok(messages[0].flags.has('\\Flagged'), 'the flag change applied to the saved result');
239
+ } finally {
240
+ await client.logout();
241
+ }
242
+ test.done();
243
+ };
244
+
215
245
  module.exports['Live rev2: MODSEQ search criterion surfaces modseq from the ESEARCH response'] = async test => {
216
246
  const client = await connectClient();
217
247
  try {
@@ -245,3 +245,60 @@ module.exports['JPDecoder: handles EUC-JP charset'] = async test => {
245
245
  test.ok(result.length > 0);
246
246
  test.done();
247
247
  };
248
+
249
+ // ============================================
250
+ // maxBytes buffering bound
251
+ // ============================================
252
+
253
+ module.exports['JPDecoder: buffers at most maxBytes'] = test => {
254
+ // The whole-input buffering must honor the caller's download limit: without the
255
+ // bound a server could defeat maxBytes just by labelling a part with a Japanese
256
+ // charset, since nothing is emitted (and limited) until _flush
257
+ let decoder = new JPDecoder('iso-2022-jp', 8);
258
+ decoder.resume();
259
+
260
+ decoder.write(Buffer.from('hello '));
261
+ decoder.write(Buffer.from('world'));
262
+
263
+ test.equal(decoder.chunklen, 8);
264
+ test.equal(Buffer.concat(decoder.chunks).toString(), 'hello wo');
265
+ decoder.end();
266
+ test.done();
267
+ };
268
+
269
+ module.exports['JPDecoder: a fractional maxBytes still bounds the decoder'] = test => {
270
+ // A computed limit (a percentage of a budget, say) is not an integer, and silently turning
271
+ // that into "no limit at all" is exactly the exposure the bound exists to close
272
+ const decoder = new JPDecoder('sjis', 512.5);
273
+ test.equal(decoder.maxBytes, 512, 'a fractional bound is floored to a whole byte count');
274
+ decoder.resume();
275
+ decoder.write(Buffer.alloc(1024, 0x61));
276
+ test.equal(decoder.chunklen, 512, 'nothing beyond the bound is buffered');
277
+ test.equal(decoder.limited, true, 'the decoder reports that it is full');
278
+ decoder.end();
279
+ test.done();
280
+ };
281
+
282
+ module.exports['JPDecoder: a non-numeric maxBytes leaves the decoder unbounded'] = test => {
283
+ test.equal(new JPDecoder('sjis').maxBytes, Infinity);
284
+ test.equal(new JPDecoder('sjis', Infinity).maxBytes, Infinity);
285
+ test.equal(new JPDecoder('sjis', 'lots').maxBytes, Infinity);
286
+ test.equal(new JPDecoder('sjis', -1).maxBytes, Infinity);
287
+ test.equal(new JPDecoder('sjis').limited, false);
288
+ test.done();
289
+ };
290
+
291
+ module.exports['JPDecoder: reports limited once the bound is reached'] = test => {
292
+ // The decoder emits nothing until _flush, so a limiter downstream cannot tell that it is
293
+ // full - the download loop reads this flag instead to stop pulling from the server
294
+ const decoder = new JPDecoder('sjis', 10);
295
+ decoder.resume();
296
+ test.equal(decoder.limited, false);
297
+ decoder.write(Buffer.alloc(4, 0x61));
298
+ test.equal(decoder.limited, false, 'not full yet');
299
+ decoder.write(Buffer.alloc(6, 0x61));
300
+ test.equal(decoder.limited, true);
301
+ test.equal(decoder.chunklen, 10);
302
+ decoder.end();
303
+ test.done();
304
+ };
@@ -273,3 +273,27 @@ module.exports['LimitedPassthrough: single byte writes'] = async test => {
273
273
  test.equal(result.toString(), 'abc');
274
274
  test.done();
275
275
  };
276
+
277
+ module.exports['LimitedPassthrough: a fractional maxBytes is floored so limited is reachable'] = async test => {
278
+ // With a fractional bound `processed` can only ever reach its floor, so `limited` would
279
+ // never flip - and the download loop polls exactly that flag to stop pulling
280
+ let stream = new LimitedPassthrough({ maxBytes: 512.5 });
281
+ test.equal(stream.maxBytes, 512);
282
+
283
+ let chunks = [];
284
+ stream.on('data', chunk => chunks.push(chunk));
285
+ stream.write(Buffer.alloc(1024, 0x61));
286
+ stream.end();
287
+ await new Promise(resolve => stream.on('end', resolve));
288
+
289
+ test.equal(Buffer.concat(chunks).length, 512);
290
+ test.equal(stream.limited, true, 'the limiter must report that it is full');
291
+ test.done();
292
+ };
293
+
294
+ module.exports['LimitedPassthrough: a numeric-string maxBytes is honored'] = test => {
295
+ test.equal(new LimitedPassthrough({ maxBytes: '100' }).maxBytes, 100);
296
+ test.equal(new LimitedPassthrough({ maxBytes: 'lots' }).maxBytes, Infinity);
297
+ test.equal(new LimitedPassthrough({ maxBytes: -5 }).maxBytes, Infinity);
298
+ test.done();
299
+ };
@@ -9,6 +9,7 @@
9
9
 
10
10
  const { ImapStream } = require('../lib/handler/imap-stream');
11
11
  const { parser } = require('../lib/handler/imap-handler');
12
+ const { MAX_LINE_SIZE, MAX_LITERAL_SIZE, MAX_RESPONSE_SIZE } = require('../lib/handler/limits');
12
13
 
13
14
  // Collects every emitted command payload and the first stream error. `error` resolves once the
14
15
  // stream fails; `settled` resolves once processing has quiesced (error or writable end).
@@ -272,3 +273,20 @@ module.exports['Parser limits: maxLiteralSize 0 rejects any non-empty inline lit
272
273
  test.equal(err.maxSize, 0);
273
274
  test.done();
274
275
  };
276
+
277
+ module.exports['Parser limits: a junk limit falls back to the default'] = test => {
278
+ // Without validation an unusable value is honored as-is, and every comparison against it is
279
+ // false - so a caller who mistypes a limit silently gets no bound at all, or (for a negative
280
+ // value) a bound that rejects everything
281
+ for (let junk of ['4096', 'abc', 1.5, -1, -Infinity, null, {}, [], NaN, true]) {
282
+ const stream = new ImapStream({ cid: 'test', maxLineLength: junk, maxLiteralSize: junk, maxResponseSize: junk });
283
+ test.equal(stream.maxLineLength, MAX_LINE_SIZE, `maxLineLength ${JSON.stringify(junk)} must fall back`);
284
+ test.equal(stream.maxLiteralSize, MAX_LITERAL_SIZE, `maxLiteralSize ${JSON.stringify(junk)} must fall back`);
285
+ test.equal(stream.maxResponseSize, MAX_RESPONSE_SIZE, `maxResponseSize ${JSON.stringify(junk)} must fall back`);
286
+ }
287
+
288
+ // an explicit 0 is a real limit, not a missing one, and must not be swallowed
289
+ const zero = new ImapStream({ cid: 'test', maxLiteralSize: 0 });
290
+ test.equal(zero.maxLiteralSize, 0);
291
+ test.done();
292
+ };
@@ -422,14 +422,14 @@ module.exports['Reliability: throttle back-off aborts promptly on close()'] = as
422
422
 
423
423
  // Let reader() reach the (tracked) back-off wait.
424
424
  await new Promise(r => setTimeout(r, 50));
425
- test.ok(client._throttleTimer, 'back-off timer is tracked while waiting');
425
+ test.equal(client._throttleWaits.size, 1, 'back-off wait is tracked while waiting');
426
426
 
427
427
  client.close();
428
428
  await new Promise(r => setImmediate(r));
429
429
 
430
430
  test.ok(rejected, 'request rejected promptly after close()');
431
431
  test.equal(rejected.code, 'NoConnection', 'rejected with connection error, not ETHROTTLE');
432
- test.equal(client._throttleTimer, null, 'throttle timer cleared on close()');
432
+ test.equal(client._throttleWaits.size, 0, 'throttle wait cleared on close()');
433
433
  test.ok(Date.now() - start < 5000, 'settled well under the 5-minute cap');
434
434
 
435
435
  await readerDone;
@@ -457,7 +457,7 @@ module.exports['Reliability: throttle back-off still rejects ETHROTTLE on normal
457
457
  test.ok(rejected, 'request rejected after the back-off elapses');
458
458
  test.equal(rejected.code, 'ETHROTTLE', 'normal expiry still rejects ETHROTTLE');
459
459
  test.equal(rejected.throttleReset, 50, 'throttleReset preserved');
460
- test.equal(client._throttleTimer, null, 'throttle timer cleared after normal expiry');
460
+ test.equal(client._throttleWaits.size, 0, 'throttle wait cleared after normal expiry');
461
461
 
462
462
  await readerDone;
463
463
  client.close();
@@ -87,13 +87,49 @@ module.exports['Search Compiler: SEQ with number'] = test => {
87
87
  test.done();
88
88
  };
89
89
 
90
- module.exports['Search Compiler: SEQ ignores invalid values'] = test => {
90
+ module.exports['Search Compiler: SEQ passes invalid values through to the compiler guard'] = test => {
91
91
  let connection = createMockConnection();
92
92
 
93
- // Whitespace in sequence is invalid
93
+ // An invalid sequence string used to be dropped silently here, which turned the
94
+ // filter into an unrestricted search matching every message. It is now emitted as
95
+ // a SEQUENCE token so the IMAP compiler rejects it with a coded error instead.
94
96
  let compiled = searchCompiler(connection, { seq: '1 2 3' });
95
- test.equal(compiled.length, 0);
97
+ let seqAttr = compiled.find(a => a.type === 'SEQUENCE');
98
+ test.ok(seqAttr, 'the sequence value must not be dropped');
99
+ test.equal(seqAttr.value, '1 2 3');
100
+
101
+ // Junk values pass through too, so they fail loudly at the compiler instead of
102
+ // silently widening the search
103
+ let junk = searchCompiler(connection, { seq: {} });
104
+ test.ok(
105
+ junk.find(a => a.type === 'SEQUENCE'),
106
+ 'a junk value must not be dropped'
107
+ );
108
+
109
+ // Zero is not a valid IMAP sequence number but it is a valid filter value,
110
+ // so it must reach the compiler guard rather than be dropped as falsy
111
+ let zero = searchCompiler(connection, { seq: 0 });
112
+ test.equal(zero.find(a => a.type === 'SEQUENCE').value, '0', 'zero must not be dropped');
113
+
114
+ test.done();
115
+ };
116
+
117
+ module.exports['Search Compiler: SEQ array compiles to a single comma-joined set'] = test => {
118
+ let connection = createMockConnection();
119
+ let compiled = searchCompiler(connection, { seq: [1, 3] });
120
+
121
+ let seqAttr = compiled.find(a => a.type === 'SEQUENCE');
122
+ test.ok(seqAttr, 'array must produce a sequence set');
123
+ test.equal(seqAttr.value, '1,3');
124
+ test.done();
125
+ };
96
126
 
127
+ module.exports['Search Compiler: SEQ empty string compiles to nothing'] = test => {
128
+ let connection = createMockConnection();
129
+ let compiled = searchCompiler(connection, { seq: '' });
130
+
131
+ test.ok(!compiled.find(a => a.type === 'SEQUENCE'), 'an empty set adds no SEQUENCE attribute');
132
+ test.equal(compiled.length, 0);
97
133
  test.done();
98
134
  };
99
135
 
@@ -366,6 +402,30 @@ module.exports['Search Compiler: UID with number'] = test => {
366
402
  test.done();
367
403
  };
368
404
 
405
+ module.exports['Search Compiler: UID array compiles to a single comma-joined set'] = test => {
406
+ let connection = createMockConnection();
407
+ let compiled = searchCompiler(connection, { uid: [5, 7, 9] });
408
+
409
+ // Separate tokens ("UID 5 7 9") would be parsed by the server as extra
410
+ // sequence-number search keys ANDed to the query - the array means the set {5,7,9}
411
+ test.ok(hasAttr(compiled, 'UID'));
412
+ let uidValueAttr = compiled.find(a => a.value === '5,7,9');
413
+ test.ok(uidValueAttr, 'array must be joined into one sequence set');
414
+ test.equal(uidValueAttr.type, 'SEQUENCE');
415
+ test.done();
416
+ };
417
+
418
+ module.exports['Search Compiler: UID accepts the SEARCHRES $ marker'] = test => {
419
+ let connection = createMockConnection();
420
+ let compiled = searchCompiler(connection, { uid: '$' });
421
+
422
+ test.ok(hasAttr(compiled, 'UID'));
423
+ let uidValueAttr = compiled.find(a => a.value === '$');
424
+ test.ok(uidValueAttr, 'the saved-result marker must pass through');
425
+ test.equal(uidValueAttr.type, 'SEQUENCE');
426
+ test.done();
427
+ };
428
+
369
429
  // ============================================
370
430
  // EMAILID / THREADID tests
371
431
  // ============================================
@@ -488,6 +548,19 @@ module.exports['Search Compiler: LABELS not compiles to -label: via X-GM-RAW'] =
488
548
  test.done();
489
549
  };
490
550
 
551
+ module.exports['Search Compiler: LABELS with a non-object value is ignored'] = test => {
552
+ let connection = createMockConnection();
553
+
554
+ // Neither a falsy value nor a plain string is a { has, not } filter - both
555
+ // must compile to nothing even without the Gmail extension
556
+ let compiledNull = searchCompiler(connection, { labels: null });
557
+ test.equal(compiledNull.length, 0);
558
+
559
+ let compiledString = searchCompiler(connection, { labels: 'Horizon' });
560
+ test.equal(compiledString.length, 0);
561
+ test.done();
562
+ };
563
+
491
564
  module.exports['Search Compiler: LABELS has and not combined'] = test => {
492
565
  let connection = createMockConnection({
493
566
  capabilities: [['X-GM-EXT-1', true]]
@@ -991,6 +1064,20 @@ module.exports['Search Compiler: non-ASCII GMRAW adds CHARSET UTF-8'] = test =>
991
1064
  test.done();
992
1065
  };
993
1066
 
1067
+ module.exports['Search Compiler: non-ASCII LABELS adds CHARSET UTF-8'] = test => {
1068
+ let connection = createMockConnection({
1069
+ capabilities: [['X-GM-EXT-1', true]],
1070
+ enabled: new Set()
1071
+ });
1072
+ // The label filter compiles into an X-GM-RAW query, and a non-ASCII label name
1073
+ // must mark the compiled query as Unicode the same way a direct gmraw value does
1074
+ let compiled = searchCompiler(connection, { labels: { has: ['Tähtis'] } });
1075
+ test.ok(hasAttr(compiled, 'CHARSET'));
1076
+ test.ok(hasAttr(compiled, 'X-GM-RAW'));
1077
+ test.ok(hasAttr(compiled, 'label:Tähtis'));
1078
+ test.done();
1079
+ };
1080
+
994
1081
  // ============================================
995
1082
  // Complex query tests
996
1083
  // ============================================
@@ -208,7 +208,33 @@ module.exports['Timers: the throttle back-off timer is unrefd and cleared'] = as
208
208
 
209
209
  // history() returns snapshots, so re-read it after close()
210
210
  test.ok(timers.history().find(timer => timer.id === backoff.id).cleared, 'close() clears the back-off timer');
211
- test.equal(client._throttleTimer, null);
211
+ test.equal(client._throttleWaits.size, 0);
212
+ test.done();
213
+ });
214
+ };
215
+
216
+ module.exports['Timers: throttleWait caps a server-suggested back-off'] = async test => {
217
+ // The delay comes straight from a server hint (a Microsoft 365 "Suggested Backoff Time"),
218
+ // which is unbounded. Asserted on the armed timer rather than by waiting it out.
219
+ await withFakeTimers(async timers => {
220
+ let client = makeClient({ port: 1 });
221
+
222
+ let pending = client.throttleWait(7 * 24 * 3600 * 1000); // a week
223
+ let armed = timers.history()[timers.history().length - 1];
224
+ test.equal(armed.delay, 5 * 60 * 1000, 'the back-off is capped at five minutes');
225
+ test.ok(armed.unrefd, 'the back-off timer must not keep the process alive');
226
+
227
+ client.close();
228
+ test.equal(await pending, true, 'close() aborts the wait');
229
+ test.ok(timers.history().find(timer => timer.id === armed.id).cleared, 'close() clears the timer');
230
+
231
+ // a delay under the cap is honored as given, and junk becomes an immediate wait
232
+ let client2 = makeClient({ port: 1 });
233
+ client2.throttleWait(1500);
234
+ test.equal(timers.history()[timers.history().length - 1].delay, 1500);
235
+ client2.throttleWait('nonsense');
236
+ test.equal(timers.history()[timers.history().length - 1].delay, 0);
237
+ client2.close();
212
238
  test.done();
213
239
  });
214
240
  };
@@ -1503,8 +1503,9 @@ module.exports['Tools: formatMessageResponse handles NIL, Buffer and unknown key
1503
1503
  literals: [Buffer.from('999')]
1504
1504
  });
1505
1505
  let r = await tools.formatMessageResponse(untagged, { path: 'INBOX' });
1506
- test.equal(r.uid, 0); // NIL value -> getString returns false -> Number(false) = 0
1507
- test.equal(r.size, 0);
1506
+ test.equal(r.uid, undefined); // a NIL UID is left unset rather than reported as UID 0
1507
+ test.equal(r.size, 0); // size keeps its 0 default
1508
+
1508
1509
  test.equal(r.emailId, '999'); // Buffer literal value -> getString stringifies it
1509
1510
  test.equal(r.source, false); // BODY[] NIL -> getBuffer returns false
1510
1511
  test.done();
@@ -1749,3 +1750,151 @@ module.exports['Tools: packMessageRange dedupes duplicate values'] = test => {
1749
1750
  test.equal(tools.packMessageRange([5, 5, 5]), '5');
1750
1751
  test.done();
1751
1752
  };
1753
+
1754
+ // ============================================
1755
+ // Security regression tests: hostile server input
1756
+ // ============================================
1757
+
1758
+ module.exports['Tools: getStructuredParams drops __proto__ continuation parameters'] = test => {
1759
+ // A parameter named "__proto__*0*" groups under "__proto__": without a guard the
1760
+ // grouping wrote attacker-controlled values onto Object.prototype (and then threw)
1761
+ let result = tools.getStructuredParams([{ value: '__proto__*0*' }, { value: "utf-8''polluted" }]);
1762
+ test.deepEqual(result, {});
1763
+ test.equal({}.charset, undefined, 'Object.prototype must stay clean');
1764
+ test.done();
1765
+ };
1766
+
1767
+ module.exports['Tools: getStructuredParams drops a plain __proto__ parameter'] = test => {
1768
+ // A plain "__proto__" parameter never reaches the output and never touches the prototype.
1769
+ // The string value alone cannot pollute (the __proto__ setter ignores primitives) - the
1770
+ // object-valued continuation form above is the case the live guard covers - so this
1771
+ // pins the output contract: the parameter is dropped, the rest of the list survives.
1772
+ let result = tools.getStructuredParams([{ value: '__proto__' }, { value: 'polluted' }, { value: 'a' }, { value: 'b' }]);
1773
+ test.deepEqual(result, { a: 'b' });
1774
+ test.ok(!Object.prototype.hasOwnProperty.call(result, '__proto__'), 'no own __proto__ property is created');
1775
+ test.equal(Object.getPrototypeOf(result), Object.prototype, 'the prototype must stay untouched');
1776
+ test.done();
1777
+ };
1778
+
1779
+ module.exports['Tools: formatMessageResponse ignores a non-numeric MODSEQ'] = async test => {
1780
+ // BigInt() throws on garbage, and the throw used to drop the whole message
1781
+ // from the result set - a malformed MODSEQ must be skipped instead
1782
+ let untagged = await parser('* 1 FETCH (UID 1 MODSEQ (abc) FLAGS (\\Seen))');
1783
+ let result = await tools.formatMessageResponse(untagged, {});
1784
+ test.equal(result.uid, 1);
1785
+ test.equal(result.modseq, undefined);
1786
+ test.ok(result.flags.has('\\Seen'));
1787
+
1788
+ // an unparenthesized MODSEQ yields no usable value either
1789
+ let untagged2 = await parser('* 1 FETCH (UID 1 MODSEQ 123)');
1790
+ let result2 = await tools.formatMessageResponse(untagged2, {});
1791
+ test.equal(result2.uid, 1);
1792
+ test.equal(result2.modseq, undefined);
1793
+ test.done();
1794
+ };
1795
+
1796
+ module.exports['Tools: parseEnvelope skips NIL address entries'] = test => {
1797
+ // A NIL inside an address list used to throw on the dereference, dropping the message
1798
+ let entry = [
1799
+ null, // date
1800
+ null, // subject
1801
+ [null, [{ value: 'Name' }, null, { value: 'user' }, { value: 'example.com' }]], // from: NIL entry + valid entry
1802
+ [], // sender
1803
+ [], // reply-to
1804
+ [], // to
1805
+ [], // cc
1806
+ [], // bcc
1807
+ null, // in-reply-to
1808
+ null // message-id
1809
+ ];
1810
+
1811
+ let result = tools.parseEnvelope(entry);
1812
+ test.equal(result.from.length, 1);
1813
+ test.equal(result.from[0].address, 'user@example.com');
1814
+ test.done();
1815
+ };
1816
+
1817
+ // ============================================
1818
+ // Bounded parsing of untrusted numeric values
1819
+ // ============================================
1820
+
1821
+ module.exports['Tools: expandRange returns nothing for a non-string range'] = test => {
1822
+ // untaggedVanished() leaves the sequence set as `false` when the response carries only the
1823
+ // (EARLIER) tag, and throwing here would abort the handler for the rest of the response
1824
+ test.deepEqual(tools.expandRange(false), []);
1825
+ test.deepEqual(tools.expandRange(undefined), []);
1826
+ test.deepEqual(tools.expandRange(null), []);
1827
+ test.deepEqual(tools.expandRange(123), []);
1828
+ test.deepEqual(tools.expandRange(['1:3']), []);
1829
+ test.deepEqual(tools.expandRange('1:3'), [1, 2, 3], 'a real range still expands');
1830
+ test.done();
1831
+ };
1832
+
1833
+ module.exports['Tools: isDecimalString accepts only bounded digit runs'] = test => {
1834
+ test.equal(tools.isDecimalString('12', 10), true);
1835
+ test.equal(tools.isDecimalString('1234567890', 10), true);
1836
+ test.equal(tools.isDecimalString('12345678901', 10), false, 'one digit past the bound is rejected');
1837
+ test.equal(tools.isDecimalString('', 10), false);
1838
+ test.equal(tools.isDecimalString('1e5', 10), false);
1839
+ test.equal(tools.isDecimalString(' 12 ', 10), false);
1840
+ test.equal(tools.isDecimalString('0x10', 10), false);
1841
+ test.equal(tools.isDecimalString('-1', 10), false);
1842
+ test.equal(tools.isDecimalString('1.5', 10), false);
1843
+ test.equal(tools.isDecimalString(12, 10), false, 'only strings are accepted');
1844
+ test.equal(tools.isDecimalString(null, 10), false);
1845
+ test.done();
1846
+ };
1847
+
1848
+ module.exports['Tools: parseBigIntValue rejects everything BigInt would throw on'] = test => {
1849
+ // isNaN() passes '1e5', ' 12 ' and 'Infinity'; BigInt() throws on all three, and the throw
1850
+ // propagates out of a response handler that was only trying to read one field
1851
+ test.equal(tools.parseBigIntValue('9122'), 9122n);
1852
+ for (let bad of ['1e5', ' 12 ', 'Infinity', 'none', '', '-1', '1.5', null, undefined, 12, ['1']]) {
1853
+ test.equal(tools.parseBigIntValue(bad), false, `${JSON.stringify(bad)} must be rejected`);
1854
+ }
1855
+ test.done();
1856
+ };
1857
+
1858
+ module.exports['Tools: parseBigIntValue bounds the digit count before converting'] = test => {
1859
+ // BigInt() on a multi-megabyte digit run costs hundreds of milliseconds of non-yielding
1860
+ // CPU, and a response line may carry up to maxLineLength digits
1861
+ test.equal(tools.parseBigIntValue('9'.repeat(19)), BigInt('9'.repeat(19)), '63-bit values still fit the default bound');
1862
+ test.equal(tools.parseBigIntValue('9'.repeat(20)), false);
1863
+ test.equal(tools.parseBigIntValue('9'.repeat(400000)), false);
1864
+ test.equal(tools.parseBigIntValue('12345678901', tools.MAX_UINT32_DIGITS), false, 'a tighter bound can be requested');
1865
+ test.done();
1866
+ };
1867
+
1868
+ module.exports['Tools: parseUintValue rejects values outside the safe integer range'] = test => {
1869
+ test.equal(tools.parseUintValue('1000'), 1000);
1870
+ test.equal(tools.parseUintValue('0'), 0);
1871
+ test.equal(tools.parseUintValue('9'.repeat(19)), false, 'a value past 2^53-1 is refused, not rounded');
1872
+ test.equal(tools.parseUintValue('9'.repeat(400)), false, 'a digit run that would coerce to Infinity is refused');
1873
+ for (let bad of ['1e3', '0x10', ' 12 ', '-5', '1.5', '']) {
1874
+ test.equal(tools.parseUintValue(bad), false, `${JSON.stringify(bad)} must be rejected`);
1875
+ }
1876
+ test.done();
1877
+ };
1878
+
1879
+ module.exports['Tools: expandRange caps the total expansion, not just each range'] = test => {
1880
+ // A per-range bound alone is multiplied by an unbounded number of ranges. With the budget
1881
+ // already spent inside the second range, the two after it must be abandoned rather than
1882
+ // expanded on top of it.
1883
+ let result = tools.expandRange('1:16777215,16777215:16777220,1:5,7');
1884
+ test.equal(result.length, tools.EXPANDED_RANGE_LIMIT);
1885
+ test.equal(result[0], 1);
1886
+ // the second range gets the single remaining entry, and the two after it never start
1887
+ test.equal(result[result.length - 1], 16777215);
1888
+ test.done();
1889
+ };
1890
+
1891
+ module.exports['Tools: formatMessageResponse drops an unusable sequence number'] = async test => {
1892
+ // Number('9'.repeat(400)) is Infinity, and a seq of Infinity indexes into nothing
1893
+ let untagged = await parser('* 1 FETCH (UID 5)');
1894
+ let ok = await tools.formatMessageResponse(untagged, { path: 'INBOX' });
1895
+ test.equal(ok.seq, 1);
1896
+
1897
+ let overflowing = await tools.formatMessageResponse({ command: '9'.repeat(400), attributes: [] }, { path: 'INBOX' });
1898
+ test.equal(overflowing.seq, undefined);
1899
+ test.done();
1900
+ };