imapflow 1.6.5 → 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 +7 -0
- package/lib/commands/append.js +26 -4
- 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-stream.js +56 -2
- package/lib/handler/limits.js +16 -4
- package/lib/imap-flow.d.ts +22 -2
- package/lib/imap-flow.js +131 -61
- package/lib/jp-decoder.js +30 -5
- package/lib/limited-passthrough.js +19 -1
- 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 +683 -0
- package/test/copyuid-parser-test.js +20 -0
- package/test/imap-flow-coverage-test.js +4 -2
- package/test/imap-flow-fetch-download-test.js +26 -0
- package/test/imap-flow-methods-test.js +92 -0
- package/test/imap-stream-edge-cases-test.js +136 -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/timer-policy-test.js +27 -1
- package/test/tools-test.js +151 -2
|
@@ -151,3 +151,23 @@ module.exports['CopyUID Parser: uidValidity zero is stored as BigInt(0)'] = test
|
|
|
151
151
|
test.equal(map.uidMap.get(1), 100);
|
|
152
152
|
test.done();
|
|
153
153
|
};
|
|
154
|
+
|
|
155
|
+
// ============================================
|
|
156
|
+
// Hostile server input
|
|
157
|
+
// ============================================
|
|
158
|
+
|
|
159
|
+
module.exports['CopyUID Parser: non-decimal uidValidity is ignored, uidMap still parsed'] = test => {
|
|
160
|
+
// isNaN() passed values like "1e5" and "Infinity" through, and BigInt() then threw,
|
|
161
|
+
// losing the whole COPYUID result
|
|
162
|
+
let map = {};
|
|
163
|
+
parseCopyUid(makeResponse([{ value: 'COPYUID' }, { value: '1e5' }, { value: '1' }, { value: '100' }]), map);
|
|
164
|
+
test.equal(map.uidValidity, undefined);
|
|
165
|
+
test.ok(map.uidMap instanceof Map);
|
|
166
|
+
test.equal(map.uidMap.get(1), 100);
|
|
167
|
+
|
|
168
|
+
let map2 = {};
|
|
169
|
+
parseCopyUid(makeResponse([{ value: 'COPYUID' }, { value: 'Infinity' }, { value: '1' }, { value: '100' }]), map2);
|
|
170
|
+
test.equal(map2.uidValidity, undefined);
|
|
171
|
+
test.equal(map2.uidMap.get(1), 100);
|
|
172
|
+
test.done();
|
|
173
|
+
};
|
|
@@ -591,9 +591,11 @@ module.exports['Coverage: reader caps very large throttle backoff'] = async test
|
|
|
591
591
|
let readerPromise = runReaderWith(client, ['A1 BAD Request is throttled. Suggested Backoff Time: 999999999 milliseconds']);
|
|
592
592
|
// give the reader a tick to register the throttle timer, then abort it
|
|
593
593
|
await drain();
|
|
594
|
-
|
|
595
|
-
|
|
594
|
+
for (let entry of client._throttleWaits) {
|
|
595
|
+
clearTimeout(entry.timer);
|
|
596
|
+
entry.resolve(true);
|
|
596
597
|
}
|
|
598
|
+
client._throttleWaits.clear();
|
|
597
599
|
await readerPromise;
|
|
598
600
|
test.ok(rejected);
|
|
599
601
|
test.done();
|
|
@@ -852,3 +852,29 @@ module.exports['DownloadMany: decodes base64 and quoted-printable parts'] = asyn
|
|
|
852
852
|
test.equal(res['3'].meta.disposition, 'attachment');
|
|
853
853
|
test.done();
|
|
854
854
|
};
|
|
855
|
+
|
|
856
|
+
module.exports['Download: a buffering transform cannot defeat maxBytes'] = async test => {
|
|
857
|
+
// format=flowed unwrapping and the Japanese charset decoder both hold their whole input
|
|
858
|
+
// before emitting anything, so the limiter at the tail of the pipeline reports
|
|
859
|
+
// `limited === false` however much the server sends. Without a bound of their own the fetch
|
|
860
|
+
// loop would keep pulling the entire part.
|
|
861
|
+
for (let contentType of ['text/plain; format=flowed', 'text/plain; charset=iso-2022-jp']) {
|
|
862
|
+
let client = makeClient();
|
|
863
|
+
let mime = Buffer.from(`Content-Type: ${contentType}\r\nContent-Disposition: inline\r\n\r\n`);
|
|
864
|
+
let calls = 0;
|
|
865
|
+
client.fetchOne = async () => {
|
|
866
|
+
calls++;
|
|
867
|
+
let bodyParts = new Map();
|
|
868
|
+
bodyParts.set('2.mime', mime);
|
|
869
|
+
// full-size chunks keep hasMore true; the loop must stop on its own, not run dry
|
|
870
|
+
bodyParts.set('2', calls <= 40 ? Buffer.alloc(1024, 0x61) : Buffer.alloc(1));
|
|
871
|
+
return { uid: 1, size: 1024 * 1024, bodyParts };
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
let { content } = await client.download('1', '2', { chunkSize: 1024, maxBytes: 512 });
|
|
875
|
+
let data = await collect(content);
|
|
876
|
+
test.ok(data.length <= 512, `${contentType}: delivered ${data.length} bytes, expected no more than 512`);
|
|
877
|
+
test.ok(calls <= 3, `${contentType}: the fetch loop must stop at the bound, made ${calls} calls`);
|
|
878
|
+
}
|
|
879
|
+
test.done();
|
|
880
|
+
};
|
|
@@ -795,3 +795,95 @@ module.exports['Methods: autoEnable does not retry when disableIMAP4rev2 already
|
|
|
795
795
|
test.equal(calls.length, 1);
|
|
796
796
|
test.done();
|
|
797
797
|
};
|
|
798
|
+
|
|
799
|
+
module.exports['Methods: untaggedExpunge refuses an unusable sequence number'] = async test => {
|
|
800
|
+
// Same bound untaggedExists() applies: an overflowing or non-decimal value must not
|
|
801
|
+
// decrement the live message count
|
|
802
|
+
let client = makeClient();
|
|
803
|
+
client.mailbox.exists = 10;
|
|
804
|
+
let events = [];
|
|
805
|
+
client.on('expunge', e => events.push(e));
|
|
806
|
+
|
|
807
|
+
await client.untaggedExpunge(false);
|
|
808
|
+
for (let bad of ['9'.repeat(400), '1e5', '0', 'abc', '']) {
|
|
809
|
+
await client.untaggedExpunge({ command: bad });
|
|
810
|
+
}
|
|
811
|
+
test.equal(client.mailbox.exists, 10, 'the live message count must be left alone');
|
|
812
|
+
test.equal(events.length, 0);
|
|
813
|
+
|
|
814
|
+
await client.untaggedExpunge({ command: '3' });
|
|
815
|
+
test.equal(client.mailbox.exists, 9, 'a usable sequence number is still applied');
|
|
816
|
+
test.equal(events.length, 1);
|
|
817
|
+
test.equal(events[0].seq, 3);
|
|
818
|
+
test.done();
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
module.exports['Methods: untaggedVanished survives a malformed response'] = async test => {
|
|
822
|
+
// "* VANISHED (EARLIER)" leaves no sequence set at all. Throwing here would abort the
|
|
823
|
+
// handler for the whole response, and handleResponse only warns - so every expunge the
|
|
824
|
+
// response did carry would be dropped and the client's mailbox view would go stale.
|
|
825
|
+
let client = makeClient();
|
|
826
|
+
let events = [];
|
|
827
|
+
client.on('expunge', e => events.push(e));
|
|
828
|
+
|
|
829
|
+
await client.untaggedVanished({ attributes: [[{ value: 'EARLIER' }]] });
|
|
830
|
+
await client.untaggedVanished({ attributes: [] });
|
|
831
|
+
await client.untaggedVanished({});
|
|
832
|
+
await client.untaggedVanished({ attributes: [[{ value: 'EARLIER' }], null] });
|
|
833
|
+
|
|
834
|
+
test.equal(events.length, 0, 'nothing to report, and nothing thrown');
|
|
835
|
+
|
|
836
|
+
// a well-formed response still works after the malformed ones
|
|
837
|
+
await client.untaggedVanished({ attributes: [[{ value: 'EARLIER' }], { value: '5:6' }] });
|
|
838
|
+
test.equal(events.length, 2);
|
|
839
|
+
test.equal(events[0].uid, 5);
|
|
840
|
+
test.equal(events[0].earlier, true);
|
|
841
|
+
test.done();
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
module.exports['Methods: untaggedExists refuses an overflowing count'] = async test => {
|
|
845
|
+
// Number('9'.repeat(400)) is Infinity: resolveRange('*') would then compile the literal
|
|
846
|
+
// string "Infinity" and every range-based command would fail until the next SELECT
|
|
847
|
+
let client = makeClient();
|
|
848
|
+
client.mailbox = { path: 'INBOX', exists: 5 };
|
|
849
|
+
let events = [];
|
|
850
|
+
client.on('exists', e => events.push(e));
|
|
851
|
+
|
|
852
|
+
await client.untaggedExists(false);
|
|
853
|
+
await client.untaggedExists({});
|
|
854
|
+
for (let bad of ['9'.repeat(400), '1e5', '-1', 'abc', '1.5', '']) {
|
|
855
|
+
await client.untaggedExists({ command: bad });
|
|
856
|
+
}
|
|
857
|
+
test.equal(client.mailbox.exists, 5, 'the live message count must be left alone');
|
|
858
|
+
test.equal(events.length, 0);
|
|
859
|
+
|
|
860
|
+
await client.untaggedExists({ command: '7' });
|
|
861
|
+
test.equal(client.mailbox.exists, 7, 'a usable count is still applied');
|
|
862
|
+
test.equal(events.length, 1);
|
|
863
|
+
test.done();
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
module.exports['Methods: throttleWait caps the delay and close() aborts it'] = async test => {
|
|
867
|
+
let client = makeClient();
|
|
868
|
+
|
|
869
|
+
// A server hint is unbounded; the wait may never outlive the client either
|
|
870
|
+
let started = Date.now();
|
|
871
|
+
let pending = client.throttleWait(7 * 24 * 3600 * 1000);
|
|
872
|
+
test.equal(client._throttleWaits.size, 1, 'the wait is tracked so close() can reach it');
|
|
873
|
+
|
|
874
|
+
client.close();
|
|
875
|
+
test.equal(await pending, true, 'an aborted wait reports true');
|
|
876
|
+
test.equal(client._throttleWaits.size, 0, 'the tracked wait is released');
|
|
877
|
+
test.ok(Date.now() - started < 5000, 'close() must not wait out the delay');
|
|
878
|
+
|
|
879
|
+
// normal expiry reports false and releases the entry. The back-off timer is deliberately
|
|
880
|
+
// unref'd, so the test has to hold the event loop open itself - in a real session the
|
|
881
|
+
// socket does that.
|
|
882
|
+
let client2 = makeClient();
|
|
883
|
+
let keepAlive = setTimeout(() => {}, 1000);
|
|
884
|
+
test.equal(await client2.throttleWait(1), false);
|
|
885
|
+
clearTimeout(keepAlive);
|
|
886
|
+
test.equal(client2._throttleWaits.size, 0);
|
|
887
|
+
client2.close();
|
|
888
|
+
test.done();
|
|
889
|
+
};
|
|
@@ -528,3 +528,139 @@ module.exports['Literal marker still accepts sizes at the digit-length bound'] =
|
|
|
528
528
|
test.equal(ok.literalWaiting, 1024);
|
|
529
529
|
test.done();
|
|
530
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
|
+
};
|
package/test/jp-decoder-test.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
460
|
+
test.equal(client._throttleWaits.size, 0, 'throttle wait cleared after normal expiry');
|
|
461
461
|
|
|
462
462
|
await readerDone;
|
|
463
463
|
client.close();
|
|
@@ -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.
|
|
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
|
};
|
package/test/tools-test.js
CHANGED
|
@@ -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,
|
|
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
|
+
};
|