imapflow 1.6.3 → 1.6.5
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/compress.js +29 -2
- package/lib/commands/search.js +10 -0
- package/lib/handler/imap-compiler.js +146 -46
- package/lib/handler/imap-parser.js +7 -0
- package/lib/handler/imap-stream.js +25 -5
- package/lib/handler/parser-instance.js +22 -3
- package/lib/imap-flow.d.ts +2 -0
- package/lib/imap-flow.js +119 -36
- package/lib/search-compiler.js +28 -17
- package/lib/tools.js +18 -4
- package/package.json +1 -1
- package/test/commands-integration-test.js +188 -5
- package/test/idle-polling-test.js +81 -0
- package/test/imap-compiler-test.js +168 -0
- package/test/imap-flow-internals-test.js +134 -0
- package/test/imap-flow-secure-test.js +154 -81
- package/test/imap-flow-server-test.js +162 -0
- package/test/imap-parser-test.js +51 -0
- package/test/imap-stream-edge-cases-test.js +72 -0
- package/test/integration/rev2-live-test.js +67 -3
- package/test/search-compiler-test.js +90 -3
- package/test/search-test.js +32 -3
- package/test/tools-test.js +28 -2
|
@@ -639,3 +639,171 @@ module.exports['IMAP Compiler: partial range in SECTION'] = test =>
|
|
|
639
639
|
).toString();
|
|
640
640
|
test.ok(compiled.includes('<0.50>'), 'should contain partial range <0.50>');
|
|
641
641
|
});
|
|
642
|
+
|
|
643
|
+
// Returns the error a compile attempt raised, or undefined when it succeeded.
|
|
644
|
+
// Several cases below assert that a token can never reach the wire, and the
|
|
645
|
+
// try/catch is the only interesting part of each. Takes either an attributes
|
|
646
|
+
// array (compiled under a default tag/command) or a full response object.
|
|
647
|
+
const compileError = async input => {
|
|
648
|
+
try {
|
|
649
|
+
await compiler(Array.isArray(input) ? { tag: 'A', command: 'CMD', attributes: input } : input);
|
|
650
|
+
} catch (err) {
|
|
651
|
+
return err;
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
module.exports['IMAP Compiler: SEQUENCE rejects values that are not sequence sets'] = test =>
|
|
656
|
+
asyncWrapper(test, async test => {
|
|
657
|
+
// Sequence sets are written verbatim, so a range string that reached the
|
|
658
|
+
// compiler unvalidated would put a second command on the wire
|
|
659
|
+
for (let value of ['1\r\nZZ1 LOGOUT', '1 2', '1;2', 'ALL', '1:2)', '1\t2', Buffer.from('1\r\nZZ NOOP')]) {
|
|
660
|
+
let err = await compileError([{ type: 'SEQUENCE', value }]);
|
|
661
|
+
test.equal(err && err.code, 'InvalidSequenceSet', `${JSON.stringify(value.toString())} must be rejected`);
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
module.exports['IMAP Compiler: SEQUENCE accepts every valid sequence-set form'] = test =>
|
|
666
|
+
asyncWrapper(test, async test => {
|
|
667
|
+
// '$' is the RFC 5182 SEARCHRES saved-result marker, valid as the entire set
|
|
668
|
+
for (let value of ['1', '*', '1:*', '*:1', '1,3,5', '1:3,7,9:*', '4294967295', '$']) {
|
|
669
|
+
const compiled = (await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value }] })).toString();
|
|
670
|
+
test.equal(compiled, `A FETCH ${value}`);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// '$' only stands for the whole set, never an element of one
|
|
674
|
+
for (let value of ['$,1', '1,$', '$:2']) {
|
|
675
|
+
let err = await compileError([{ type: 'SEQUENCE', value }]);
|
|
676
|
+
test.equal(err && err.code, 'InvalidSequenceSet', `${JSON.stringify(value)} must be rejected`);
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
module.exports['IMAP Compiler: SEQUENCE validation stays linear on huge valid sets'] = test =>
|
|
681
|
+
asyncWrapper(test, async test => {
|
|
682
|
+
// The regex this replaced overflowed the engine's backtrack stack with an
|
|
683
|
+
// uncoded RangeError at roughly a million comma-separated elements - a set
|
|
684
|
+
// a fetch over a large mailbox can legitimately produce
|
|
685
|
+
const huge = Array.from({ length: 1500000 }, (_, i) => i + 1).join(',');
|
|
686
|
+
const compiled = await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value: huge }] });
|
|
687
|
+
test.ok(compiled.toString().endsWith(huge), 'the huge but valid set must compile');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
module.exports['IMAP Compiler: SEQUENCE validation is skipped when logging'] = test =>
|
|
691
|
+
asyncWrapper(test, async test => {
|
|
692
|
+
// The incoming token parser accepts sequence-shaped tokens the strict grammar
|
|
693
|
+
// rejects (an ESEARCH set like "1:2:3", a folder name like "12:30:00"). Every
|
|
694
|
+
// parsed server response is re-compiled for the log, so a throw here would let
|
|
695
|
+
// one quirky server line kill the whole connection.
|
|
696
|
+
for (let value of ['1:2:3', '12:30:00', '1,2:3:4']) {
|
|
697
|
+
const compiled = (await compiler({ tag: '*', command: 'OK', attributes: [{ type: 'SEQUENCE', value }] }, { isLogging: true })).toString();
|
|
698
|
+
test.equal(compiled, `* OK ${value}`, 'logging re-compile must pass the token through');
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
module.exports['IMAP Compiler: tag and command cannot carry a line terminator'] = test =>
|
|
703
|
+
asyncWrapper(test, async test => {
|
|
704
|
+
// The tag/command preamble is written verbatim ahead of any token, so it goes
|
|
705
|
+
// through the same choke point as everything else
|
|
706
|
+
let err = await compileError({ tag: 'A\r\nZZ LOGOUT', command: 'NOOP' });
|
|
707
|
+
test.equal(err && err.code, 'InvalidTokenValue', 'CRLF in the tag must be rejected');
|
|
708
|
+
|
|
709
|
+
err = await compileError({ tag: 'A', command: 'NOOP\r\nZZ LOGOUT' });
|
|
710
|
+
test.equal(err && err.code, 'InvalidTokenValue', 'CRLF in the command must be rejected');
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
module.exports['IMAP Compiler: quoted strings use IMAP escaping, not JSON escaping'] = test =>
|
|
714
|
+
asyncWrapper(test, async test => {
|
|
715
|
+
// Only DQUOTE and backslash have escapes in the IMAP grammar - a tab must
|
|
716
|
+
// survive as a raw byte rather than becoming a literal backslash-t
|
|
717
|
+
const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'STRING', value: 'a\tb"c\\d' }] })).toString();
|
|
718
|
+
test.equal(compiled, 'A CMD "a\tb\\"c\\\\d"');
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
module.exports['IMAP Compiler: quoted strings reject CR, LF and NUL'] = test =>
|
|
722
|
+
asyncWrapper(test, async test => {
|
|
723
|
+
for (let value of ['a\rb', 'a\nb', 'a\0b']) {
|
|
724
|
+
let err = await compileError([{ type: 'STRING', value }]);
|
|
725
|
+
test.equal(err && err.code, 'InvalidStringValue', `${JSON.stringify(value)} cannot be sent as a quoted string`);
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
module.exports['IMAP Compiler: a mailbox name with CRLF cannot reach the wire'] = test =>
|
|
730
|
+
asyncWrapper(test, async test => {
|
|
731
|
+
// Paths travel as ATOM tokens and get quoted when they fall outside ATOM-CHAR
|
|
732
|
+
let err = await compileError([{ type: 'ATOM', value: 'INBOX\r\nZZ LOGOUT' }]);
|
|
733
|
+
test.equal(err && err.code, 'InvalidStringValue');
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
module.exports['IMAP Compiler: response text cannot carry a line terminator'] = test =>
|
|
737
|
+
asyncWrapper(test, async test => {
|
|
738
|
+
// TEXT is written verbatim as well, so it gets the same guarantee even though
|
|
739
|
+
// only the parser produces it today
|
|
740
|
+
let err = await compileError([{ type: 'TEXT', value: 'oops\r\nZZ NOOP' }]);
|
|
741
|
+
test.equal(err && err.code, 'InvalidTextValue');
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
module.exports['IMAP Compiler: NUMBER coerces its value instead of writing it through'] = test =>
|
|
745
|
+
asyncWrapper(test, async test => {
|
|
746
|
+
const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value: '1\r\nZZ NOOP' }] })).toString();
|
|
747
|
+
test.equal(compiled, 'A CMD 0', 'a non-numeric value must not reach the wire verbatim');
|
|
748
|
+
test.equal((await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value: '42' }] })).toString(), 'A CMD 42');
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
module.exports['IMAP Compiler: numeric tokens clamp to bounded non-negative integers'] = test =>
|
|
752
|
+
asyncWrapper(test, async test => {
|
|
753
|
+
// Infinity, negatives and unsafe magnitudes would otherwise emit bytes outside
|
|
754
|
+
// the number alphabet ("Infinity", "-5", "1e+300") and get a server BAD blamed
|
|
755
|
+
// on the server instead of the caller
|
|
756
|
+
for (let [value, expected] of [
|
|
757
|
+
[Infinity, '0'],
|
|
758
|
+
['Infinity', '0'],
|
|
759
|
+
[-5, '0'],
|
|
760
|
+
['2e21', '0'],
|
|
761
|
+
[4.6, '5']
|
|
762
|
+
]) {
|
|
763
|
+
const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value }] })).toString();
|
|
764
|
+
test.equal(compiled, `A CMD ${expected}`, `${JSON.stringify(value)} must clamp to ${expected}`);
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
module.exports['IMAP Compiler: partial range coerces its elements'] = test =>
|
|
769
|
+
asyncWrapper(test, async test => {
|
|
770
|
+
// The partial range is the last token component written verbatim, so it is
|
|
771
|
+
// coerced rather than joined as-is
|
|
772
|
+
const attributes = [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: ['0>\r\nZZ NOOP'] }];
|
|
773
|
+
const compiled = (await compiler({ tag: 'A', command: 'FETCH', attributes })).toString();
|
|
774
|
+
test.equal(compiled, 'A FETCH BODY.PEEK[]<0>');
|
|
775
|
+
|
|
776
|
+
const normal = (
|
|
777
|
+
await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 1024] }] })
|
|
778
|
+
).toString();
|
|
779
|
+
test.equal(normal, 'A FETCH BODY.PEEK[]<0.1024>');
|
|
780
|
+
|
|
781
|
+
// Floats and unbounded numerics clamp the same way NUMBER tokens do, so no
|
|
782
|
+
// non-digit byte ("512.5", "Infinity") can appear inside the partial range
|
|
783
|
+
const float = (
|
|
784
|
+
await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 512.5] }] })
|
|
785
|
+
).toString();
|
|
786
|
+
test.equal(float, 'A FETCH BODY.PEEK[]<0.513>');
|
|
787
|
+
|
|
788
|
+
const unbounded = (
|
|
789
|
+
await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: ['Infinity', '2e21'] }] })
|
|
790
|
+
).toString();
|
|
791
|
+
test.equal(unbounded, 'A FETCH BODY.PEEK[]<0.0>');
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
module.exports['IMAP Compiler: logging output never throws on unsendable values'] = test =>
|
|
795
|
+
asyncWrapper(test, async test => {
|
|
796
|
+
// The logging pass must survive whatever the wire pass refuses, so a rejected
|
|
797
|
+
// command can still be logged
|
|
798
|
+
const compiled = (
|
|
799
|
+
await compiler(
|
|
800
|
+
{
|
|
801
|
+
tag: 'A',
|
|
802
|
+
command: 'LOGIN',
|
|
803
|
+
attributes: [{ type: 'STRING', value: 'a\r\nb' }]
|
|
804
|
+
},
|
|
805
|
+
{ isLogging: true }
|
|
806
|
+
)
|
|
807
|
+
).toString();
|
|
808
|
+
test.ok(compiled.includes('\\r\\n'), 'control characters stay escaped for the log');
|
|
809
|
+
});
|
|
@@ -471,3 +471,137 @@ module.exports['Internals: untaggedFetch includes modseq and flagColor'] = async
|
|
|
471
471
|
test.equal(evt.flagColor, 'red');
|
|
472
472
|
test.done();
|
|
473
473
|
};
|
|
474
|
+
|
|
475
|
+
// ============================================================================
|
|
476
|
+
// runInternal dispatch guards
|
|
477
|
+
// ============================================================================
|
|
478
|
+
|
|
479
|
+
module.exports['Internals: runInternal returns false for unknown command'] = async test => {
|
|
480
|
+
let client = makeClient();
|
|
481
|
+
client.socket = { destroyed: false };
|
|
482
|
+
let res = await client.runInternal('NOT_A_COMMAND');
|
|
483
|
+
test.equal(res, false);
|
|
484
|
+
test.done();
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
module.exports['Internals: runInternal throws NoConnection when the socket is destroyed'] = async test => {
|
|
488
|
+
let client = makeClient();
|
|
489
|
+
client.socket = { destroyed: true };
|
|
490
|
+
let err = null;
|
|
491
|
+
try {
|
|
492
|
+
await client.runInternal('NOOP');
|
|
493
|
+
} catch (e) {
|
|
494
|
+
err = e;
|
|
495
|
+
}
|
|
496
|
+
test.ok(err);
|
|
497
|
+
test.equal(err.code, 'NoConnection');
|
|
498
|
+
test.done();
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// ============================================================================
|
|
502
|
+
// send() onSend error containment
|
|
503
|
+
// ============================================================================
|
|
504
|
+
|
|
505
|
+
module.exports['Internals: send contains a throwing onSend callback'] = async test => {
|
|
506
|
+
let client = makeClient();
|
|
507
|
+
let written = [];
|
|
508
|
+
let warnings = [];
|
|
509
|
+
client.socket = { destroyed: false };
|
|
510
|
+
client.writeSocket = { destroyed: false, write: chunk => written.push(chunk) };
|
|
511
|
+
client.state = client.states.AUTHENTICATED;
|
|
512
|
+
client.log.warn = entry => warnings.push(entry);
|
|
513
|
+
client.currentRequest = { tag: 'A1', sent: false };
|
|
514
|
+
|
|
515
|
+
// The command is already on the wire when onSend runs, so a throwing callback
|
|
516
|
+
// must be logged and swallowed instead of rejecting the request
|
|
517
|
+
await client.send({
|
|
518
|
+
tag: 'A1',
|
|
519
|
+
command: 'NOOP',
|
|
520
|
+
attributes: [],
|
|
521
|
+
options: {
|
|
522
|
+
onSend: () => {
|
|
523
|
+
throw new Error('onSend boom');
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test.equal(written.length, 1, 'the command was written to the socket');
|
|
529
|
+
test.equal(written[0].toString(), 'A1 NOOP\r\n', 'the actual wire bytes went out before onSend ran');
|
|
530
|
+
test.equal(client.currentRequest.sent, true, 'the request is marked as sent');
|
|
531
|
+
test.ok(
|
|
532
|
+
warnings.some(entry => entry && entry.err && entry.err.message === 'onSend boom'),
|
|
533
|
+
'the callback error was logged'
|
|
534
|
+
);
|
|
535
|
+
test.done();
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// ============================================================================
|
|
539
|
+
// countUnknownTag teardown guard
|
|
540
|
+
// ============================================================================
|
|
541
|
+
|
|
542
|
+
module.exports['Internals: countUnknownTag ignores tags on a closed connection'] = test => {
|
|
543
|
+
let client = makeClient();
|
|
544
|
+
client.isClosed = true;
|
|
545
|
+
client.countUnknownTag('A1');
|
|
546
|
+
test.equal(client._unknownTagCount, 0, 'teardown crossover is not counted');
|
|
547
|
+
test.done();
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// ============================================================================
|
|
551
|
+
// rejectUnparsedCompletion
|
|
552
|
+
// ============================================================================
|
|
553
|
+
|
|
554
|
+
// Installs a command that is current and on the wire, returning a holder for
|
|
555
|
+
// the rejection the reader loop would deliver to the caller
|
|
556
|
+
const armInFlight = (client, tag) => {
|
|
557
|
+
let captured = { err: null };
|
|
558
|
+
client.currentRequest = { tag, sent: true };
|
|
559
|
+
client.requestTagMap.set(tag, {
|
|
560
|
+
tag,
|
|
561
|
+
reject: err => {
|
|
562
|
+
captured.err = err;
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
return captured;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
module.exports['Internals: rejectUnparsedCompletion ignores lines without an in-flight command'] = test => {
|
|
569
|
+
let client = makeClient();
|
|
570
|
+
|
|
571
|
+
client.currentRequest = false;
|
|
572
|
+
client.rejectUnparsedCompletion(Buffer.from('A1 OK done'), new Error('parse fail'));
|
|
573
|
+
test.equal(client.currentRequest, false, 'no request to settle');
|
|
574
|
+
|
|
575
|
+
// A command that is current but not yet on the wire must not be settled either
|
|
576
|
+
client.currentRequest = { tag: 'A1', sent: false };
|
|
577
|
+
client.rejectUnparsedCompletion(Buffer.from('A1 OK done'), new Error('parse fail'));
|
|
578
|
+
test.ok(client.currentRequest, 'the unsent request is untouched');
|
|
579
|
+
test.done();
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
module.exports['Internals: rejectUnparsedCompletion recovers the tag from NUL-padded raw bytes'] = async test => {
|
|
583
|
+
let client = makeClient();
|
|
584
|
+
let captured = armInFlight(client, 'A1');
|
|
585
|
+
|
|
586
|
+
// The parser died before extracting a tag - the raw line carries the buggy-server
|
|
587
|
+
// NUL padding, so the fallback must skip it and stop at the first non-tag byte
|
|
588
|
+
let parserError = new Error('parse fail');
|
|
589
|
+
client.rejectUnparsedCompletion(Buffer.from('\x00\x00A1 \x07garbage'), parserError);
|
|
590
|
+
|
|
591
|
+
test.ok(captured.err, 'the in-flight command was failed');
|
|
592
|
+
test.equal(captured.err.code, 'ParserError');
|
|
593
|
+
test.equal(captured.err.parserError, parserError);
|
|
594
|
+
test.equal(client.currentRequest, false, 'the request slot was cleared');
|
|
595
|
+
test.done();
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
module.exports['Internals: rejectUnparsedCompletion ignores a mismatched raw tag'] = test => {
|
|
599
|
+
let client = makeClient();
|
|
600
|
+
let captured = armInFlight(client, 'A1');
|
|
601
|
+
|
|
602
|
+
client.rejectUnparsedCompletion(Buffer.from('A2 NO other'), new Error('parse fail'));
|
|
603
|
+
|
|
604
|
+
test.equal(captured.err, null, 'a line for another tag settles nothing');
|
|
605
|
+
test.ok(client.currentRequest, 'the in-flight request stays current');
|
|
606
|
+
test.done();
|
|
607
|
+
};
|
|
@@ -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
|
|
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`
|
|
119
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();
|
|
@@ -135,24 +168,69 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
|
|
|
135
168
|
test.done();
|
|
136
169
|
};
|
|
137
170
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
171
|
+
module.exports['Secure: STARTTLS discards capabilities even when the OK carries a CAPABILITY code'] = async test => {
|
|
172
|
+
// A server (or a MITM rewriting the plaintext stream) may stamp [CAPABILITY ...]
|
|
173
|
+
// on the STARTTLS OK itself. That marks the capability set as freshly updated, so
|
|
174
|
+
// a discard conditioned on "an update is still pending" would keep exactly the
|
|
175
|
+
// pre-TLS list an attacker controls - the list that then chooses the AUTH
|
|
176
|
+
// mechanism and answers LOGINDISABLED. RFC 9051 6.2.1 makes the discard mandatory.
|
|
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`
|
|
182
|
+
});
|
|
142
183
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
detachPlain();
|
|
147
|
-
let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
|
|
148
|
-
tlsSocket.on('error', () => {});
|
|
149
|
-
lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, CAPS));
|
|
150
|
-
});
|
|
151
|
-
});
|
|
184
|
+
let port = await listen(server);
|
|
185
|
+
let client = makeStartTlsClient(port);
|
|
186
|
+
client.on('error', () => {});
|
|
152
187
|
|
|
153
|
-
|
|
188
|
+
await client.connect();
|
|
189
|
+
test.ok(client.secureConnection, 'connection upgraded to TLS');
|
|
190
|
+
test.ok(client.capabilities.has('POSTTLS-ONLY'), 'post-TLS capabilities were re-fetched');
|
|
191
|
+
test.ok(!client.capabilities.has('PRETLS-ONLY'), 'pre-TLS capabilities were discarded');
|
|
192
|
+
|
|
193
|
+
await client.logout();
|
|
194
|
+
client.close();
|
|
195
|
+
server.close();
|
|
196
|
+
test.done();
|
|
197
|
+
};
|
|
198
|
+
|
|
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
|
+
}
|
|
154
216
|
});
|
|
155
217
|
|
|
218
|
+
let port = await listen(server);
|
|
219
|
+
let client = makeStartTlsClient(port);
|
|
220
|
+
client.on('error', () => {});
|
|
221
|
+
|
|
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
|
+
};
|
|
233
|
+
|
|
156
234
|
// Records every socket that went through configureSocket(), which is the single place the
|
|
157
235
|
// transport options (keepalive + inactivity watchdog) are applied.
|
|
158
236
|
const trackConfiguredSockets = client => {
|
|
@@ -171,19 +249,7 @@ module.exports['Secure: STARTTLS session keeps the inactivity watchdog'] = async
|
|
|
171
249
|
// and a dead connection was never noticed.
|
|
172
250
|
let server = createStartTlsServer();
|
|
173
251
|
let port = await listen(server);
|
|
174
|
-
let client =
|
|
175
|
-
host: '127.0.0.1',
|
|
176
|
-
port,
|
|
177
|
-
secure: false,
|
|
178
|
-
doSTARTTLS: true,
|
|
179
|
-
servername: 'localhost',
|
|
180
|
-
tls: { rejectUnauthorized: false },
|
|
181
|
-
socketTimeout: 200,
|
|
182
|
-
disableAutoIdle: true,
|
|
183
|
-
disableCompression: true,
|
|
184
|
-
logger: false,
|
|
185
|
-
auth: { user: 'test', pass: 'secret' }
|
|
186
|
-
});
|
|
252
|
+
let client = makeStartTlsClient(port, { socketTimeout: 200 });
|
|
187
253
|
|
|
188
254
|
let errors = [];
|
|
189
255
|
client.on('error', err => errors.push(err));
|
|
@@ -215,25 +281,12 @@ module.exports['Secure: STARTTLS leaves no upgrade state behind on success'] = a
|
|
|
215
281
|
// a successful handshake no timer, rejector, flag or temporary handler survives.
|
|
216
282
|
let server = createStartTlsServer();
|
|
217
283
|
let port = await listen(server);
|
|
218
|
-
let client =
|
|
219
|
-
host: '127.0.0.1',
|
|
220
|
-
port,
|
|
221
|
-
secure: false,
|
|
222
|
-
doSTARTTLS: true,
|
|
223
|
-
servername: 'localhost',
|
|
224
|
-
tls: { rejectUnauthorized: false },
|
|
225
|
-
disableAutoIdle: true,
|
|
226
|
-
disableCompression: true,
|
|
227
|
-
logger: false,
|
|
228
|
-
auth: { user: 'test', pass: 'secret' }
|
|
229
|
-
});
|
|
284
|
+
let client = makeStartTlsClient(port);
|
|
230
285
|
client.on('error', () => {});
|
|
231
286
|
|
|
232
287
|
await client.connect();
|
|
233
288
|
|
|
234
|
-
test
|
|
235
|
-
test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
|
|
236
|
-
test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
|
|
289
|
+
assertUpgradeSettled(test, client);
|
|
237
290
|
// The handshake-only handler is gone, leaving the generic socket error handler as the single
|
|
238
291
|
// error path (during the handshake it is the other way round, which is what prevents a
|
|
239
292
|
// handshake error from firing two handlers at once).
|
|
@@ -258,18 +311,7 @@ module.exports['Secure: close() during a STARTTLS upgrade settles the upgrade']
|
|
|
258
311
|
rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
|
|
259
312
|
});
|
|
260
313
|
let port = await listen(server);
|
|
261
|
-
let client =
|
|
262
|
-
host: '127.0.0.1',
|
|
263
|
-
port,
|
|
264
|
-
secure: false,
|
|
265
|
-
doSTARTTLS: true,
|
|
266
|
-
servername: 'localhost',
|
|
267
|
-
tls: { rejectUnauthorized: false },
|
|
268
|
-
disableAutoIdle: true,
|
|
269
|
-
disableCompression: true,
|
|
270
|
-
logger: false,
|
|
271
|
-
auth: { user: 'test', pass: 'secret' }
|
|
272
|
-
});
|
|
314
|
+
let client = makeStartTlsClient(port);
|
|
273
315
|
client.on('error', () => {});
|
|
274
316
|
|
|
275
317
|
let connectResult = client.connect().then(
|
|
@@ -281,15 +323,46 @@ module.exports['Secure: close() during a STARTTLS upgrade settles the upgrade']
|
|
|
281
323
|
while (!client.upgrading) {
|
|
282
324
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
283
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;
|
|
284
329
|
client.close();
|
|
285
330
|
|
|
286
331
|
let err = await connectResult;
|
|
287
332
|
test.ok(err, 'connect rejected rather than hanging on the abandoned upgrade');
|
|
288
333
|
test.ok(['ClosedAfterConnectText', 'ClosedAfterConnectTLS', 'NoConnection'].includes(err.code), `connect rejected with ${err.code}`);
|
|
289
|
-
test
|
|
290
|
-
|
|
291
|
-
|
|
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
|
+
};
|
|
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
|
+
);
|
|
292
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();
|
|
293
366
|
server.close();
|
|
294
367
|
test.done();
|
|
295
368
|
};
|