imapflow 1.3.5 → 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,12 @@
1
+ name: "ImapFlow CodeQL config"
2
+
3
+ # Exclude code that is not part of the published library runtime from analysis.
4
+ # These paths generate only false-positive noise:
5
+ # - test fixtures intentionally feed malformed/hostile input and exercise
6
+ # edge cases that are not representative of production usage
7
+ # - the example scripts hardcode demo credentials and connection details for
8
+ # local experimentation and are not maintained as production code
9
+ paths-ignore:
10
+ - test
11
+ - '**/test/**'
12
+ - examples
@@ -0,0 +1,102 @@
1
+ # For most projects, this workflow file will not need changing; you simply need
2
+ # to commit it to your repository.
3
+ #
4
+ # You may wish to alter this file to override the set of languages analyzed,
5
+ # or to provide custom queries or build logic.
6
+ #
7
+ # ******** NOTE ********
8
+ # We have attempted to detect the languages in your repository. Please check
9
+ # the `language` matrix defined below to confirm you have the correct set of
10
+ # supported CodeQL languages.
11
+ #
12
+ name: "CodeQL Advanced"
13
+
14
+ on:
15
+ push:
16
+ branches: [ "master" ]
17
+ pull_request:
18
+ branches: [ "master" ]
19
+ schedule:
20
+ - cron: '40 17 * * 6'
21
+
22
+ jobs:
23
+ analyze:
24
+ name: Analyze (${{ matrix.language }})
25
+ # Runner size impacts CodeQL analysis time. To learn more, please see:
26
+ # - https://gh.io/recommended-hardware-resources-for-running-codeql
27
+ # - https://gh.io/supported-runners-and-hardware-resources
28
+ # - https://gh.io/using-larger-runners (GitHub.com only)
29
+ # Consider using larger runners or machines with greater resources for possible analysis time improvements.
30
+ runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
31
+ permissions:
32
+ # required for all workflows
33
+ security-events: write
34
+
35
+ # required to fetch internal or private CodeQL packs
36
+ packages: read
37
+
38
+ # only required for workflows in private repositories
39
+ actions: read
40
+ contents: read
41
+
42
+ strategy:
43
+ fail-fast: false
44
+ matrix:
45
+ include:
46
+ - language: actions
47
+ build-mode: none
48
+ - language: javascript-typescript
49
+ build-mode: none
50
+ # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
51
+ # Use `c-cpp` to analyze code written in C, C++ or both
52
+ # Use 'java-kotlin' to analyze code written in Java, Kotlin or both
53
+ # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
54
+ # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
55
+ # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
56
+ # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
57
+ # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
58
+ steps:
59
+ - name: Checkout repository
60
+ uses: actions/checkout@v4
61
+
62
+ # Add any setup steps before running the `github/codeql-action/init` action.
63
+ # This includes steps like installing compilers or runtimes (`actions/setup-node`
64
+ # or others). This is typically only required for manual builds.
65
+ # - name: Setup runtime (example)
66
+ # uses: actions/setup-example@v1
67
+
68
+ # Initializes the CodeQL tools for scanning.
69
+ - name: Initialize CodeQL
70
+ uses: github/codeql-action/init@v4
71
+ with:
72
+ languages: ${{ matrix.language }}
73
+ build-mode: ${{ matrix.build-mode }}
74
+ config-file: ./.github/codeql/codeql-config.yml
75
+ # If you wish to specify custom queries, you can do so here or in a config file.
76
+ # By default, queries listed here will override any specified in a config file.
77
+ # Prefix the list here with "+" to use these queries and those in the config file.
78
+
79
+ # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
80
+ # queries: security-extended,security-and-quality
81
+
82
+ # If the analyze step fails for one of the languages you are analyzing with
83
+ # "We were unable to automatically build your code", modify the matrix above
84
+ # to set the build mode to "manual" for that language. Then modify this step
85
+ # to build your code.
86
+ # ℹ️ Command-line programs to run using the OS shell.
87
+ # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
88
+ - name: Run manual build steps
89
+ if: matrix.build-mode == 'manual'
90
+ shell: bash
91
+ run: |
92
+ echo 'If you are using a "manual" build mode for one or more of the' \
93
+ 'languages you are analyzing, replace this with the commands to build' \
94
+ 'your code, for example:'
95
+ echo ' make bootstrap'
96
+ echo ' make release'
97
+ exit 1
98
+
99
+ - name: Perform CodeQL Analysis
100
+ uses: github/codeql-action/analyze@v4
101
+ with:
102
+ category: "/language:${{matrix.language}}"
@@ -3,6 +3,11 @@ on:
3
3
  schedule:
4
4
  - cron: '30 1 * * *'
5
5
 
6
+ permissions:
7
+ contents: read
8
+ issues: write
9
+ pull-requests: write
10
+
6
11
  jobs:
7
12
  stale:
8
13
  runs-on: ubuntu-latest
@@ -4,6 +4,9 @@ on:
4
4
  push:
5
5
  pull_request:
6
6
 
7
+ permissions:
8
+ contents: read
9
+
7
10
  jobs:
8
11
  test:
