imapflow 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/CLAUDE.md +1 -1
- package/lib/commands/idle.js +197 -104
- package/lib/commands/list.js +15 -7
- package/lib/commands/quota.js +3 -0
- package/lib/commands/select.js +5 -0
- package/lib/commands/status.js +4 -0
- package/lib/connection-deadline.js +98 -0
- package/lib/handler/imap-compiler.js +8 -5
- package/lib/handler/imap-stream.js +141 -50
- package/lib/handler/limits.js +43 -0
- package/lib/handler/token-parser.js +31 -1
- package/lib/imap-flow.d.ts +33 -5
- package/lib/imap-flow.js +575 -281
- package/lib/proxy-connection.js +393 -98
- package/lib/special-use.js +660 -51
- package/lib/tools.js +17 -0
- package/package.json +2 -2
- package/test/commands-branches-test.js +17 -1
- package/test/commands-integration-test.js +24 -2
- package/test/fixtures/fake-timers.js +115 -0
- package/test/handler-branches-test.js +0 -25
- package/test/idle-polling-test.js +349 -0
- package/test/imap-flow-compress-test.js +12 -0
- package/test/imap-flow-coverage-test.js +3 -3
- package/test/imap-flow-internals-test.js +23 -0
- package/test/imap-flow-proxy-paths-test.js +151 -0
- package/test/imap-flow-secure-test.js +159 -0
- package/test/imap-flow-server-test.js +149 -0
- package/test/parser-limits-test.js +274 -0
- package/test/proxy-connection-test.js +553 -442
- package/test/reliability-improvements-test.js +87 -0
- package/test/special-use-test.js +337 -0
- package/test/tag-correlation-test.js +333 -0
- package/test/timer-policy-test.js +214 -0
package/lib/proxy-connection.js
CHANGED
|
@@ -1,20 +1,67 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const httpProxyClient = require('nodemailer/lib/smtp-connection/http-proxy-client');
|
|
4
3
|
const { SocksClient } = require('socks');
|
|
5
|
-
const util = require('util');
|
|
6
|
-
const httpProxyClientAsync = util.promisify(httpProxyClient);
|
|
7
4
|
const dns = require('dns').promises;
|
|
8
5
|
const net = require('net');
|
|
6
|
+
const tls = require('tls');
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
const { ConnectionDeadline } = require('./connection-deadline');
|
|
9
|
+
|
|
10
|
+
// Cap the CONNECT response buffered before the header terminator, so a proxy that never sends
|
|
11
|
+
// \r\n\r\n cannot grow memory without bound.
|
|
12
|
+
const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
|
|
13
|
+
|
|
14
|
+
const DEFAULT_SOCKS_PORT = 1080;
|
|
15
|
+
|
|
16
|
+
// URL hostnames keep the brackets around an IPv6 literal ("[2001:db8::1]"), which is neither a
|
|
17
|
+
// valid input for net.isIP() nor an address net/tls/socks can connect to. Strip them for socket
|
|
18
|
+
// options; the parsed URL itself stays intact for logging and credentials.
|
|
19
|
+
const unbracketAddress = host => (typeof host === 'string' && host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host);
|
|
20
|
+
|
|
21
|
+
// CONNECT request lines and Host headers need an IPv6 destination wrapped in brackets. Hostnames
|
|
22
|
+
// and IPv4 literals are used as-is, and an already bracketed literal is not bracketed twice.
|
|
23
|
+
const formatAuthority = (host, port) => {
|
|
24
|
+
let address = unbracketAddress(host);
|
|
25
|
+
return net.isIPv6(address) ? `[${address}]:${port}` : `${address}:${port}`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Password-free rendering of the proxy URL, used in every log path. The caller's URL object is
|
|
29
|
+
// left untouched so credentials stay available for authentication.
|
|
30
|
+
const redactUrl = proxyUrl => {
|
|
31
|
+
let redacted = new URL(proxyUrl.href);
|
|
32
|
+
if (redacted.password) {
|
|
33
|
+
redacted.password = '(hidden)';
|
|
34
|
+
}
|
|
35
|
+
return redacted.href;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const proxyError = (message, code) => {
|
|
39
|
+
let err = new Error(message);
|
|
40
|
+
err.code = code || 'ProxyError';
|
|
41
|
+
return err;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// URL userinfo is percent-encoded, so it has to be decoded before it can be used as credentials.
|
|
45
|
+
// A password containing a bare '%' is not valid percent-encoding and makes decodeURIComponent
|
|
46
|
+
// throw, so such values are used as they came in rather than failing the connection.
|
|
47
|
+
const decodeUserInfo = value => {
|
|
48
|
+
try {
|
|
49
|
+
return decodeURIComponent(value);
|
|
50
|
+
} catch {
|
|
51
|
+
return value;
|
|
15
52
|
}
|
|
16
53
|
};
|
|
17
54
|
|
|
55
|
+
// The socks client attaches its full options object - proxy password included - to the errors it
|
|
56
|
+
// throws. Any logger that serializes error properties would then write that password out in clear
|
|
57
|
+
// text, so the credentials are dropped before the error is logged or handed to the caller.
|
|
58
|
+
const stripProxyCredentials = err => {
|
|
59
|
+
if (err && typeof err === 'object' && err.options) {
|
|
60
|
+
delete err.options;
|
|
61
|
+
}
|
|
62
|
+
return err;
|
|
63
|
+
};
|
|
64
|
+
|
|
18
65
|
// Attaches a benign 'error' listener as soon as the proxied socket exists, so an early
|
|
19
66
|
// socket error (before ImapFlow installs its own handlers) cannot surface as an unhandled
|
|
20
67
|
// 'error' event and crash the process. The handler is stored on the socket so the caller
|
|
@@ -38,113 +85,361 @@ const detachEarlyErrorHandler = socket => {
|
|
|
38
85
|
}
|
|
39
86
|
};
|
|
40
87
|
|
|
41
|
-
|
|
42
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Establishes a tunnel through an HTTP or HTTPS proxy with a CONNECT request.
|
|
90
|
+
*
|
|
91
|
+
* ImapFlow owns this instead of using a bundled helper because the connection-wide deadline has
|
|
92
|
+
* to apply here: the socket is retained as soon as it exists, so an expiry destroys the in-flight
|
|
93
|
+
* socket immediately, and concurrent connections cannot share a single process-global timeout.
|
|
94
|
+
*
|
|
95
|
+
* The destination hostname is passed through unresolved - resolving it is the proxy's job, which
|
|
96
|
+
* is also what keeps DNS traffic off the client for HTTP proxies.
|
|
97
|
+
*
|
|
98
|
+
* @param {Object} params
|
|
99
|
+
* @param {Object} params.logger Logger instance.
|
|
100
|
+
* @param {URL} params.proxyUrl Parsed proxy URL (credentials intact).
|
|
101
|
+
* @param {Boolean} params.secureProxy Whether the proxy endpoint itself speaks TLS.
|
|
102
|
+
* @param {String} params.proxyHost Proxy endpoint host, IPv6 literals unbracketed.
|
|
103
|
+
* @param {Number} params.proxyPort Proxy endpoint port.
|
|
104
|
+
* @param {String} params.host Destination host (hostname or IP literal).
|
|
105
|
+
* @param {Number} params.port Destination port.
|
|
106
|
+
* @param {ConnectionDeadline} params.deadline Shared connection deadline.
|
|
107
|
+
* @returns {Promise<Object>} The established socket, tunnelled to the destination.
|
|
108
|
+
*/
|
|
109
|
+
const httpConnect = async ({ logger, proxyUrl, secureProxy, proxyHost, proxyPort, host, port, deadline }) => {
|
|
110
|
+
// Reject CRLF in the destination before it reaches the CONNECT request line and Host header.
|
|
111
|
+
// A tainted host/port could otherwise inject additional headers (HTTP request splitting).
|
|
112
|
+
let destinationPort = Number(port) || 0;
|
|
113
|
+
if (!destinationPort || /[\r\n]/.test(host)) {
|
|
114
|
+
throw proxyError('Invalid proxy destination', 'EPROXY');
|
|
115
|
+
}
|
|
43
116
|
|
|
44
|
-
let
|
|
117
|
+
let authority = formatAuthority(host, destinationPort);
|
|
45
118
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (!net.isIP(host)) {
|
|
50
|
-
let resolveResult = await dns.resolve(host);
|
|
51
|
-
if (resolveResult && resolveResult.length) {
|
|
52
|
-
host = resolveResult[0];
|
|
53
|
-
}
|
|
119
|
+
let remaining = deadline.remaining();
|
|
120
|
+
if (!remaining) {
|
|
121
|
+
throw deadline.error();
|
|
54
122
|
}
|
|
55
123
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
124
|
+
let socket = null;
|
|
125
|
+
|
|
126
|
+
return await new Promise((resolve, reject) => {
|
|
127
|
+
let settled = false;
|
|
128
|
+
let timer = null;
|
|
129
|
+
let headers = '';
|
|
130
|
+
|
|
131
|
+
const onSocketData = chunk => {
|
|
132
|
+
// Scan only the newly arrived bytes (plus the 3 that a terminator could straddle),
|
|
133
|
+
// so a proxy that dribbles its headers cannot turn this into a quadratic rescan.
|
|
134
|
+
let searchFrom = Math.max(0, headers.length - 3);
|
|
135
|
+
headers += chunk.toString('binary');
|
|
136
|
+
|
|
137
|
+
let terminator = headers.indexOf('\r\n\r\n', searchFrom);
|
|
138
|
+
if (terminator < 0) {
|
|
139
|
+
if (headers.length > MAX_RESPONSE_HEADER_BYTES) {
|
|
140
|
+
fail(proxyError('Proxy response headers too large', 'EPROXY'));
|
|
71
141
|
}
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The header block is complete, so this listener must stop consuming before anything
|
|
146
|
+
// is put back: unshifting while still subscribed re-emits the data straight back into
|
|
147
|
+
// this handler, which would swallow it. Pausing hands the socket over cleanly - the
|
|
148
|
+
// next owner resumes it (ImapFlow pipes it into the parser).
|
|
149
|
+
socket.removeListener('data', onSocketData);
|
|
150
|
+
socket.pause();
|
|
151
|
+
|
|
152
|
+
// Anything after the header terminator already belongs to the tunnelled stream (a
|
|
153
|
+
// server greeting that the proxy coalesced with its own response) and has to be
|
|
154
|
+
// preserved for the next consumer. It is put back as the original bytes, taken from
|
|
155
|
+
// this chunk rather than round-tripped through a string.
|
|
156
|
+
let headerBytes = terminator + 4;
|
|
157
|
+
let consumedFromChunk = chunk.length - (headers.length - headerBytes);
|
|
158
|
+
if (consumedFromChunk < chunk.length) {
|
|
159
|
+
socket.unshift(chunk.subarray(consumedFromChunk));
|
|
83
160
|
}
|
|
161
|
+
headers = headers.slice(0, terminator);
|
|
162
|
+
|
|
163
|
+
let status = headers.match(/^HTTP\/\d+\.\d+ (\d+)/i);
|
|
164
|
+
if (!status || (status[1] || '').charAt(0) !== '2') {
|
|
165
|
+
return fail(proxyError(`Invalid response from proxy${status ? `: ${status[1]}` : ''}`, 'EPROXY'));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
succeed();
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Single settlement path: temporary listeners and the deadline timer are dropped exactly
|
|
172
|
+
// once, so a late socket event cannot settle the promise twice or leave a timer armed.
|
|
173
|
+
const cleanup = () => {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
timer = null;
|
|
176
|
+
if (socket) {
|
|
177
|
+
// Every temporary listener goes, the connect callback included: after settlement
|
|
178
|
+
// no socket event may run any of this again.
|
|
179
|
+
socket.removeListener('connect', onConnected);
|
|
180
|
+
socket.removeListener('data', onSocketData);
|
|
181
|
+
socket.removeListener('error', fail);
|
|
182
|
+
socket.removeListener('close', onEarlyClose);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
function fail(err) {
|
|
187
|
+
if (settled) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
settled = true;
|
|
191
|
+
cleanup();
|
|
192
|
+
if (socket) {
|
|
193
|
+
socket.destroy();
|
|
194
|
+
}
|
|
195
|
+
reject(err);
|
|
84
196
|
}
|
|
85
197
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
198
|
+
function succeed() {
|
|
199
|
+
settled = true;
|
|
200
|
+
cleanup();
|
|
201
|
+
resolve(socket);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function onEarlyClose() {
|
|
205
|
+
fail(proxyError('Proxy closed the connection before the tunnel was established', 'EPROXY'));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
timer = setTimeout(() => fail(deadline.error()), remaining);
|
|
209
|
+
|
|
210
|
+
let connectOptions = { host: proxyHost, port: proxyPort };
|
|
211
|
+
if (secureProxy) {
|
|
212
|
+
// Verify the proxy's certificate (Node default) and target SNI plus hostname
|
|
213
|
+
// verification at the proxy endpoint rather than the IMAP destination. An IP-literal
|
|
214
|
+
// endpoint gets no servername, which would be an invalid SNI value.
|
|
215
|
+
if (!net.isIP(proxyHost)) {
|
|
216
|
+
connectOptions.servername = proxyHost;
|
|
102
217
|
}
|
|
218
|
+
}
|
|
103
219
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
destination: {
|
|
111
|
-
host,
|
|
112
|
-
port
|
|
113
|
-
},
|
|
114
|
-
command: 'connect',
|
|
115
|
-
set_tcp_nodelay: true
|
|
220
|
+
// Declared as a function so cleanup() above can detach it (the connect callback is
|
|
221
|
+
// registered as a one-shot 'connect' listener by net/tls).
|
|
222
|
+
function onConnected() {
|
|
223
|
+
let requestHeaders = {
|
|
224
|
+
Host: authority,
|
|
225
|
+
Connection: 'close'
|
|
116
226
|
};
|
|
117
227
|
|
|
118
228
|
if (proxyUrl.username || proxyUrl.password) {
|
|
119
|
-
|
|
120
|
-
|
|
229
|
+
let credentials = `${decodeUserInfo(proxyUrl.username)}:${decodeUserInfo(proxyUrl.password)}`;
|
|
230
|
+
requestHeaders['Proxy-Authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}`;
|
|
121
231
|
}
|
|
122
232
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
233
|
+
socket.write(
|
|
234
|
+
`CONNECT ${authority} HTTP/1.1\r\n` +
|
|
235
|
+
Object.keys(requestHeaders)
|
|
236
|
+
.map(key => `${key}: ${requestHeaders[key]}`)
|
|
237
|
+
.join('\r\n') +
|
|
238
|
+
'\r\n\r\n'
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
socket.on('data', onSocketData);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The socket is retained as soon as it is created, so an expiry can destroy it at once.
|
|
245
|
+
socket = secureProxy ? tls.connect(connectOptions, onConnected) : net.connect(connectOptions, onConnected);
|
|
246
|
+
socket.once('error', fail);
|
|
247
|
+
socket.once('close', onEarlyClose);
|
|
248
|
+
})
|
|
249
|
+
.then(established => {
|
|
250
|
+
logger.info({
|
|
251
|
+
msg: `Established a socket via HTTP proxy`,
|
|
252
|
+
proxyUrl: redactUrl(proxyUrl),
|
|
253
|
+
port,
|
|
254
|
+
host
|
|
255
|
+
});
|
|
256
|
+
attachEarlyErrorHandler(logger, established);
|
|
257
|
+
return established;
|
|
258
|
+
})
|
|
259
|
+
.catch(err => {
|
|
260
|
+
logger.error({
|
|
261
|
+
msg: 'Failed to establish a socket via HTTP proxy',
|
|
262
|
+
proxyUrl: redactUrl(proxyUrl),
|
|
263
|
+
port,
|
|
264
|
+
host,
|
|
265
|
+
err
|
|
266
|
+
});
|
|
267
|
+
throw err;
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolves a destination hostname to an IPv4 address. Only used for SOCKS4, which has no IPv6
|
|
273
|
+
* destination address type and no hostname form of its own.
|
|
274
|
+
*
|
|
275
|
+
* @param {String} hostname Destination hostname.
|
|
276
|
+
* @param {ConnectionDeadline} deadline Shared connection deadline.
|
|
277
|
+
* @returns {Promise<String>} An IPv4 address.
|
|
278
|
+
*/
|
|
279
|
+
const resolveIPv4 = async (hostname, deadline) => {
|
|
280
|
+
let addresses = await deadline.race(dns.resolve4(hostname));
|
|
281
|
+
if (!addresses || !addresses.length) {
|
|
282
|
+
throw proxyError(`Could not resolve an IPv4 address for ${hostname}`, 'EPROXY');
|
|
283
|
+
}
|
|
284
|
+
return addresses[0];
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Establishes a tunnel through a SOCKS proxy.
|
|
289
|
+
*
|
|
290
|
+
* DNS policy per protocol, because the `socks` client picks the SOCKS4 or SOCKS4a wire format from
|
|
291
|
+
* the destination value alone and offers no switch of its own:
|
|
292
|
+
* * SOCKS4 - destination hostnames are resolved locally to IPv4. Passing a hostname would
|
|
293
|
+
* silently produce a SOCKS4a request that a plain SOCKS4 proxy cannot answer.
|
|
294
|
+
* * SOCKS4a - destination hostnames are preserved for remote DNS.
|
|
295
|
+
* * SOCKS5 - destination hostnames are preserved for remote DNS, IP literals pass through.
|
|
296
|
+
* IPv6 destination literals are rejected for both SOCKS4 and SOCKS4a: neither can carry them, and
|
|
297
|
+
* the dependency would write the literal into the SOCKS4a hostname field instead.
|
|
298
|
+
*
|
|
299
|
+
* @param {Object} params
|
|
300
|
+
* @param {Object} params.logger Logger instance.
|
|
301
|
+
* @param {URL} params.proxyUrl Parsed proxy URL (credentials intact).
|
|
302
|
+
* @param {String} params.protocol Configured proxy protocol (socks, socks4, socks4a, socks5).
|
|
303
|
+
* @param {String} params.proxyHost Proxy endpoint host, IPv6 literals unbracketed.
|
|
304
|
+
* @param {Number} params.proxyPort Proxy endpoint port.
|
|
305
|
+
* @param {String} params.host Destination host (hostname or IP literal).
|
|
306
|
+
* @param {Number} params.port Destination port.
|
|
307
|
+
* @param {ConnectionDeadline} params.deadline Shared connection deadline.
|
|
308
|
+
* @returns {Promise<Object>} The established socket, tunnelled to the destination.
|
|
309
|
+
*/
|
|
310
|
+
const socksConnect = async ({ logger, proxyUrl, protocol, proxyHost, proxyPort, host, port, deadline }) => {
|
|
311
|
+
let proxyType = protocol === 'socks4' || protocol === 'socks4a' ? 4 : 5;
|
|
312
|
+
let destinationHost = unbracketAddress(host);
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
if (proxyType === 4) {
|
|
316
|
+
if (net.isIPv6(destinationHost)) {
|
|
317
|
+
throw proxyError(`SOCKS4 and SOCKS4a cannot address IPv6 destinations (${destinationHost})`, 'UnsupportedProxyAddress');
|
|
146
318
|
}
|
|
319
|
+
|
|
320
|
+
if (protocol === 'socks4' && !net.isIP(destinationHost)) {
|
|
321
|
+
destinationHost = await resolveIPv4(destinationHost, deadline);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let connectionOpts = {
|
|
326
|
+
proxy: {
|
|
327
|
+
// The endpoint is handed to net.Socket.connect() by the dependency, so a hostname
|
|
328
|
+
// is left unresolved and gets Node's normal lookup and connection behavior.
|
|
329
|
+
host: proxyHost,
|
|
330
|
+
port: proxyPort,
|
|
331
|
+
type: proxyType
|
|
332
|
+
},
|
|
333
|
+
destination: {
|
|
334
|
+
host: destinationHost,
|
|
335
|
+
port
|
|
336
|
+
},
|
|
337
|
+
command: 'connect',
|
|
338
|
+
set_tcp_nodelay: true
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
if (proxyUrl.username || proxyUrl.password) {
|
|
342
|
+
connectionOpts.proxy.userId = proxyUrl.username;
|
|
343
|
+
connectionOpts.proxy.password = proxyUrl.password;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The dependency treats a zero timeout as its own 30 second default, so only a strictly
|
|
347
|
+
// positive remaining budget may be passed.
|
|
348
|
+
let remaining = deadline.remaining();
|
|
349
|
+
if (!remaining) {
|
|
350
|
+
throw deadline.error();
|
|
351
|
+
}
|
|
352
|
+
connectionOpts.timeout = remaining;
|
|
353
|
+
|
|
354
|
+
const info = await deadline.race(SocksClient.createConnection(connectionOpts));
|
|
355
|
+
if (!info || !info.socket) {
|
|
356
|
+
throw proxyError('SOCKS proxy did not return a socket', 'EPROXY');
|
|
147
357
|
}
|
|
358
|
+
|
|
359
|
+
logger.info({
|
|
360
|
+
msg: 'Established a socket via SOCKS proxy',
|
|
361
|
+
proxyUrl: redactUrl(proxyUrl),
|
|
362
|
+
port,
|
|
363
|
+
host
|
|
364
|
+
});
|
|
365
|
+
attachEarlyErrorHandler(logger, info.socket);
|
|
366
|
+
|
|
367
|
+
return info.socket;
|
|
368
|
+
} catch (caught) {
|
|
369
|
+
// A dependency expiry and the shared deadline are reported with the same
|
|
370
|
+
// CONNECT_TIMEOUT shape, so a caller does not need to know which noticed first.
|
|
371
|
+
let err = deadline.normalize(stripProxyCredentials(caught));
|
|
372
|
+
stripProxyCredentials(err._err);
|
|
373
|
+
|
|
374
|
+
logger.error({
|
|
375
|
+
msg: 'Failed to establish a socket via SOCKS proxy',
|
|
376
|
+
proxyUrl: redactUrl(proxyUrl),
|
|
377
|
+
port,
|
|
378
|
+
host,
|
|
379
|
+
err
|
|
380
|
+
});
|
|
381
|
+
throw err;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Opens a socket to `host`:`port` through the configured proxy.
|
|
387
|
+
*
|
|
388
|
+
* @param {Object} logger Logger instance.
|
|
389
|
+
* @param {String} connectionUrl Proxy URL (http, https, socks, socks4, socks4a, socks5).
|
|
390
|
+
* @param {String} host Destination host, passed through unresolved wherever the proxy protocol
|
|
391
|
+
* can resolve it itself.
|
|
392
|
+
* @param {Number} port Destination port.
|
|
393
|
+
* @param {Object} [options]
|
|
394
|
+
* @param {ConnectionDeadline} [options.deadline] Shared connection deadline. Proxy DNS and
|
|
395
|
+
* negotiation run inside it, so a stalled proxy cannot exceed the configured connectionTimeout.
|
|
396
|
+
* @param {Number} [options.connectionTimeout] Used to build a deadline when none was passed.
|
|
397
|
+
* @returns {Promise<Object|undefined>} The tunnelled socket, or undefined for an unknown protocol.
|
|
398
|
+
*/
|
|
399
|
+
const proxyConnection = async (logger, connectionUrl, host, port, options) => {
|
|
400
|
+
options = options || {};
|
|
401
|
+
|
|
402
|
+
let deadline = options.deadline || new ConnectionDeadline(options.connectionTimeout);
|
|
403
|
+
deadline.check();
|
|
404
|
+
|
|
405
|
+
let proxyUrl = new URL(connectionUrl);
|
|
406
|
+
let protocol = proxyUrl.protocol.replace(/:$/, '').toLowerCase();
|
|
407
|
+
|
|
408
|
+
// ImapFlow performs no DNS lookup of its own for the proxy endpoint: net, tls and the SOCKS
|
|
409
|
+
// client all resolve a hostname endpoint themselves, which keeps Node's normal connection
|
|
410
|
+
// behavior (including address-family selection) instead of pinning one address.
|
|
411
|
+
let proxyHost = unbracketAddress(proxyUrl.hostname);
|
|
412
|
+
|
|
413
|
+
switch (protocol) {
|
|
414
|
+
// Connect using a HTTP CONNECT method
|
|
415
|
+
case 'http':
|
|
416
|
+
case 'https':
|
|
417
|
+
return await httpConnect({
|
|
418
|
+
logger,
|
|
419
|
+
proxyUrl,
|
|
420
|
+
secureProxy: protocol === 'https',
|
|
421
|
+
proxyHost,
|
|
422
|
+
proxyPort: Number(proxyUrl.port) || (protocol === 'https' ? 443 : 80),
|
|
423
|
+
host,
|
|
424
|
+
port,
|
|
425
|
+
deadline
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// SOCKS proxy
|
|
429
|
+
case 'socks':
|
|
430
|
+
case 'socks5':
|
|
431
|
+
case 'socks4':
|
|
432
|
+
case 'socks4a':
|
|
433
|
+
return await socksConnect({
|
|
434
|
+
logger,
|
|
435
|
+
proxyUrl,
|
|
436
|
+
protocol,
|
|
437
|
+
proxyHost,
|
|
438
|
+
proxyPort: Number(proxyUrl.port) || DEFAULT_SOCKS_PORT,
|
|
439
|
+
host,
|
|
440
|
+
port,
|
|
441
|
+
deadline
|
|
442
|
+
});
|
|
148
443
|
}
|
|
149
444
|
};
|
|
150
445
|
|