imapflow 1.3.4 → 1.3.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.
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ const net = require('net');
4
+ const { ImapFlow } = require('../lib/imap-flow');
5
+ const { ImapStream } = require('../lib/handler/imap-stream');
6
+
7
+ // Minimal pre-STARTTLS IMAP server. Sends the greeting, advertises STARTTLS, and on the
8
+ // STARTTLS command invokes `onStartTls(socket, tag)` so each test controls what (if
9
+ // anything) is written after the tagged OK. It never performs a real TLS handshake.
10
+ const createStartTlsServer = onStartTls =>
11
+ net.createServer(socket => {
12
+ socket.on('error', () => {});
13
+ socket.write('* OK [CAPABILITY IMAP4rev1 STARTTLS LOGINDISABLED] ready\r\n');
14
+
15
+ let buf = '';
16
+ socket.on('data', data => {
17
+ buf += data.toString('binary');
18
+ let idx;
19
+ while ((idx = buf.indexOf('\r\n')) >= 0) {
20
+ let line = buf.slice(0, idx);
21
+ buf = buf.slice(idx + 2);
22
+ let parts = line.split(' ');
23
+ let tag = parts[0];
24
+ let command = (parts[1] || '').toUpperCase();
25
+ if (command === 'CAPABILITY') {
26
+ socket.write('* CAPABILITY IMAP4rev1 STARTTLS LOGINDISABLED\r\n');
27
+ socket.write(`${tag} OK CAPABILITY done\r\n`);
28
+ } else if (command === 'STARTTLS') {
29
+ onStartTls(socket, tag);
30
+ }
31
+ }
32
+ });
33
+ });
34
+
35
+ const makeClient = port =>
36
+ new ImapFlow({
37
+ host: '127.0.0.1',
38
+ port,
39
+ secure: false,
40
+ doSTARTTLS: true,
41
+ servername: 'localhost',
42
+ tls: { rejectUnauthorized: false },
43
+ disableAutoIdle: true,
44
+ logger: false,
45
+ auth: { user: 'test', pass: 'test' }
46
+ });
47
+
48
+ // A MITM injects a response in the SAME segment as the STARTTLS OK, before the handshake.
49
+ // This is caught by the parser-level trailing-data flag (Check 1 in upgradeToSTARTTLS).
50
+ // Injection that instead arrives after the OK in a separate segment is caught by the
51
+ // socket-level read after unpipe (Check 2) or, failing that, by TLS handshake corruption;
52
+ // those fragmented cases are timing-dependent and not asserted deterministically here.
53
+ module.exports['STARTTLS: rejects same-segment plaintext injection'] = async test => {
54
+ let server = createStartTlsServer((socket, tag) => {
55
+ // OK and an injected untagged CAPABILITY in a single write, no TLS handshake.
56
+ socket.write(`${tag} OK Begin TLS\r\n* CAPABILITY IMAP4rev1 INJECTED\r\n`);
57
+ });
58
+
59
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
60
+ let port = server.address().port;
61
+
62
+ let client = makeClient(port);
63
+ client.on('error', () => {});
64
+
65
+ let connectErr = null;
66
+ try {
67
+ await client.connect();
68
+ test.ok(false, 'connect() must reject when data is injected after the STARTTLS OK');
69
+ } catch (err) {
70
+ connectErr = err;
71
+ }
72
+
73
+ test.ok(connectErr, 'connect() rejected');
74
+ test.equal(connectErr.code, 'STARTTLS_INJECTION', 'rejection is flagged as an injection');
75
+ test.ok(connectErr.tlsFailed, 'rejection is treated as a TLS failure');
76
+ // The connection must fail closed: it never becomes usable and never authenticates.
77
+ // (The injected untagged response may be parsed transiently before teardown, so asserting
78
+ // it never reaches the capability set would be a timing-fragile false assurance.)
79
+ test.ok(!client.usable, 'the connection did not become usable (failed closed)');
80
+ test.ok(!client.authenticated, 'the connection never authenticated');
81
+
82
+ client.close();
83
+ server.close();
84
+ test.done();
85
+ };
86
+
87
+ // A compliant server stays silent after the STARTTLS OK. The injection guard must NOT
88
+ // fire (no false positive); the upgrade proceeds to the TLS handshake, which then fails
89
+ // here only because this stub server never speaks TLS — proving the guard let it through.
90
+ module.exports['STARTTLS: clean OK is not flagged as injection'] = async test => {
91
+ let server = createStartTlsServer((socket, tag) => {
92
+ // Only the tagged OK, nothing after it, then drop the connection.
93
+ socket.write(`${tag} OK Begin TLS\r\n`);
94
+ setImmediate(() => socket.destroy());
95
+ });
96
+
97
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
98
+ let port = server.address().port;
99
+
100
+ let client = makeClient(port);
101
+ client.on('error', () => {});
102
+
103
+ let connectErr = null;
104
+ try {
105
+ await client.connect();
106
+ test.ok(false, 'connect() rejects because the stub never completes the TLS handshake');
107
+ } catch (err) {
108
+ connectErr = err;
109
+ }
110
+
111
+ test.ok(connectErr, 'connect() rejected (no TLS handshake on the stub server)');
112
+ test.notEqual(connectErr.code, 'STARTTLS_INJECTION', 'a clean OK must not be flagged as injection');
113
+
114
+ client.close();
115
+ server.close();
116
+ test.done();
117
+ };
118
+
119
+ // A STARTTLS handshake failure must reject connect() through a single error path: it must
120
+ // not also emit an 'error' event (which would double-report and crash a listener-less client).
121
+ module.exports['STARTTLS: handshake failure has a single error path'] = async test => {
122
+ let server = createStartTlsServer((socket, tag) => {
123
+ // Acknowledge STARTTLS, then drop the connection instead of doing TLS.
124
+ socket.write(`${tag} OK Begin TLS\r\n`);
125
+ setImmediate(() => socket.destroy());
126
+ });
127
+
128
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
129
+ let port = server.address().port;
130
+
131
+ let client = makeClient(port);
132
+
133
+ // A second error path would emit here (and crash a listener-less client).
134
+ let errorEvents = 0;
135
+ client.on('error', () => {
136
+ errorEvents++;
137
+ });
138
+
139
+ let connectErr = null;
140
+ try {
141
+ await client.connect();
142
+ test.ok(false, 'connect() should reject on STARTTLS handshake failure');
143
+ } catch (err) {
144
+ connectErr = err;
145
+ }
146
+
147
+ // Give any stray async error handler a chance to (wrongly) fire.
148
+ await new Promise(r => setTimeout(r, 50));
149
+
150
+ test.ok(connectErr, 'connect() rejected');
151
+ test.ok(connectErr.tlsFailed, 'rejection is flagged as a TLS failure');
152
+ test.equal(errorEvents, 0, 'no duplicate error event emitted (single error path)');
153
+
154
+ client.close();
155
+ server.close();
156
+ test.done();
157
+ };
158
+
159
+ // Unit-level check of the per-command trailing flag the guard relies on: each pushed
160
+ // command records whether more input followed it, independently of later commands.
161
+ module.exports['STARTTLS: parser flags trailing data per command'] = test => {
162
+ const stream = new ImapStream({ cid: 'test' });
163
+ let flags = [];
164
+
165
+ stream.on('readable', () => {
166
+ let cmd;
167
+ while ((cmd = stream.read()) !== null) {
168
+ flags.push(cmd.trailingAfterLine);
169
+ cmd.next();
170
+ }
171
+ });
172
+ stream.on('error', err => test.ifError(err));
173
+ stream.on('end', () => {
174
+ // First command had a second command after it -> true; the last one -> false.
175
+ test.deepEqual(flags, [true, false], 'trailingAfterLine is per-command and not overwritten');
176
+ test.done();
177
+ });
178
+
179
+ // Two complete commands in a single write.
180
+ stream.end(Buffer.from('A OK first\r\nB OK second\r\n'));
181
+ };
@@ -372,3 +372,21 @@ module.exports['Token Parser: E34: digit after star throws ParserError34'] = tes
372
372
  test.ok(err, 'expected an error to be thrown');