9
12
  strategy:
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.3.5"
2
+ ".": "1.3.6"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.6](https://github.com/postalsys/imapflow/compare/v1.3.5...v1.3.6) (2026-06-05)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * harden STARTTLS upgrade, literal bounds, and connection error paths ([35eca81](https://github.com/postalsys/imapflow/commit/35eca81075401e89f0b3aca84e26c41710a62c28))
9
+
3
10
  ## [1.3.5](https://github.com/postalsys/imapflow/compare/v1.3.4...v1.3.5) (2026-06-01)
4
11
 
5
12
 
@@ -15,6 +15,9 @@ module.exports = async connection => {
15
15
  let response;
16
16
  try {
17
17
  response = await connection.exec('STARTTLS');
18
+ // Whether the server sent anything after the STARTTLS OK and before the TLS
19
+ // handshake. upgradeToSTARTTLS() uses this to reject a plaintext injection.
20
+ connection._starttlsHadTrailingData = !!(response && response.hasTrailingData);
18
21
  response.next();
19
22
  return true;
20
23
  } catch (err) {
@@ -43,6 +43,9 @@ class ImapStream extends Transform {
43
43
  * line (a response without a literal). Defaults to MAX_LITERAL_SIZE (1GB). Guards against a
44
44
  * malicious or broken server that never sends a line terminator, which would otherwise grow
45
45
  * the internal line buffer without bound.
46
+ * @param {number} [options.maxLiteralSize] - Maximum allowed size (in bytes) of a single
47
+ * literal block. Defaults to MAX_LITERAL_SIZE (1GB). Lower it to bound peak memory
48
+ * allocation against a malicious or broken server announcing an oversized literal.
46
49
  */
47
50
  constructor(options) {
48
51
  super({
@@ -65,8 +68,16 @@ class ImapStream extends Transform {
65
68
  this.readBytesCounter = 0;
66
69
 
67
70
  // Maximum length of a single line (response without a literal). Bounds the line buffer
68
- // so a server that never sends a line terminator cannot exhaust memory.
69
- this.maxLineLength = this.options.maxLineLength || MAX_LINE_SIZE;
71
+ // so a server that never sends a line terminator cannot exhaust memory. A non-negative
72
+ // integer is honored as-is (including 0); anything else falls back to the default, so an
73
+ // explicit 0 is not silently swallowed into the 1GB default the way `|| MAX_LINE_SIZE` was.
74
+ this.maxLineLength = Number.isInteger(this.options.maxLineLength) && this.options.maxLineLength >= 0 ? this.options.maxLineLength : MAX_LINE_SIZE;
75
+
76
+ // Maximum size of a single literal block. Bounds peak memory allocation so a server
77
+ // announcing an oversized literal cannot exhaust memory. As above, a non-negative integer
78
+ // (including an explicit 0, meaning "reject all non-empty literals") is honored as-is.
79
+ this.maxLiteralSize =
80
+ Number.isInteger(this.options.maxLiteralSize) && this.options.maxLiteralSize >= 0 ? this.options.maxLiteralSize : MAX_LITERAL_SIZE;
70
81
 
71
82
  this.state = LINE;
72
83
  this.literalWaiting = 0;
@@ -125,11 +136,11 @@ class ImapStream extends Transform {
125
136
  if (c === CURLY_OPEN && numBytes.length) {
126
137
  const literalSize = Number(Buffer.from(numBytes).toString());
127
138
 
128
- if (literalSize > MAX_LITERAL_SIZE) {
129
- const err = new Error(`Literal size ${literalSize} exceeds maximum allowed size of ${MAX_LITERAL_SIZE} bytes`);
139
+ if (literalSize > this.maxLiteralSize) {
140
+ const err = new Error(`Literal size ${literalSize} exceeds maximum allowed size of ${this.maxLiteralSize} bytes`);
130
141
  err.code = 'LiteralTooLarge';
131
142
  err.literalSize = literalSize;
132
- err.maxSize = MAX_LITERAL_SIZE;
143
+ err.maxSize = this.maxLiteralSize;
133
144
  this.emit('error', err);
134
145
  return false;
135
146
  }
@@ -197,8 +208,14 @@ class ImapStream extends Transform {
197
208
  }
198
209
 
199
210
  if (payload.length) {
211
+ // Whether more buffered input already followed this command on the
212
+ // wire — more bytes in this chunk or another queued chunk. Captured
213
+ // per emitted command (immutable on the pushed object) so a later
214
+ // command cannot overwrite it; consumers that care about pipelining
215
+ // boundaries can read it from the pushed object.
216
+ let trailingAfterLine = lineStart < chunk.length || this.inputQueue.length > 0;
200
217
  await new Promise(resolve => {
201
- this.push({ payload, literals, next: resolve });
218
+ this.push({ payload, literals, next: resolve, trailingAfterLine });
202
219
  });
203
220
  }
204
221
  }
@@ -66,6 +66,11 @@ export interface ImapFlowOptions {
66
66
  * 1GB.
67
67
  */
68
68
  maxLineLength?: number;
69
+ /**
70
+ * Maximum allowed size in bytes of a single IMAP literal block. Bounds peak memory allocation
71
+ * against a malicious or broken server announcing an oversized literal. Defaults to 1GB.
72
+ */
73
+ maxLiteralSize?: number;
69
74
  /**
70
75
  * Threshold in milliseconds for warning that a mailbox lock has been held
71
76
  * for a long time (diagnostic for forgotten release() calls). Defaults to
package/lib/imap-flow.js CHANGED
@@ -23,7 +23,7 @@ const libbase64 = require('libbase64');
23
23
  const FlowedDecoder = require('@zone-eu/mailsplit/lib/flowed-decoder');
24
24
  const { PassThrough } = require('stream');
25
25
 
26
- const { proxyConnection } = require('./proxy-connection');
26
+ const { proxyConnection, detachEarlyErrorHandler } = require('./proxy-connection');
27
27
 
28
28
  const {
29
29
  comparePaths,
@@ -292,13 +292,24 @@ class ImapFlow extends EventEmitter {
292
292
  cid: this.id,
293
293
  logRaw: this.logRaw,
294
294
  secureConnection: this.secureConnection,
295
- maxLineLength: this.options.maxLineLength
295
+ maxLineLength: this.options.maxLineLength,
296
+ maxLiteralSize: this.options.maxLiteralSize
296
297
  });
297
298
 
298
299
  this.reading = false;
299
300
  this.socket = false;
300
301
  this.writeSocket = false;
301
302
 
303
+ // Tracked throttle back-off timer (see reader()). Stored so close() can clear it
304
+ // and abort the wait instead of letting it keep the event loop alive for minutes.
305
+ this._throttleTimer = null;
306
+ this._throttleAbort = null;
307
+
308
+ // Pending rejector of the in-flight STARTTLS upgrade promise (see upgradeToSTARTTLS()).
309
+ // Stored so emitError() can route a streamer-originated error into the upgrade's single
310
+ // error path instead of dropping it (which could hang a verifyOnly connect()).
311
+ this._upgradeReject = null;
312
+
302
313
  this.isClosed = false;
303
314
 
304
315
  this.states = states;
@@ -327,6 +338,10 @@ class ImapFlow extends EventEmitter {
327
338
 
328
339
  this.expectCapabilityUpdate = false; // force CAPABILITY after LOGIN
329
340
 
341
+ // Set true if the server sent data after the STARTTLS OK and before the TLS
342
+ // handshake (a plaintext-injection signal). See upgradeToSTARTTLS().
343
+ this._starttlsHadTrailingData = false;
344
+
330
345
  /**
331
346
  * Enabled capabilities. Usually `CONDSTORE` and `UTF8=ACCEPT` if server supports these.
332
347
  * @type {Set<string>}
@@ -405,6 +420,34 @@ class ImapFlow extends EventEmitter {
405
420
  }
406
421
  err._connId = err._connId || this.id;
407
422
 
423
+ // During a STARTTLS handshake the upgrade promise owns the single error path
424
+ // (tlsSocketErrorHandler -> reject). Route the error there so a streamer-originated
425
+ // failure is surfaced with its real code (instead of a generic ClosedAfterConnect*)
426
+ // and cannot hang a verifyOnly connect() waiting on a 'close' that never rejects.
427
+ // Fall back to closing if the upgrade has no pending rejector.
428
+ if (this.upgrading) {
429
+ this.upgrading = false;
430
+ this.closeAfter();
431
+ if (typeof this._upgradeReject === 'function') {
432
+ let reject = this._upgradeReject;
433
+ this._upgradeReject = null;
434
+ reject(err);
435
+ }
436
+ return;
437
+ }
438
+
439
+ // While the initial connect promise is still pending it owns error reporting:
440
+ // reject it once instead of emitting a duplicate 'error' event (which would also
441
+ // throw if the caller has not attached an 'error' listener yet).
442
+ if (typeof this.initialReject === 'function') {
443
+ let reject = this.initialReject;
444
+ this.initialResolve = false;
445
+ this.initialReject = false;
446
+ this.closeAfter();
447
+ reject(err);
448
+ return;
449
+ }
450
+
408
451
  this.closeAfter();
409
452
  this.emit('error', err);
410
453
  }
@@ -689,8 +732,14 @@ class ImapFlow extends EventEmitter {
689
732
  // Server acknowledged our literal size with "+", send the actual literal data
690
733
  if (parsed.tag === '+' && this.commandParts.length) {
691
734
  let content = this.commandParts.shift();
692
- this.write(content);
693
- this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
735
+ // A write() failure here (e.g. socket closed mid-command) must not propagate
736
+ // out of the loop and skip data.next(), which would stall the parser stream.
737
+ try {
738
+ this.write(content);
739
+ this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
740
+ } catch (err) {
741
+ this.log.warn({ err, cid: this.id });
742
+ }
694
743
  data.next();
695
744
  continue;
696
745
  }
@@ -725,15 +774,22 @@ class ImapFlow extends EventEmitter {
725
774
  this.requestTagMap.delete(parsed.tag);
726
775
 
727
776
  if (this.currentRequest && this.currentRequest.tag === parsed.tag) {
728
- // send next pending command
777
+ // send next pending command. A failure here must not propagate out of the
778
+ // loop and skip data.next() below, which would stall the parser stream.
729
779
  this.currentRequest = false;
730
- await this.trySend();
780
+ try {
781
+ await this.trySend();
782
+ } catch (err) {
783
+ this.log.warn({ err, cid: this.id });
784
+ }
731
785
  }
732
786
 
733
787
  switch (parsed.command.toUpperCase()) {
734
788
  case 'OK':
735
789
  case 'BYE':
736
- await new Promise(resolve => request.resolve({ response: parsed, next: resolve }));
790
+ // hasTrailingData is forwarded so STARTTLS can detect a plaintext
791
+ // injection (data buffered after the tagged OK, before the handshake).
792
+ await new Promise(resolve => request.resolve({ response: parsed, next: resolve, hasTrailingData: !!data.trailingAfterLine }));
737
793
  break;
738
794
 
739
795
  case 'NO':
@@ -796,7 +852,29 @@ class ImapFlow extends EventEmitter {
796
852
  }
797
853
 
798
854
  this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err });
799
- await new Promise(r => setTimeout(r, delayResponse));
855
+
856
+ // Tracked, abortable wait. Storing the timer lets close() clear it so
857
+ // the back-off never keeps the event loop alive, and storing the resolve
858
+ // lets close() abort the wait promptly (aborted=true) instead of blocking
859
+ // the reader for up to 5 minutes and rejecting long after the connection
860
+ // is gone. Normal expiry resolves with aborted=false.
861
+ let aborted = await new Promise(resolve => {
862
+ this._throttleAbort = resolve;
863
+ this._throttleTimer = setTimeout(() => resolve(false), delayResponse);
864
+ if (typeof this._throttleTimer.unref === 'function') {
865
+ this._throttleTimer.unref();
866
+ }
867
+ });
868
+ this._throttleTimer = null;
869
+ this._throttleAbort = null;
870
+
871
+ if (aborted) {
872
+ // Connection closed during back-off: reject promptly with a
873
+ // connection error (carrying any server BYE reason) instead of
874
+ // waiting out the throttle delay.
875
+ request.reject(this.createNoConnectionError(this.byeReason));
876
+ break;
877
+ }
800
878
  }
801
879
  }
802
880
 
@@ -1013,7 +1091,13 @@ class ImapFlow extends EventEmitter {
1013
1091
  this.streamer.compress = true;
1014
1092
  this.socket.pipe(this._inflate).pipe(this.streamer);
1015
1093
  this._inflate.on('error', err => {
1016
- this.streamer.emit('error', err);
1094
+ // Only forward into the streamer while it is alive and still has an error
1095
+ // listener. After close() the streamer is destroyed and its listener removed,
1096
+ // so emitting 'error' would throw an unhandled error and crash the process.
1097
+ // (this.streamer is assigned once in the constructor and never nulled.)
1098
+ if (!this.streamer.destroyed && this.streamer.listenerCount('error')) {
1099
+ this.streamer.emit('error', err);
1100
+ }
1017
1101
  });
1018
1102
 
1019
1103
  // For outgoing data, replace the writeSocket with a PassThrough buffer.
@@ -1139,11 +1223,41 @@ class ImapFlow extends EventEmitter {
1139
1223
  return this._failSTARTTLS();
1140
1224
  }
1141
1225
 
1226
+ // STARTTLS plaintext-injection guard (RFC 3501 §6.2.1): a compliant server stays
1227
+ // silent after the tagged STARTTLS OK until the TLS handshake, so any data that
1228
+ // followed the OK was injected by a MITM and must not be treated as if it arrived
1229
+ // over TLS. Two complementary best-effort checks fail closed before wrapping the
1230
+ // socket; injection that still races in afterwards corrupts the TLS handshake and
1231
+ // is rejected there instead (with a generic TLS error rather than STARTTLS_INJECTION).
1232
+ const failSTARTTLSInjection = () => {
1233
+ let err = new Error('Server sent data after the STARTTLS response and before the TLS handshake; possible plaintext-injection attack');
1234
+ err.code = 'STARTTLS_INJECTION';
1235
+ err.tlsFailed = true;
1236
+ this.closeAfter();
1237
+ return err;
1238
+ };
1239
+
1240
+ // Check 1: the parser saw more input already buffered right after the tagged OK
1241
+ // (same TCP segment, or an already-queued chunk) — see hasTrailingData / starttls.js.
1242
+ if (this._starttlsHadTrailingData) {
1243
+ throw failSTARTTLSInjection();
1244
+ }
1245
+
1142
1246
  // STARTTLS upgrade sequence: detach the plain socket from the parser,
1143
1247
  // wrap it in a TLS socket, then reconnect the new TLS socket to the
1144
1248
  // parser. The plain socket becomes the underlying transport for TLS.
1145
1249
  this.socket.unpipe(this.streamer);
1250
+
1251
+ // Check 2: now that the parser is detached, any bytes still buffered on the plain
1252
+ // socket arrived after the OK and were not consumed by the handshake — i.e. injected.
1253
+ // This catches late/fragmented injection that the parse-time snapshot cannot see.
1254
+ let injectedTail = typeof this.socket.read === 'function' ? this.socket.read() : null;
1255
+ if (injectedTail && injectedTail.length) {
1256
+ throw failSTARTTLSInjection();
1257
+ }
1146
1258
  let upgraded = await new Promise((resolve, reject) => {
1259
+ // Expose this rejector so emitError() can settle the upgrade with a streamer error.
1260
+ this._upgradeReject = reject;
1147
1261
  let socketPlain = this.socket;
1148
1262
  let opts = Object.assign(
1149
1263
  {
@@ -1231,20 +1345,29 @@ class ImapFlow extends EventEmitter {
1231
1345
  socketPlain.removeListener('error', socketPlainErrorHandler);
1232
1346
  this.socket.removeListener('error', tlsSocketErrorHandler);
1233
1347
 
1348
+ // Install the normal socket handlers only now that the handshake
1349
+ // succeeded. Doing this during the handshake would leave both
1350
+ // tlsSocketErrorHandler and the generic _socketError on the socket;
1351
+ // a handshake 'error' would then fire BOTH (EventEmitter clones its
1352
+ // listener array on emit), causing a duplicate error and a possible
1353
+ // unhandled 'error' crash. Keeping tlsSocketErrorHandler as the sole
1354
+ // listener until here guarantees a single error path for the upgrade.
1355
+ this.setSocketHandlers();
1356
+
1357
+ this._upgradeReject = null;
1234
1358
  return resolve(true);
1235
1359
  } catch (ex) {
1236
1360
  this.emitError(ex);
1237
1361
  }
1238
1362
  });
1239
1363
 
1240
- // Registered after tls.connect (the TLS socket now exists) but before
1241
- // setSocketHandlers() so it fires first on a handshake error and removes the generic
1242
- // handlers, keeping this the single error path for the upgrade.
1364
+ // Registered after tls.connect (the TLS socket now exists). This is the ONLY
1365
+ // error listener during the handshake window; the generic handlers are installed
1366
+ // by setSocketHandlers() inside the success callback above, so a handshake error
1367
+ // has a single error path (tlsSocketErrorHandler -> reject).
1243
1368
  this.socket.once('error', tlsSocketErrorHandler);
1244
1369
 
1245
1370
  this.writeSocket = this.socket;
1246
-
1247
- this.setSocketHandlers();
1248
1371
  });
1249
1372
 
1250
1373
  if (upgraded && this.expectCapabilityUpdate) {
@@ -1692,6 +1815,11 @@ class ImapFlow extends EventEmitter {
1692
1815
  let onConnect = () => {
1693
1816
  try {
1694
1817
  clearTimeout(this.connectTimeout);
1818
+
1819
+ // ImapFlow now owns the socket; drop the proxy's early error handler
1820
+ // (its "before connection setup" message no longer applies).
1821
+ detachEarlyErrorHandler(socket);
1822
+
1695
1823
  this.socket.setKeepAlive(true, 5 * 1000);
1696
1824
  this.socket.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT);
1697
1825
 
@@ -1802,6 +1930,17 @@ class ImapFlow extends EventEmitter {
1802
1930
  setImmediate(() => this.close());
1803
1931
  }
1804
1932
 
1933
+ // Builds the standard "connection not available" error, optionally annotated with the
1934
+ // server's BYE reason. Single source of truth so every NoConnection rejection is consistent.
1935
+ createNoConnectionError(byeReason) {
1936
+ const error = new Error('Connection not available');
1937
+ error.code = 'NoConnection';
1938
+ if (byeReason) {
1939
+ error.reason = byeReason;
1940
+ }
1941
+ return error;
1942
+ }
1943
+
1805
1944
  /**
1806
1945
  * Closes TCP connection without notifying the server.
1807
1946
  *
@@ -1819,6 +1958,15 @@ class ImapFlow extends EventEmitter {
1819
1958
  clearTimeout(this.connectTimeout);
1820
1959
  clearTimeout(this.greetingTimeout);
1821
1960
 
1961
+ // Abort any in-flight throttle back-off so the reader unblocks and the
1962
+ // throttled request is rejected promptly rather than after the full delay.
1963
+ clearTimeout(this._throttleTimer);
1964
+ this._throttleTimer = null;
1965
+ if (typeof this._throttleAbort === 'function') {
1966
+ this._throttleAbort(true);
1967
+ this._throttleAbort = null;
1968
+ }
1969
+
1822
1970
  this.usable = false;
1823
1971
  this.idling = false;
1824
1972
 
@@ -1829,6 +1977,11 @@ class ImapFlow extends EventEmitter {
1829
1977
  this.initialReject = false;
1830
1978
  let err = new Error('Unexpected close');
1831
1979
  err.code = `ClosedAfterConnect${this.secureConnection ? 'TLS' : 'Text'}`;
1980
+ // Surface the server's BYE reason (e.g. "Too many connections") when the
1981
+ // connection was closed by an untagged BYE, so the caller sees why.
1982
+ if (this.byeReason) {
1983
+ err.reason = this.byeReason;
1984
+ }
1832
1985
  // Synchronous rejection is safe: connectPromise.catch(noop) is already
1833
1986
  // attached, so the rejection is observed immediately. close() is synchronous,
1834
1987
  // so all cleanup completes before any microtask rejection handler runs.
@@ -1865,15 +2018,8 @@ class ImapFlow extends EventEmitter {
1865
2018
  }
1866
2019
  }
1867
2020
 
1868
- // Helper to create connection error
1869
- const createNoConnectionError = byeReason => {
1870
- const error = new Error('Connection not available');
1871
- error.code = 'NoConnection';
1872
- if (byeReason) {
1873
- error.reason = byeReason;
1874
- }
1875
- return error;
1876
- };
2021
+ // Helper to create connection error (delegates to the shared builder)
2022
+ const createNoConnectionError = byeReason => this.createNoConnectionError(byeReason);
1877
2023
 
1878
2024
  // Reject pending requests and locks synchronously. Each exec() and
1879
2025
  // getMailboxLock() promise already has .catch(noop) attached, so the
@@ -15,6 +15,29 @@ const hidePassword = proxyUrl => {
15
15
  }
16
16
  };
17
17
 
18
+ // Attaches a benign 'error' listener as soon as the proxied socket exists, so an early
19
+ // socket error (before ImapFlow installs its own handlers) cannot surface as an unhandled
20
+ // 'error' event and crash the process. The handler is stored on the socket so the caller
21
+ // can remove it once it takes ownership of the socket.
22
+ const attachEarlyErrorHandler = (logger, socket) => {
23
+ if (!socket || typeof socket.on !== 'function') {
24
+ return;
25
+ }
26
+ socket._earlyErrorHandler = err => {
27
+ logger.error({ msg: 'Proxy socket error before connection setup', err });
28
+ };
29
+ socket.on('error', socket._earlyErrorHandler);
30
+ };
31
+
32
+ // Removes the handler installed by attachEarlyErrorHandler once the caller takes ownership
33
+ // of the socket. Keeps the internal `_earlyErrorHandler` contract inside this module.
34
+ const detachEarlyErrorHandler = socket => {
35
+ if (socket && socket._earlyErrorHandler) {
36
+ socket.removeListener('error', socket._earlyErrorHandler);
37
+ socket._earlyErrorHandler = null;
38
+ }
39
+ };
40
+
18
41
  const proxyConnection = async (logger, connectionUrl, host, port) => {
19
42
  let proxyUrl = new URL(connectionUrl);
20
43
 
@@ -44,6 +67,7 @@ const proxyConnection = async (logger, connectionUrl, host, port) => {
44
67
  port,
45
68
  host
46
69
  });
70
+ attachEarlyErrorHandler(logger, socket);
47
71
  }
48
72
  return socket;
49
73
  } catch (err) {
@@ -106,6 +130,7 @@ const proxyConnection = async (logger, connectionUrl, host, port) => {
106
130
  port,
107
131
  host
108
132
  });
133
+ attachEarlyErrorHandler(logger, info.socket);
109
134
  }
110
135
  return info.socket;
111
136
  } catch (err) {
@@ -123,4 +148,4 @@ const proxyConnection = async (logger, connectionUrl, host, port) => {
123
148
  }
124
149
  };
125
150
 
126
- module.exports = { proxyConnection };
151
+ module.exports = { proxyConnection, detachEarlyErrorHandler };
package/lib/tools.js CHANGED
@@ -502,6 +502,10 @@ const tools = {
502
502
  }
503
503
  }
504
504
 
505
+ // Non-cryptographic identifier: MD5 is used only to derive a stable, compact
506
+ // account-unique id from non-secret data (path:uidValidity:uid). No security
507
+ // property (collision/preimage resistance, secrecy) is relied upon, so a fast
508
+ // hash is the appropriate choice here — not a security-sensitive use.
505
509
  map.id =
506
510
  map.emailId ||
507
511
  createHash('md5')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.3.5",
3
+ "version": "1.3.6",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { ImapFlow } = require('../lib/imap-flow');
4
4
  const { EventEmitter } = require('events');
5
+ const net = require('net');
5
6
 
6
7
  // Edge Cases Tests
7
8
 
@@ -1539,7 +1540,14 @@ async function setupCompressedClient() {
1539
1540
  mockSocket.destroy = () => {};
1540
1541
  mockSocket.destroyed = false;
1541
1542
  client.socket = mockSocket;
1542
- client.streamer = new EventEmitter();
1543
+ // Mock streamer that tracks destruction like a real stream, so close() drives it into
1544
+ // the destroyed state the post-close guards actually check.
1545
+ client.streamer = Object.assign(new EventEmitter(), {
1546
+ destroyed: false,
1547
+ destroy() {
1548
+ this.destroyed = true;
1549
+ }
1550
+ });
1543
1551
 
1544
1552
  client.run = async command => {
1545
1553
  if (command === 'COMPRESS') {
@@ -1630,6 +1638,33 @@ module.exports['Connection Edge: compress _deflate error after close does not cr
1630
1638
  test.done();
1631
1639
  };
1632
1640
 
1641
+ module.exports['Connection Edge: compress _inflate error after close does not crash'] = async test => {
1642
+ let { client } = await setupCompressedClient();
1643
+
1644
+ let inflate = client._inflate;
1645
+
1646
+ // close() removes the streamer 'error' listener and destroys the streamer.
1647
+ // A late inflate error must not be forwarded into the destroyed streamer
1648
+ // (which would throw an unhandled 'error' and crash the process).
1649
+ client.close();
1650
+
1651
+ // Confirm we actually reached the post-close state the guard targets.
1652
+ test.ok(client.streamer.destroyed, 'streamer is destroyed after close()');
1653
+
1654
+ // Re-attach an 'error' listener so listenerCount('error') > 0: this isolates the
1655
+ // `!streamer.destroyed` term of the guard. If forwarding still happened, this listener
1656
+ // would throw and escape doesNotThrow — proving the destroyed-check is what suppresses it.
1657
+ client.streamer.on('error', () => {
1658
+ throw new Error('inflate error must not be forwarded into a destroyed streamer');
1659
+ });
1660
+
1661
+ test.doesNotThrow(() => {
1662
+ inflate.emit('error', new Error('late inflate error'));
1663
+ });
1664
+
1665
+ test.done();
1666
+ };
1667
+
1633
1668
  // ============================================
1634
1669
  // Mailbox Lock Tests
1635
1670
  // ============================================
@@ -1723,3 +1758,105 @@ module.exports['Connection Edge: multiple locks rejected when no connection'] =
1723
1758
  }
1724
1759
  test.done();
1725
1760
  };
1761
+
1762
+ // ============================================
1763
+ // reader() survives a write() throw without stalling the stream
1764
+ // ============================================
1765
+
1766
+ module.exports['Connection Edge: reader survives write throw on + continuation'] = async test => {
1767
+ let client = new ImapFlow({
1768
+ host: 'imap.example.com',
1769
+ port: 993,
1770
+ auth: { user: 'test', pass: 'test' },
1771
+ logger: false
1772
+ });
1773
+
1774
+ client.currentRequest = false;
1775
+ client.commandParts = [Buffer.from('literal-bytes')];
1776
+
1777
+ let writeAttempted = false;
1778
+ client.write = () => {
1779
+ writeAttempted = true;
1780
+ throw new Error('write boom');
1781
+ };
1782
+
1783
+ let nextCalled = false;
1784
+ let done = false;
1785
+ client.streamer.read = () => {
1786
+ if (done) {
1787
+ return null;
1788
+ }
1789
+ done = true;
1790
+ return {
1791
+ payload: Buffer.from('+ Ready'),
1792
+ literals: [],
1793
+ next: () => {
1794
+ nextCalled = true;
1795
+ }
1796
+ };
1797
+ };
1798
+
1799
+ let rejected = false;
1800
+ await client.reader().catch(() => {
1801
+ rejected = true;
1802
+ });
1803
+
1804
+ test.ok(writeAttempted, 'the literal-continuation write path was exercised');
1805
+ test.equal(rejected, false, 'reader did not reject on write throw');
1806
+ test.ok(nextCalled, 'data.next() was still called so the stream is not stalled');
1807
+ test.done();
1808
+ };
1809
+
1810
+ // ============================================
1811
+ // BYE greeting surfaces its reason on the connect rejection
1812
+ // ============================================
1813
+
1814
+ module.exports['Connection Edge: BYE greeting surfaces reason on connect rejection'] = async test => {
1815
+ // Deterministic ordering: the server ends the socket only AFTER the client has parsed the
1816
+ // BYE and set byeReason, instead of racing a fixed 50ms timer that could flake on slow CI.
1817
+ let signalByeParsed;
1818
+ let byeParsed = new Promise(resolve => {
1819
+ signalByeParsed = resolve;
1820
+ });
1821
+
1822
+ let server = net.createServer(socket => {
1823
+ socket.on('error', () => {});
1824
+ socket.write('* BYE Server too busy\r\n');
1825
+ byeParsed.then(() => socket.end());
1826
+ });
1827
+
1828
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
1829
+ let port = server.address().port;
1830
+
1831
+ let client = new ImapFlow({
1832
+ host: '127.0.0.1',
1833
+ port,
1834
+ secure: false,
1835
+ disableAutoIdle: true,
1836
+ logger: false,
1837
+ auth: { user: 'test', pass: 'test' }
1838
+ });
1839
+ client.on('error', () => {});
1840
+
1841
+ // Signal once the client's BYE handler has run, so byeReason is guaranteed set before close().
1842
+ let origServerBye = client.serverBye.bind(client);
1843
+ client.serverBye = async parsed => {
1844
+ await origServerBye(parsed);
1845
+ signalByeParsed();
1846
+ };
1847
+
1848
+ let connectErr = null;
1849
+ try {
1850
+ await client.connect();
1851
+ test.ok(false, 'connect() should reject after a BYE greeting');
1852
+ } catch (err) {
1853
+ connectErr = err;
1854
+ }
1855
+
1856
+ test.ok(connectErr, 'connect() rejected');
1857
+ test.equal(connectErr.reason, 'Server too busy', 'BYE reason surfaced on the rejection');
1858
+
1859
+ client.close();
1860
+ server.close();
1861
+ test.done();
1862
+ };
@@ -131,6 +131,64 @@ module.exports['LiteralTooLarge error'] = test => {
131
131
  stream.write(Buffer.from('A APPEND {1073741825}\r\n'));
132
132
  };
133
133
 
134
+ module.exports['LiteralTooLarge error honors configured maxLiteralSize'] = test => {
135
+ const cap = 1024; // 1KB cap
136
+ const stream = new ImapStream({ cid: 'test', maxLiteralSize: cap });
137
+
138
+ stream.on('error', err => {
139
+ test.equal(err.code, 'LiteralTooLarge', 'error code should be LiteralTooLarge');
140
+ test.equal(err.maxSize, cap, 'maxSize should reflect the configured cap');
141
+ test.equal(err.literalSize, 2048, 'literalSize should be the offending value');
142
+ stream.destroy();
143
+ test.done();
144
+ });
145
+
146
+ stream.write(Buffer.from('A APPEND {2048}\r\n'));
147
+ };
148
+
149
+ module.exports['maxLiteralSize: 0 is honored (not swallowed into the default)'] = test => {
150
+ // Regression: `this.options.maxLiteralSize || MAX_LITERAL_SIZE` turned an explicit 0 into
151
+ // the 1GB default. An explicit 0 must mean "reject any non-empty literal".
152
+ test.expect(2);
153
+
154
+ const stream = new ImapStream({ cid: 'test', maxLiteralSize: 0 });
155
+ test.equal(stream.maxLiteralSize, 0, 'an explicit 0 cap is preserved, not replaced by the default');
156
+
157
+ stream.on('error', err => {
158
+ test.equal(err.code, 'LiteralTooLarge', 'a 1-byte literal exceeds the 0 cap');
159
+ stream.destroy();
160
+ test.done();
161
+ });
162
+
163
+ stream.write(Buffer.from('A APPEND {1}\r\n'));
164
+ };
165
+
166
+ module.exports['Literal within configured maxLiteralSize parses cleanly'] = test => {
167
+ // Require both literal assertions to actually run: 'end' fires even if the parser never
168
+ // emits the command, so without expect() a dropped-literal regression would pass green.
169
+ test.expect(2);
170
+
171
+ const stream = new ImapStream({ cid: 'test', maxLiteralSize: 1024 });
172
+ const literal = Buffer.alloc(512, 0x61); // 512 * 'a'
173
+
174
+ stream.on('readable', () => {
175
+ let cmd;
176
+ while ((cmd = stream.read()) !== null) {
177
+ test.equal(cmd.literals.length, 1, 'should have one literal');
178
+ test.equal(cmd.literals[0].length, 512, 'literal length should be 512');
179
+ cmd.next();
180
+ }
181
+ });
182
+
183
+ stream.on('error', err => test.ifError(err));
184
+
185
+ stream.on('end', () => test.done());
186
+
187
+ stream.write(Buffer.from('A APPEND {512}\r\n'));
188
+ stream.write(literal);
189
+ stream.end(Buffer.from('\r\n'));
190
+ };
191
+
134
192
  module.exports['Incomplete line continued in next chunk'] = test => {
135
193
  runStreamTest(
136
194
  test,
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const proxyquire = require('proxyquire').noCallThru();
4
+ const { EventEmitter } = require('events');
4
5
 
5
6
  // Mock socket object
6
7
  const createMockSocket = () => ({
@@ -10,6 +11,16 @@ const createMockSocket = () => ({
10
11
  destroy: () => {}
11
12
  });
12
13
 
14
+ // Real EventEmitter-backed mock socket, for tests that need listener tracking / emit.
15
+ const createMockEmitterSocket = () => Object.assign(new EventEmitter(), { write() {}, end() {}, destroy() {} });
16
+
17
+ // Asserts proxyConnection returned the socket and guarded it with an error listener.
18
+ const assertEarlyErrorHandler = (test, socket, mockSocket) => {
19
+ test.equal(socket, mockSocket);
20
+ test.ok(socket.listenerCount('error') >= 1, 'proxy socket carries an error listener before being returned');
21
+ test.doesNotThrow(() => socket.emit('error', new Error('early proxy error')), 'an early proxy socket error does not throw');
22
+ };
23
+
13
24
  // Mock logger
14
25
  const createMockLogger = () => {
15
26
  const logs = { info: [], error: [] };
@@ -539,3 +550,78 @@ module.exports['Proxy Connection: SOCKS with username only'] = async test => {
539
550
  await proxyConnection(logger, 'socks5://testuser@proxy.example.com:1080', '192.168.1.1', 993);
540
551
  test.done();
541
552
  };
553
+
554
+ // ============================================
555
+ // Early error handler (no unhandled 'error' before ImapFlow takes over)
556
+ // ============================================
557
+
558
+ module.exports['Proxy Connection: HTTP socket has early error handler before return'] = async test => {
559
+ const mockSocket = createMockEmitterSocket();
560
+ const logger = createMockLogger();
561
+
562
+ const { proxyConnection } = proxyquire('../lib/proxy-connection', {
563
+ 'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
564
+ cb(null, mockSocket);
565
+ },
566
+ socks: { SocksClient: {} },
567
+ dns: { promises: { resolve: async () => ['127.0.0.1'] } },
568
+ net: { isIP: () => true }
569
+ });
570
+
571
+ const socket = await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
572
+
573
+ assertEarlyErrorHandler(test, socket, mockSocket);
574
+ test.done();
575
+ };
576
+
577
+ module.exports['Proxy Connection: SOCKS socket has early error handler before return'] = async test => {
578
+ const mockSocket = createMockEmitterSocket();
579
+ const logger = createMockLogger();
580
+
581
+ const { proxyConnection } = proxyquire('../lib/proxy-connection', {
582
+ 'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
583
+ socks: {
584
+ SocksClient: {
585
+ createConnection: async () => ({ socket: mockSocket })
586
+ }
587
+ },
588
+ dns: { promises: { resolve: async () => ['127.0.0.1'] } },
589
+ net: { isIP: () => true }
590
+ });
591
+
592
+ const socket = await proxyConnection(logger, 'socks5://proxy.example.com:1080', '192.168.1.1', 993);
593
+
594
+ assertEarlyErrorHandler(test, socket, mockSocket);
595
+ test.done();
596
+ };
597
+
598
+ // detachEarlyErrorHandler must remove the guard once the caller takes ownership of the socket,
599
+ // so it no longer swallows/logs errors meant for the new owner's handlers.
600
+ module.exports['Proxy Connection: detachEarlyErrorHandler removes the early guard'] = async test => {
601
+ const mockSocket = createMockEmitterSocket();
602
+ const logger = createMockLogger();
603
+
604
+ const { proxyConnection, detachEarlyErrorHandler } = proxyquire('../lib/proxy-connection', {
605
+ 'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
606
+ cb(null, mockSocket);
607
+ },
608
+ socks: { SocksClient: {} },
609
+ dns: { promises: { resolve: async () => ['127.0.0.1'] } },
610
+ net: { isIP: () => true }
611
+ });
612
+
613
+ const socket = await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
614
+ test.ok(socket.listenerCount('error') >= 1, 'early error handler attached on return');
615
+ test.ok(typeof socket._earlyErrorHandler === 'function', 'handler reference stored on the socket');
616
+
617
+ detachEarlyErrorHandler(socket);
618
+
619
+ test.equal(socket.listenerCount('error'), 0, 'early error handler removed after detach');
620
+ test.equal(socket._earlyErrorHandler, null, 'stored handler reference cleared');
621
+
622
+ // Detaching again (or on a bare socket) must be a safe no-op.
623
+ test.doesNotThrow(() => detachEarlyErrorHandler(socket));
624
+ test.doesNotThrow(() => detachEarlyErrorHandler(createMockEmitterSocket()));
625
+
626
+ test.done();
627
+ };
@@ -368,3 +368,91 @@ module.exports['Reliability: decoder emit(error) does not crash when user has no
368
368
 
369
369
  test.done();
370
370
  };
371
+
372
+ // ============================================================================
373
+ // Throttle back-off timer is tracked and abortable on close()
374
+ // ============================================================================
375
+
376
+ // Feed a single throttling BAD response into reader() once, then null.
377
+ const stubThrottleResponse = (client, backoffMs) => {
378
+ let request = { tag: 'A001', command: 'FETCH', resolve: () => {}, reject: () => {} };
379
+ client.requestTagMap = new Map([['A001', request]]);
380
+
381
+ let done = false;
382
+ client.streamer.read = () => {
383
+ if (done) {
384
+ return null;
385
+ }
386
+ done = true;
387
+ return {
388
+ payload: Buffer.from(`A001 BAD Request is throttled. Suggested Backoff Time: ${backoffMs} milliseconds`),
389
+ literals: [],
390
+ next: () => {}
391
+ };
392
+ };
393
+
394
+ return request;
395
+ };
396
+
397
+ module.exports['Reliability: throttle back-off aborts promptly on close()'] = async test => {
398
+ let client = new ImapFlow({
399
+ host: 'imap.example.com',
400
+ port: 993,
401
+ auth: { user: 'test', pass: 'test' },
402
+ logger: false
403
+ });
404
+ client.socket = { destroyed: false, destroy: () => {} };
405
+ client.writeSocket = client.socket;
406
+
407
+ let rejected = null;
408
+ let request = stubThrottleResponse(client, 300000); // 5 min back-off
409
+ request.reject = err => {
410
+ rejected = err;
411
+ };
412
+
413
+ let start = Date.now();
414
+ let readerDone = client.reader().catch(() => {});
415
+
416
+ // Let reader() reach the (tracked) back-off wait.
417
+ await new Promise(r => setTimeout(r, 50));
418
+ test.ok(client._throttleTimer, 'back-off timer is tracked while waiting');
419
+
420
+ client.close();
421
+ await new Promise(r => setImmediate(r));
422
+
423
+ test.ok(rejected, 'request rejected promptly after close()');
424
+ test.equal(rejected.code, 'NoConnection', 'rejected with connection error, not ETHROTTLE');
425
+ test.equal(client._throttleTimer, null, 'throttle timer cleared on close()');
426
+ test.ok(Date.now() - start < 5000, 'settled well under the 5-minute cap');
427
+
428
+ await readerDone;
429
+ test.done();
430
+ };
431
+
432
+ module.exports['Reliability: throttle back-off still rejects ETHROTTLE on normal expiry'] = async test => {
433
+ let client = new ImapFlow({
434
+ host: 'imap.example.com',
435
+ port: 993,
436
+ auth: { user: 'test', pass: 'test' },
437
+ logger: false
438
+ });
439
+
440
+ let rejected = null;
441
+ let request = stubThrottleResponse(client, 50); // 50ms back-off
442
+ request.reject = err => {
443
+ rejected = err;
444
+ };
445
+
446
+ let readerDone = client.reader().catch(() => {});
447
+
448
+ await new Promise(r => setTimeout(r, 250));
449
+
450
+ test.ok(rejected, 'request rejected after the back-off elapses');
451
+ test.equal(rejected.code, 'ETHROTTLE', 'normal expiry still rejects ETHROTTLE');
452
+ test.equal(rejected.throttleReset, 50, 'throttleReset preserved');
453
+ test.equal(client._throttleTimer, null, 'throttle timer cleared after normal expiry');
454
+
455
+ await readerDone;
456
+ client.close();
457
+ test.done();
458
+ };
@@ -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
+ };