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.
- package/.github/codeql/codeql-config.yml +12 -0
- package/.github/workflows/codeql.yml +102 -0
- package/.github/workflows/stale.yml +5 -0
- package/.github/workflows/test.yml +3 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/lib/commands/starttls.js +3 -0
- package/lib/handler/imap-stream.js +23 -6
- package/lib/imap-flow.d.ts +5 -0
- package/lib/imap-flow.js +169 -23
- package/lib/proxy-connection.js +26 -1
- package/lib/tools.js +4 -0
- package/package.json +1 -1
- package/test/connection-edge-cases-test.js +138 -1
- package/test/imap-stream-edge-cases-test.js +58 -0
- package/test/proxy-connection-test.js +86 -0
- package/test/reliability-improvements-test.js +88 -0
- package/test/starttls-injection-test.js +181 -0
|
@@ -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}}"
|
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
|
|
package/lib/commands/starttls.js
CHANGED
|
@@ -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
|
-
|
|
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 >
|
|
129
|
-
const err = new Error(`Literal size ${literalSize} exceeds maximum allowed size of ${
|
|
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 =
|
|
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
|
}
|
package/lib/imap-flow.d.ts
CHANGED
|
@@ -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
|
-
|
|
693
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
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
|
package/lib/proxy-connection.js
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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
|
+
};
|