373
373
  test.equal(err.code, 'ParserError34');
374
374
  });
375
+
376
+ module.exports['Token Parser: REFERRAL response code is parsed as an IMAP URL atom'] = test =>
377
+ asyncWrapper(test, async test => {
378
+ let r = await parser('* OK [REFERRAL imap://user@host/INBOX] please use another server');
379
+ let section = r.attributes[0].section;
380
+ test.equal(section[0].value, 'REFERRAL');
381
+ test.equal(section[1].value, 'imap://user@host/INBOX');
382
+ });
383
+
384
+ module.exports['Token Parser: malformed REFERRAL with no closing bracket consumes the rest'] = test =>
385
+ asyncWrapper(test, async test => {
386
+ // Missing ']' previously produced a negative-index substring (garbage). The whole
387
+ // remaining string should be captured as the URL instead, without throwing.
388
+ let r = await parser('* OK [REFERRAL imap://user@host/INBOX');
389
+ let section = r.attributes[0].section;
390
+ test.equal(section[0].value, 'REFERRAL');
391
+ test.equal(section[1].value, 'imap://user@host/INBOX');
392
+ });
@@ -2,6 +2,8 @@
2
2
 
3
3
  const tools = require('../lib/tools');
4
4
  const { parser } = require('../lib/handler/imap-handler');
5
+ const crypto = require('crypto');
6
+ const iconv = require('iconv-lite');
5
7
 
6
8
  // Mock connection for testing
7
9
  let createMockConnection = (options = {}) => ({
@@ -1164,3 +1166,43 @@ module.exports['Tools: formatMessageResponse handles normal THREADID'] = async t
1164
1166
  test.equal(result.emailId, 'E2');
1165
1167
  test.done();
1166
1168
  };
1169
+
1170
+ // ============================================
1171
+ // formatMessageResponse: non-ASCII mailbox path normalization for stable id
1172
+ // ============================================
1173
+
1174
+ module.exports['Tools: formatMessageResponse normalizes non-ASCII mailbox path for id'] = async test => {
1175
+ // No EMAILID, so the message id falls back to an md5 over [path, uidValidity, uid].
1176
+ // The mailbox path is non-ASCII and must be modified-UTF-7 normalized before hashing.
1177
+ // (A previous bogus regex /[0x80-0xff]/ never matched non-ASCII, skipping normalization.)
1178
+ let untagged = await parser('* 1 FETCH (UID 5 FLAGS (\\Seen))');
1179
+ let mailbox = { path: '日本語', uidValidity: 123n };
1180
+ let result = await tools.formatMessageResponse(untagged, mailbox);
1181
+
1182
+ let encodedPath = iconv.encode('日本語', 'utf-7-imap').toString();
1183
+ let expectedId = crypto.createHash('md5').update([encodedPath, '123', '5'].join(':')).digest('hex');
1184
+ let rawId = crypto.createHash('md5').update(['日本語', '123', '5'].join(':')).digest('hex');
1185
+
1186
+ test.equal(result.id, expectedId, 'id must hash the UTF-7 normalized path');
1187
+ test.notEqual(result.id, rawId, 'id must not hash the raw non-ASCII path');
1188
+ test.done();
1189
+ };
1190
+
1191
+ // ============================================
1192
+ // packMessageRange
1193
+ // ============================================
1194
+
1195
+ module.exports['Tools: packMessageRange packs contiguous and gapped ranges'] = test => {
1196
+ test.equal(tools.packMessageRange([1, 2, 3, 5, 7, 8]), '1:3,5,7:8');
1197
+ test.equal(tools.packMessageRange(7), '7');
1198
+ test.equal(tools.packMessageRange([]), '');
1199
+ test.done();
1200
+ };
1201
+
1202
+ module.exports['Tools: packMessageRange dedupes duplicate values'] = test => {
1203
+ // Duplicates must not produce overlapping/non-canonical tokens like "1,1:3".
1204
+ test.equal(tools.packMessageRange([1, 1, 2, 3]), '1:3');
1205
+ test.equal(tools.packMessageRange([3, 1, 2, 2, 5]), '1:3,5');
1206
+ test.equal(tools.packMessageRange([5, 5, 5]), '5');
1207
+ test.done();
1208
+ };