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
|
@@ -1,27 +1,80 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Proxy connection tests.
|
|
4
|
+
//
|
|
5
|
+
// ImapFlow owns HTTP/HTTPS CONNECT negotiation and applies one connection-wide deadline to DNS,
|
|
6
|
+
// proxy negotiation and the transport handshake. The DNS policy differs per proxy protocol, so
|
|
7
|
+
// these tests pin what each mode is handed:
|
|
8
|
+
// * HTTP/HTTPS - the destination hostname reaches the proxy unresolved
|
|
9
|
+
// * SOCKS4 - destination hostnames are resolved locally to IPv4
|
|
10
|
+
// * SOCKS4a - destination hostnames are preserved for remote DNS
|
|
11
|
+
// * SOCKS5 - destination hostnames are preserved, IP literals pass through
|
|
12
|
+
// and that ImapFlow never resolves a proxy endpoint itself.
|
|
13
|
+
|
|
14
|
+
const net = require('net');
|
|
4
15
|
const { EventEmitter } = require('events');
|
|
16
|
+
const proxyquire = require('proxyquire');
|
|
17
|
+
const { ConnectionDeadline } = require('../lib/connection-deadline');
|
|
18
|
+
|
|
19
|
+
// Socket stand-in for the stubbed net/tls connect calls. Tests drive it by emitting 'data'.
|
|
20
|
+
const createFakeSocket = () => {
|
|
21
|
+
const socket = new EventEmitter();
|
|
22
|
+
socket.writes = [];
|
|
23
|
+
socket.unshifted = [];
|
|
24
|
+
socket.destroyed = false;
|
|
25
|
+
socket.write = chunk => {
|
|
26
|
+
socket.writes.push(chunk.toString('binary'));
|
|
27
|
+
return true;
|
|
28
|
+
};
|
|
29
|
+
socket.unshift = chunk => socket.unshifted.push(chunk.toString('binary'));
|
|
30
|
+
socket.destroy = () => {
|
|
31
|
+
socket.destroyed = true;
|
|
32
|
+
};
|
|
33
|
+
socket.setTimeout = () => {};
|
|
34
|
+
socket.end = () => {};
|
|
35
|
+
socket.paused = false;
|
|
36
|
+
socket.pause = () => {
|
|
37
|
+
socket.paused = true;
|
|
38
|
+
return socket;
|
|
39
|
+
};
|
|
40
|
+
socket.resume = () => {
|
|
41
|
+
socket.paused = false;
|
|
42
|
+
return socket;
|
|
43
|
+
};
|
|
44
|
+
return socket;
|
|
45
|
+
};
|
|
5
46
|
|
|
6
|
-
//
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
47
|
+
// Builds a connect() stub that records its options and hands out fake sockets.
|
|
48
|
+
const createConnectStub =
|
|
49
|
+
({ calls, sockets, autoConnect = true }) =>
|
|
50
|
+
(options, onConnected) => {
|
|
51
|
+
calls.push(options);
|
|
52
|
+
const socket = createFakeSocket();
|
|
53
|
+
sockets.push(socket);
|
|
54
|
+
if (autoConnect) {
|
|
55
|
+
setImmediate(() => onConnected());
|
|
56
|
+
}
|
|
57
|
+
return socket;
|
|
58
|
+
};
|
|
16
59
|
|
|
17
|
-
//
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
60
|
+
// dns stub that fails loudly: no proxy mode may resolve an endpoint through ImapFlow itself.
|
|
61
|
+
const createDnsStub = (resolve4Result, onCall) => ({
|
|
62
|
+
promises: {
|
|
63
|
+
resolve4: async hostname => {
|
|
64
|
+
if (onCall) {
|
|
65
|
+
onCall('resolve4', hostname);
|
|
66
|
+
}
|
|
67
|
+
return resolve4Result || [];
|
|
68
|
+
},
|
|
69
|
+
resolve: async hostname => {
|
|
70
|
+
if (onCall) {
|
|
71
|
+
onCall('resolve', hostname);
|
|
72
|
+
}
|
|
73
|
+
return resolve4Result || [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
23
77
|
|
|
24
|
-
// Mock logger
|
|
25
78
|
const createMockLogger = () => {
|
|
26
79
|
const logs = { info: [], error: [] };
|
|
27
80
|
return {
|
|
@@ -31,586 +84,566 @@ const createMockLogger = () => {
|
|
|
31
84
|
};
|
|
32
85
|
};
|
|
33
86
|
|
|
87
|
+
const tick = () => new Promise(resolve => setImmediate(resolve));
|
|
88
|
+
|
|
89
|
+
// Loads proxy-connection with stubbed transports. `net` keeps its real address helpers so
|
|
90
|
+
// isIP/isIPv6 behave normally.
|
|
91
|
+
const loadProxyModule = ({ netConnect, tlsConnect, socksCreateConnection, dnsStub } = {}) =>
|
|
92
|
+
proxyquire('../lib/proxy-connection', {
|
|
93
|
+
net: Object.assign({}, net, netConnect ? { connect: netConnect } : {}),
|
|
94
|
+
tls: tlsConnect ? { connect: tlsConnect } : { connect: () => createFakeSocket() },
|
|
95
|
+
socks: { SocksClient: { createConnection: socksCreateConnection || (async () => ({ socket: createFakeSocket() })) } },
|
|
96
|
+
dns: dnsStub || createDnsStub()
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const respondOk = socket => socket.emit('data', Buffer.from('HTTP/1.1 200 Connection established\r\n\r\n'));
|
|
100
|
+
|
|
101
|
+
// Loads the module with a stubbed transport and returns everything an HTTP CONNECT test needs:
|
|
102
|
+
// the recorded connect options, the fake sockets handed out, and a capturing logger. Mirrors
|
|
103
|
+
// socksCase() further down.
|
|
104
|
+
const httpCase = ({ secureProxy = false, autoConnect = true, dnsStub } = {}) => {
|
|
105
|
+
const calls = [];
|
|
106
|
+
const sockets = [];
|
|
107
|
+
const connectStub = createConnectStub({ calls, sockets, autoConnect });
|
|
108
|
+
const { proxyConnection, detachEarlyErrorHandler } = loadProxyModule({
|
|
109
|
+
[secureProxy ? 'tlsConnect' : 'netConnect']: connectStub,
|
|
110
|
+
dnsStub
|
|
111
|
+
});
|
|
112
|
+
return { proxyConnection, detachEarlyErrorHandler, calls, sockets, logger: createMockLogger() };
|
|
113
|
+
};
|
|
114
|
+
|
|
34
115
|
// ============================================
|
|
35
|
-
// HTTP
|
|
116
|
+
// HTTP / HTTPS CONNECT
|
|
36
117
|
// ============================================
|
|
37
118
|
|
|
38
|
-
module.exports['Proxy Connection: HTTP
|
|
39
|
-
|
|
40
|
-
const logger =
|
|
41
|
-
|
|
42
|
-
const { proxyConnection } = proxyquire('../lib/proxy-connection', {
|
|
43
|
-
'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
|
|
44
|
-
cb(null, mockSocket);
|
|
45
|
-
},
|
|
46
|
-
socks: { SocksClient: {} },
|
|
47
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
48
|
-
net: { isIP: () => true }
|
|
119
|
+
module.exports['Proxy Connection: HTTP CONNECT preserves the destination hostname'] = async test => {
|
|
120
|
+
let dnsCalls = [];
|
|
121
|
+
const { proxyConnection, calls, sockets, logger } = httpCase({
|
|
122
|
+
dnsStub: createDnsStub([], (method, hostname) => dnsCalls.push([method, hostname]))
|
|
49
123
|
});
|
|
50
124
|
|
|
51
|
-
const
|
|
125
|
+
const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
|
|
126
|
+
await tick();
|
|
127
|
+
|
|
128
|
+
test.deepEqual(calls[0], { host: 'proxy.example.com', port: 8080 }, 'the proxy endpoint hostname is passed unresolved');
|
|
129
|
+
test.ok(sockets[0].writes[0].startsWith('CONNECT mail.example.com:993 HTTP/1.1\r\n'), 'the request line carries the unresolved destination');
|
|
130
|
+
test.ok(sockets[0].writes[0].includes('Host: mail.example.com:993\r\n'), 'the Host header carries the unresolved destination');
|
|
131
|
+
test.deepEqual(dnsCalls, [], 'ImapFlow performs no DNS lookup of its own for an HTTP proxy');
|
|
52
132
|
|
|
53
|
-
|
|
133
|
+
respondOk(sockets[0]);
|
|
134
|
+
const socket = await promise;
|
|
135
|
+
|
|
136
|
+
test.equal(socket, sockets[0], 'the tunnelled socket is returned');
|
|
137
|
+
test.ok(socket.listenerCount('error') >= 1, 'the returned socket carries an early error listener');
|
|
54
138
|
test.equal(logger._logs.info.length, 1);
|
|
55
139
|
test.ok(logger._logs.info[0].msg.includes('HTTP proxy'));
|
|
56
140
|
test.done();
|
|
57
141
|
};
|
|
58
142
|
|
|
59
|
-
module.exports['Proxy Connection:
|
|
60
|
-
const
|
|
61
|
-
const logger = createMockLogger();
|
|
143
|
+
module.exports['Proxy Connection: HTTP CONNECT brackets an IPv6 destination'] = async test => {
|
|
144
|
+
const { proxyConnection, sockets, logger } = httpCase();
|
|
62
145
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
cb(null, mockSocket);
|
|
66
|
-
},
|
|
67
|
-
socks: { SocksClient: {} },
|
|
68
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
69
|
-
net: { isIP: () => true }
|
|
70
|
-
});
|
|
146
|
+
const promise = proxyConnection(logger, 'http://proxy.example.com:8080', '2001:db8::5', 993);
|
|
147
|
+
await tick();
|
|
71
148
|
|
|
72
|
-
const
|
|
149
|
+
const request = sockets[0].writes[0];
|
|
150
|
+
test.ok(request.startsWith('CONNECT [2001:db8::5]:993 HTTP/1.1\r\n'), 'the request line uses a bracketed authority');
|
|
151
|
+
test.ok(request.includes('Host: [2001:db8::5]:993\r\n'), 'the Host header uses a bracketed authority');
|
|
73
152
|
|
|
74
|
-
|
|
75
|
-
|
|
153
|
+
respondOk(sockets[0]);
|
|
154
|
+
await promise;
|
|
76
155
|
test.done();
|
|
77
156
|
};
|
|
78
157
|
|
|
79
|
-
module.exports['Proxy Connection: HTTP
|
|
80
|
-
const
|
|
81
|
-
const logger = createMockLogger();
|
|
158
|
+
module.exports['Proxy Connection: HTTP CONNECT does not double-bracket an IPv6 destination'] = async test => {
|
|
159
|
+
const { proxyConnection, sockets, logger } = httpCase();
|
|
82
160
|
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
cb(null, mockSocket);
|
|
86
|
-
},
|
|
87
|
-
socks: { SocksClient: {} },
|
|
88
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
89
|
-
net: { isIP: () => true }
|
|
90
|
-
});
|
|
161
|
+
const promise = proxyConnection(logger, 'http://proxy.example.com:8080', '[2001:db8::5]', 993);
|
|
162
|
+
await tick();
|
|
91
163
|
|
|
92
|
-
|
|
164
|
+
test.ok(sockets[0].writes[0].startsWith('CONNECT [2001:db8::5]:993 HTTP/1.1\r\n'), 'an already bracketed literal stays single-bracketed');
|
|
93
165
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
test.ok(logger._logs.info[0].proxyUrl.includes('(hidden)'));
|
|
166
|
+
respondOk(sockets[0]);
|
|
167
|
+
await promise;
|
|
97
168
|
test.done();
|
|
98
169
|
};
|
|
99
170
|
|
|
100
|
-
module.exports['Proxy Connection: HTTP proxy
|
|
101
|
-
const logger =
|
|
102
|
-
const testError = new Error('Connection refused');
|
|
171
|
+
module.exports['Proxy Connection: HTTP proxy endpoint given as an IPv6 URL uses a bare literal'] = async test => {
|
|
172
|
+
const { proxyConnection, calls, sockets, logger } = httpCase();
|
|
103
173
|
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
cb(testError);
|
|
107
|
-
},
|
|
108
|
-
socks: { SocksClient: {} },
|
|
109
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
110
|
-
net: { isIP: () => true }
|
|
111
|
-
});
|
|
174
|
+
const promise = proxyConnection(logger, 'http://[2001:db8::1]:8080', 'mail.example.com', 993);
|
|
175
|
+
await tick();
|
|
112
176
|
|
|
113
|
-
|
|
114
|
-
await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
|
|
115
|
-
test.ok(false, 'Should have thrown');
|
|
116
|
-
} catch (err) {
|
|
117
|
-
test.equal(err, testError);
|
|
118
|
-
test.equal(logger._logs.error.length, 1);
|
|
119
|
-
test.ok(logger._logs.error[0].msg.includes('Failed'));
|
|
120
|
-
}
|
|
177
|
+
test.equal(calls[0].host, '2001:db8::1', 'socket options get the bare literal, not the bracketed URL form');
|
|
121
178
|
|
|
179
|
+
respondOk(sockets[0]);
|
|
180
|
+
await promise;
|
|
181
|
+
|
|
182
|
+
test.ok(logger._logs.info[0].proxyUrl.includes('[2001:db8::1]'), 'logs keep a valid bracketed URL');
|
|
122
183
|
test.done();
|
|
123
184
|
};
|
|
124
185
|
|
|
125
|
-
module.exports['Proxy Connection:
|
|
126
|
-
|
|
186
|
+
module.exports['Proxy Connection: HTTPS proxy sets SNI for a hostname endpoint only'] = async test => {
|
|
187
|
+
let { proxyConnection, calls: hostnameCalls, sockets: hostnameSockets, logger } = httpCase({ secureProxy: true });
|
|
127
188
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
cb(new Error('Failed'));
|
|
131
|
-
},
|
|
132
|
-
socks: { SocksClient: {} },
|
|
133
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
134
|
-
net: { isIP: () => true }
|
|
135
|
-
});
|
|
189
|
+
let promise = proxyConnection(logger, 'https://proxy.example.com:8443', 'mail.example.com', 993);
|
|
190
|
+
await tick();
|
|
136
191
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
192
|
+
test.equal(hostnameCalls[0].servername, 'proxy.example.com', 'SNI and hostname verification target the proxy, not the IMAP destination');
|
|
193
|
+
respondOk(hostnameSockets[0]);
|
|
194
|
+
await promise;
|
|
195
|
+
|
|
196
|
+
let { proxyConnection: connectViaLiteral, calls: literalCalls, sockets: literalSockets } = httpCase({ secureProxy: true });
|
|
197
|
+
promise = connectViaLiteral(logger, 'https://10.0.0.1:8443', 'mail.example.com', 993);
|
|
198
|
+
await tick();
|
|
199
|
+
|
|
200
|
+
test.equal(literalCalls[0].servername, undefined, 'an IP-literal endpoint gets no servername');
|
|
201
|
+
test.equal(literalCalls[0].host, '10.0.0.1');
|
|
202
|
+
respondOk(literalSockets[0]);
|
|
203
|
+
await promise;
|
|
145
204
|
|
|
146
205
|
test.done();
|
|
147
206
|
};
|
|
148
207
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
// ============================================
|
|
208
|
+
module.exports['Proxy Connection: HTTP CONNECT sends Basic credentials and keeps them out of logs'] = async test => {
|
|
209
|
+
const { proxyConnection, sockets, logger } = httpCase();
|
|
152
210
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const logger = createMockLogger();
|
|
211
|
+
const promise = proxyConnection(logger, 'http://user:secret123@proxy.example.com:8080', 'mail.example.com', 993);
|
|
212
|
+
await tick();
|
|
156
213
|
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
test.equal(opts.proxy.type, 5);
|
|
163
|
-
test.equal(opts.command, 'connect');
|
|
164
|
-
return { socket: mockSocket };
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
},
|
|
168
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
169
|
-
net: { isIP: () => true }
|
|
170
|
-
});
|
|
214
|
+
const request = sockets[0].writes[0];
|
|
215
|
+
const authHeader = request.match(/Proxy-Authorization: Basic (\S+)/);
|
|
216
|
+
test.ok(authHeader, 'credentials are sent in the Proxy-Authorization header');
|
|
217
|
+
test.equal(Buffer.from(authHeader[1], 'base64').toString(), 'user:secret123');
|
|
218
|
+
test.ok(!request.includes('secret123'), 'credentials appear only base64 encoded, never in clear text');
|
|
171
219
|
|
|
172
|
-
|
|
220
|
+
respondOk(sockets[0]);
|
|
221
|
+
await promise;
|
|
173
222
|
|
|
174
|
-
test.
|
|
175
|
-
test.
|
|
176
|
-
test.ok(logger._logs.info[0].msg.includes('SOCKS proxy'));
|
|
223
|
+
test.ok(!logger._logs.info[0].proxyUrl.includes('secret123'), 'the success log is redacted');
|
|
224
|
+
test.ok(logger._logs.info[0].proxyUrl.includes('(hidden)'));
|
|
177
225
|
test.done();
|
|
178
226
|
};
|
|
179
227
|
|
|
180
|
-
module.exports['Proxy Connection:
|
|
181
|
-
const
|
|
182
|
-
const logger = createMockLogger();
|
|
228
|
+
module.exports['Proxy Connection: HTTP CONNECT rejects a non-2xx response and redacts the log'] = async test => {
|
|
229
|
+
const { proxyConnection, sockets, logger } = httpCase();
|
|
183
230
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
SocksClient: {
|
|
188
|
-
createConnection: async opts => {
|
|
189
|
-
test.equal(opts.proxy.type, 5); // Default to SOCKS5
|
|
190
|
-
return { socket: mockSocket };
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
},
|
|
194
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
195
|
-
net: { isIP: () => true }
|
|
196
|
-
});
|
|
231
|
+
const promise = proxyConnection(logger, 'http://user:secret123@proxy.example.com:8080', 'mail.example.com', 993);
|
|
232
|
+
await tick();
|
|
233
|
+
sockets[0].emit('data', Buffer.from('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n'));
|
|
197
234
|
|
|
198
|
-
await
|
|
235
|
+
let err = await promise.then(() => null).catch(e => e);
|
|
236
|
+
test.ok(err, 'a refused CONNECT rejects');
|
|
237
|
+
test.equal(err.code, 'EPROXY');
|
|
238
|
+
test.ok(/407/.test(err.message), 'the status code is reported');
|
|
239
|
+
test.ok(sockets[0].destroyed, 'the socket is destroyed');
|
|
240
|
+
test.ok(!logger._logs.error[0].proxyUrl.includes('secret123'), 'the failure log is redacted');
|
|
241
|
+
test.ok(logger._logs.error[0].proxyUrl.includes('(hidden)'));
|
|
199
242
|
test.done();
|
|
200
243
|
};
|
|
201
244
|
|
|
202
|
-
module.exports['Proxy Connection:
|
|
203
|
-
const
|
|
204
|
-
const logger = createMockLogger();
|
|
245
|
+
module.exports['Proxy Connection: HTTP CONNECT bounds the response header buffer'] = async test => {
|
|
246
|
+
const { proxyConnection, sockets, logger } = httpCase();
|
|
205
247
|
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
socks: {
|
|
209
|
-
SocksClient: {
|
|
210
|
-
createConnection: async opts => {
|
|
211
|
-
test.equal(opts.proxy.type, 4);
|
|
212
|
-
return { socket: mockSocket };
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
},
|
|
216
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
217
|
-
net: { isIP: () => true }
|
|
218
|
-
});
|
|
248
|
+
const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
|
|
249
|
+
await tick();
|
|
219
250
|
|
|
220
|
-
|
|
251
|
+
// A proxy that never sends the header terminator must not grow memory without bound
|
|
252
|
+
for (let i = 0; i < 10 && !sockets[0].destroyed; i++) {
|
|
253
|
+
sockets[0].emit('data', Buffer.alloc(16 * 1024, 0x41));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let err = await promise.then(() => null).catch(e => e);
|
|
257
|
+
test.ok(err, 'the oversized header response rejects');
|
|
258
|
+
test.equal(err.code, 'EPROXY');
|
|
259
|
+
test.ok(/headers too large/i.test(err.message));
|
|
260
|
+
test.ok(sockets[0].destroyed, 'the socket is destroyed');
|
|
221
261
|
test.done();
|
|
222
262
|
};
|
|
223
263
|
|
|
224
|
-
module.exports['Proxy Connection:
|
|
225
|
-
const
|
|
226
|
-
const logger = createMockLogger();
|
|
264
|
+
module.exports['Proxy Connection: HTTP CONNECT preserves bytes after the header terminator'] = async test => {
|
|
265
|
+
const { proxyConnection, sockets, logger } = httpCase();
|
|
227
266
|
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
test.equal(opts.proxy.type, 4);
|
|
234
|
-
return { socket: mockSocket };
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
},
|
|
238
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
239
|
-
net: { isIP: () => true }
|
|
240
|
-
});
|
|
267
|
+
const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
|
|
268
|
+
await tick();
|
|
269
|
+
|
|
270
|
+
// The destination greeting arrives in the same segment as the CONNECT response
|
|
271
|
+
sockets[0].emit('data', Buffer.from('HTTP/1.1 200 OK\r\n\r\n* OK [CAPABILITY IMAP4rev1] ready\r\n'));
|
|
241
272
|
|
|
242
|
-
|
|
273
|
+
const socket = await promise;
|
|
274
|
+
test.deepEqual(socket.unshifted, ['* OK [CAPABILITY IMAP4rev1] ready\r\n'], 'trailing bytes are pushed back for the next consumer');
|
|
243
275
|
test.done();
|
|
244
276
|
};
|
|
245
277
|
|
|
246
|
-
module.exports['Proxy Connection:
|
|
247
|
-
const
|
|
248
|
-
const logger = createMockLogger();
|
|
278
|
+
module.exports['Proxy Connection: HTTP CONNECT rejects a destination with CRLF'] = async test => {
|
|
279
|
+
const { proxyConnection, calls, logger } = httpCase();
|
|
249
280
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
SocksClient: {
|
|
254
|
-
createConnection: async opts => {
|
|
255
|
-
test.equal(opts.proxy.userId, 'testuser');
|
|
256
|
-
test.equal(opts.proxy.password, 'testpass');
|
|
257
|
-
return { socket: mockSocket };
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
},
|
|
261
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
262
|
-
net: { isIP: () => true }
|
|
263
|
-
});
|
|
281
|
+
let err = await proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com\r\nX-Injected: 1', 993)
|
|
282
|
+
.then(() => null)
|
|
283
|
+
.catch(e => e);
|
|
264
284
|
|
|
265
|
-
|
|
285
|
+
test.ok(err, 'header injection through the destination is rejected');
|
|
286
|
+
test.equal(err.code, 'EPROXY');
|
|
287
|
+
test.deepEqual(calls, [], 'no socket is opened for an invalid destination');
|
|
266
288
|
test.done();
|
|
267
289
|
};
|
|
268
290
|
|
|
269
|
-
module.exports['Proxy Connection:
|
|
270
|
-
const
|
|
271
|
-
const logger = createMockLogger();
|
|
291
|
+
module.exports['Proxy Connection: HTTP CONNECT rejects an invalid destination port'] = async test => {
|
|
292
|
+
const { proxyConnection, logger } = httpCase();
|
|
272
293
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
net: { isIP: () => true }
|
|
282
|
-
});
|
|
294
|
+
let err = await proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 0)
|
|
295
|
+
.then(() => null)
|
|
296
|
+
.catch(e => e);
|
|
297
|
+
|
|
298
|
+
test.ok(err, 'a missing destination port is rejected');
|
|
299
|
+
test.equal(err.code, 'EPROXY');
|
|
300
|
+
test.done();
|
|
301
|
+
};
|
|
283
302
|
|
|
284
|
-
|
|
303
|
+
module.exports['Proxy Connection: an early socket close rejects the tunnel'] = async test => {
|
|
304
|
+
const { proxyConnection, sockets, logger } = httpCase();
|
|
285
305
|
|
|
286
|
-
|
|
287
|
-
|
|
306
|
+
const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
|
|
307
|
+
await tick();
|
|
308
|
+
sockets[0].emit('close');
|
|
309
|
+
|
|
310
|
+
let err = await promise.then(() => null).catch(e => e);
|
|
311
|
+
test.ok(err, 'a proxy that hangs up before responding rejects');
|
|
312
|
+
test.equal(err.code, 'EPROXY');
|
|
288
313
|
test.done();
|
|
289
314
|
};
|
|
290
315
|
|
|
291
|
-
module.exports['Proxy Connection:
|
|
292
|
-
const
|
|
293
|
-
const logger = createMockLogger();
|
|
316
|
+
module.exports['Proxy Connection: a late socket event cannot settle the tunnel twice'] = async test => {
|
|
317
|
+
const { proxyConnection, sockets, logger } = httpCase();
|
|
294
318
|
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
createConnection: async opts => {
|
|
300
|
-
test.equal(opts.proxy.port, 1080); // Default SOCKS port
|
|
301
|
-
return { socket: mockSocket };
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
},
|
|
305
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
306
|
-
net: { isIP: () => true }
|
|
307
|
-
});
|
|
319
|
+
const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
|
|
320
|
+
await tick();
|
|
321
|
+
respondOk(sockets[0]);
|
|
322
|
+
const socket = await promise;
|
|
308
323
|
|
|
309
|
-
|
|
324
|
+
// Late events after settlement: the temporary listeners are gone, so nothing re-settles and
|
|
325
|
+
// nothing throws. Only the early error guard remains.
|
|
326
|
+
test.equal(socket.listenerCount('close'), 0, 'the temporary close listener was removed');
|
|
327
|
+
test.doesNotThrow(() => socket.emit('error', new Error('late error')), 'a late error is absorbed by the early error guard');
|
|
328
|
+
test.doesNotThrow(() => socket.emit('data', Buffer.from('HTTP/1.1 500 late\r\n\r\n')), 'late data is no longer parsed as a CONNECT response');
|
|
329
|
+
test.equal(logger._logs.error.length, 1, 'the late error was logged once by the early guard, not reported as a proxy failure');
|
|
310
330
|
test.done();
|
|
311
331
|
};
|
|
312
332
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
333
|
+
// ============================================
|
|
334
|
+
// Connection deadline
|
|
335
|
+
// ============================================
|
|
316
336
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
socks: {
|
|
320
|
-
SocksClient: {
|
|
321
|
-
createConnection: async () => {
|
|
322
|
-
throw testError;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
},
|
|
326
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
327
|
-
net: { isIP: () => true }
|
|
328
|
-
});
|
|
337
|
+
module.exports['Proxy Connection: HTTP CONNECT timeout destroys the in-flight socket'] = async test => {
|
|
338
|
+
const { proxyConnection, sockets, logger } = httpCase();
|
|
329
339
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
test.ok(false, 'Should have thrown');
|
|
333
|
-
} catch (err) {
|
|
334
|
-
test.equal(err, testError);
|
|
335
|
-
test.equal(logger._logs.error.length, 1);
|
|
336
|
-
test.ok(logger._logs.error[0].msg.includes('Failed'));
|
|
337
|
-
}
|
|
340
|
+
// The proxy accepts the connection and never answers the CONNECT request
|
|
341
|
+
const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993, { connectionTimeout: 60 });
|
|
338
342
|
|
|
343
|
+
let err = await promise.then(() => null).catch(e => e);
|
|
344
|
+
test.ok(err, 'the stalled CONNECT rejects');
|
|
345
|
+
test.equal(err.code, 'CONNECT_TIMEOUT', 'a proxy phase expiry uses the shared connection timeout code');
|
|
346
|
+
test.equal(err.details.connectionTimeout, 60, 'the configured timeout is reported');
|
|
347
|
+
test.ok(sockets[0].destroyed, 'the in-flight socket is destroyed immediately');
|
|
339
348
|
test.done();
|
|
340
349
|
};
|
|
341
350
|
|
|
342
|
-
module.exports['Proxy Connection:
|
|
343
|
-
|
|
351
|
+
module.exports['Proxy Connection: a stalled endpoint connection still hits the deadline'] = async test => {
|
|
352
|
+
// autoConnect false: the dependency-level connection never completes
|
|
353
|
+
const { proxyConnection, sockets, logger } = httpCase({ autoConnect: false });
|
|
344
354
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
SocksClient: {
|
|
349
|
-
createConnection: async () => {
|
|
350
|
-
throw new Error('Failed');
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
},
|
|
354
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
355
|
-
net: { isIP: () => true }
|
|
356
|
-
});
|
|
355
|
+
let err = await proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993, { connectionTimeout: 60 })
|
|
356
|
+
.then(() => null)
|
|
357
|
+
.catch(e => e);
|
|
357
358
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
359
|
+
test.ok(err, 'a connection that never establishes rejects');
|
|
360
|
+
test.equal(err.code, 'CONNECT_TIMEOUT');
|
|
361
|
+
test.ok(sockets[0].destroyed, 'the shared deadline destroys the stalled socket');
|
|
362
|
+
test.done();
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
module.exports['Proxy Connection: concurrent HTTP deadlines are independent'] = async test => {
|
|
366
|
+
const { proxyConnection, sockets, logger } = httpCase();
|
|
367
|
+
|
|
368
|
+
// A short-deadline connection that stalls and a long-deadline connection that succeeds
|
|
369
|
+
const stalled = proxyConnection(logger, 'http://proxy.example.com:8080', 'stalled.example.com', 993, { connectionTimeout: 60 })
|
|
370
|
+
.then(() => null)
|
|
371
|
+
.catch(e => e);
|
|
372
|
+
const healthy = proxyConnection(logger, 'http://proxy.example.com:8080', 'healthy.example.com', 993, { connectionTimeout: 5000 });
|
|
373
|
+
|
|
374
|
+
await tick();
|
|
375
|
+
test.equal(sockets.length, 2, 'both connections opened their own socket');
|
|
376
|
+
|
|
377
|
+
let err = await stalled;
|
|
378
|
+
test.equal(err.code, 'CONNECT_TIMEOUT', 'the short deadline expired on its own connection');
|
|
379
|
+
test.ok(sockets[0].destroyed, 'only the stalled socket was destroyed');
|
|
380
|
+
test.ok(!sockets[1].destroyed, 'the other connection is untouched by the expired deadline');
|
|
381
|
+
|
|
382
|
+
respondOk(sockets[1]);
|
|
383
|
+
const socket = await healthy;
|
|
384
|
+
test.equal(socket, sockets[1], 'the longer deadline still completes normally');
|
|
385
|
+
test.done();
|
|
386
|
+
};
|
|
366
387
|
|
|
388
|
+
module.exports['Proxy Connection: an exhausted deadline rejects before any work starts'] = async test => {
|
|
389
|
+
const { proxyConnection, calls, logger } = httpCase();
|
|
390
|
+
|
|
391
|
+
const deadline = new ConnectionDeadline(50);
|
|
392
|
+
await new Promise(resolve => setTimeout(resolve, 80));
|
|
393
|
+
|
|
394
|
+
let err = await proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993, { deadline })
|
|
395
|
+
.then(() => null)
|
|
396
|
+
.catch(e => e);
|
|
397
|
+
|
|
398
|
+
test.ok(err, 'no phase is started once the budget is gone');
|
|
399
|
+
test.equal(err.code, 'CONNECT_TIMEOUT');
|
|
400
|
+
test.deepEqual(calls, [], 'no socket was opened');
|
|
367
401
|
test.done();
|
|
368
402
|
};
|
|
369
403
|
|
|
370
404
|
// ============================================
|
|
371
|
-
//
|
|
405
|
+
// SOCKS
|
|
372
406
|
// ============================================
|
|
373
407
|
|
|
374
|
-
|
|
375
|
-
const
|
|
408
|
+
const socksCase = async ({ proxyUrl, host, port = 993, resolve4Result, dnsCalls }) => {
|
|
409
|
+
const options = [];
|
|
376
410
|
const logger = createMockLogger();
|
|
377
|
-
|
|
411
|
+
const socket = createFakeSocket();
|
|
378
412
|
|
|
379
|
-
const { proxyConnection } =
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
413
|
+
const { proxyConnection } = loadProxyModule({
|
|
414
|
+
socksCreateConnection: async opts => {
|
|
415
|
+
options.push(opts);
|
|
416
|
+
return { socket };
|
|
383
417
|
},
|
|
384
|
-
|
|
385
|
-
dns: {
|
|
386
|
-
promises: {
|
|
387
|
-
resolve: async hostname => {
|
|
388
|
-
if (hostname === 'mail.example.com') {
|
|
389
|
-
return ['93.184.216.34'];
|
|
390
|
-
}
|
|
391
|
-
return [];
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
},
|
|
395
|
-
net: { isIP: host => /^\d+\.\d+\.\d+\.\d+$/.test(host) }
|
|
418
|
+
dnsStub: createDnsStub(resolve4Result, dnsCalls ? (method, hostname) => dnsCalls.push([method, hostname]) : null)
|
|
396
419
|
});
|
|
397
420
|
|
|
398
|
-
await proxyConnection(logger,
|
|
421
|
+
const result = await proxyConnection(logger, proxyUrl, host, port).then(
|
|
422
|
+
value => ({ value }),
|
|
423
|
+
err => ({ err })
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
return { options, logger, socket, result };
|
|
427
|
+
};
|
|
399
428
|
|
|
400
|
-
|
|
429
|
+
module.exports['Proxy Connection: SOCKS5 preserves the destination hostname for remote DNS'] = async test => {
|
|
430
|
+
const dnsCalls = [];
|
|
431
|
+
const { options, result } = await socksCase({ proxyUrl: 'socks5://proxy.example.com:1080', host: 'mail.example.com', dnsCalls });
|
|
432
|
+
|
|
433
|
+
test.equal(options[0].proxy.type, 5);
|
|
434
|
+
test.equal(options[0].proxy.host, 'proxy.example.com', 'the endpoint hostname reaches the dependency unresolved');
|
|
435
|
+
test.equal(options[0].destination.host, 'mail.example.com', 'the destination hostname is left for the proxy to resolve');
|
|
436
|
+
test.equal(options[0].command, 'connect');
|
|
437
|
+
test.ok(options[0].timeout > 0, 'a strictly positive timeout is passed, never zero');
|
|
438
|
+
test.deepEqual(dnsCalls, [], 'ImapFlow resolves nothing for SOCKS5');
|
|
439
|
+
test.ok(result.value, 'the socket is returned');
|
|
401
440
|
test.done();
|
|
402
441
|
};
|
|
403
442
|
|
|
404
|
-
module.exports['Proxy Connection:
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
443
|
+
module.exports['Proxy Connection: SOCKS (alias) defaults to SOCKS5'] = async test => {
|
|
444
|
+
const { options } = await socksCase({ proxyUrl: 'socks://proxy.example.com:1080', host: 'mail.example.com' });
|
|
445
|
+
test.equal(options[0].proxy.type, 5);
|
|
446
|
+
test.equal(options[0].destination.host, 'mail.example.com');
|
|
447
|
+
test.done();
|
|
448
|
+
};
|
|
408
449
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
},
|
|
413
|
-
socks: { SocksClient: {} },
|
|
414
|
-
dns: {
|
|
415
|
-
promises: {
|
|
416
|
-
resolve: async () => {
|
|
417
|
-
dnsResolveCalled = true;
|
|
418
|
-
return ['127.0.0.1'];
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
},
|
|
422
|
-
net: { isIP: () => true } // Pretend it's already an IP
|
|
423
|
-
});
|
|
450
|
+
module.exports['Proxy Connection: SOCKS5 passes IP literals through unchanged'] = async test => {
|
|
451
|
+
let { options } = await socksCase({ proxyUrl: 'socks5://proxy.example.com:1080', host: '192.168.1.1' });
|
|
452
|
+
test.equal(options[0].destination.host, '192.168.1.1', 'IPv4 literal unchanged');
|
|
424
453
|
|
|
425
|
-
await
|
|
454
|
+
({ options } = await socksCase({ proxyUrl: 'socks5://proxy.example.com:1080', host: '2001:db8::9' }));
|
|
455
|
+
test.equal(options[0].destination.host, '2001:db8::9', 'IPv6 literal unchanged');
|
|
456
|
+
test.done();
|
|
457
|
+
};
|
|
426
458
|
|
|
427
|
-
|
|
459
|
+
module.exports['Proxy Connection: SOCKS4a preserves the destination hostname for remote DNS'] = async test => {
|
|
460
|
+
const dnsCalls = [];
|
|
461
|
+
const { options } = await socksCase({ proxyUrl: 'socks4a://proxy.example.com:1080', host: 'mail.example.com', dnsCalls });
|
|
462
|
+
|
|
463
|
+
test.equal(options[0].proxy.type, 4, 'the dependency uses proxy type 4 for both SOCKS4 and SOCKS4a');
|
|
464
|
+
test.equal(options[0].destination.host, 'mail.example.com', 'the hostname is preserved so the proxy resolves it');
|
|
465
|
+
test.deepEqual(dnsCalls, [], 'no local lookup for SOCKS4a');
|
|
428
466
|
test.done();
|
|
429
467
|
};
|
|
430
468
|
|
|
431
|
-
module.exports['Proxy Connection:
|
|
432
|
-
const
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
socks: {
|
|
439
|
-
SocksClient: {
|
|
440
|
-
createConnection: async opts => {
|
|
441
|
-
proxyHostResolved = opts.proxy.host;
|
|
442
|
-
return { socket: mockSocket };
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
},
|
|
446
|
-
dns: {
|
|
447
|
-
promises: {
|
|
448
|
-
resolve: async hostname => {
|
|
449
|
-
if (hostname === 'proxy.example.com') {
|
|
450
|
-
return ['10.0.0.1'];
|
|
451
|
-
}
|
|
452
|
-
return ['127.0.0.1'];
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
},
|
|
456
|
-
net: { isIP: host => /^\d+\.\d+\.\d+\.\d+$/.test(host) }
|
|
469
|
+
module.exports['Proxy Connection: SOCKS4 resolves the destination locally to IPv4'] = async test => {
|
|
470
|
+
const dnsCalls = [];
|
|
471
|
+
const { options } = await socksCase({
|
|
472
|
+
proxyUrl: 'socks4://proxy.example.com:1080',
|
|
473
|
+
host: 'mail.example.com',
|
|
474
|
+
resolve4Result: ['93.184.216.34'],
|
|
475
|
+
dnsCalls
|
|
457
476
|
});
|
|
458
477
|
|
|
459
|
-
|
|
478
|
+
test.equal(options[0].proxy.type, 4);
|
|
479
|
+
test.equal(options[0].destination.host, '93.184.216.34', 'SOCKS4 gets a resolved IPv4 address, not a hostname');
|
|
480
|
+
test.deepEqual(dnsCalls, [['resolve4', 'mail.example.com']], 'the lookup is explicitly IPv4 only');
|
|
481
|
+
test.done();
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
module.exports['Proxy Connection: SOCKS4 reports an unresolvable destination'] = async test => {
|
|
485
|
+
const { result } = await socksCase({
|
|
486
|
+
proxyUrl: 'socks4://proxy.example.com:1080',
|
|
487
|
+
host: 'mail.example.com',
|
|
488
|
+
resolve4Result: []
|
|
489
|
+
});
|
|
460
490
|
|
|
461
|
-
test.
|
|
491
|
+
test.ok(result.err, 'an empty IPv4 lookup fails the connection');
|
|
492
|
+
test.equal(result.err.code, 'EPROXY');
|
|
462
493
|
test.done();
|
|
463
494
|
};
|
|
464
495
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
496
|
+
module.exports['Proxy Connection: SOCKS4 and SOCKS4a reject IPv6 destinations clearly'] = async test => {
|
|
497
|
+
for (let proxyUrl of ['socks4://proxy.example.com:1080', 'socks4a://proxy.example.com:1080']) {
|
|
498
|
+
const { options, result, logger } = await socksCase({ proxyUrl, host: '2001:db8::9' });
|
|
468
499
|
|
|
469
|
-
|
|
470
|
-
|
|
500
|
+
test.ok(result.err, `${proxyUrl} rejects an IPv6 destination`);
|
|
501
|
+
test.equal(result.err.code, 'UnsupportedProxyAddress', 'the failure names the unsupported address type');
|
|
502
|
+
test.deepEqual(options, [], 'no request is emitted with the address in the wrong field');
|
|
503
|
+
test.equal(logger._logs.error.length, 1, 'the failure is logged');
|
|
504
|
+
}
|
|
505
|
+
test.done();
|
|
506
|
+
};
|
|
471
507
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
socks: { SocksClient: {} },
|
|
475
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
476
|
-
net: { isIP: () => true }
|
|
477
|
-
});
|
|
508
|
+
module.exports['Proxy Connection: bracketed IPv6 proxy URL yields a bare literal endpoint'] = async test => {
|
|
509
|
+
const { options, logger } = await socksCase({ proxyUrl: 'socks5://[2001:db8::1]:1080', host: 'mail.example.com' });
|
|
478
510
|
|
|
479
|
-
|
|
511
|
+
test.equal(options[0].proxy.host, '2001:db8::1', 'brackets are stripped before the address reaches the dependency');
|
|
512
|
+
test.ok(logger._logs.info[0].proxyUrl.includes('[2001:db8::1]'), 'logs keep a valid bracketed URL');
|
|
513
|
+
test.done();
|
|
514
|
+
};
|
|
480
515
|
|
|
481
|
-
|
|
516
|
+
module.exports['Proxy Connection: SOCKS passes credentials and hides them in logs'] = async test => {
|
|
517
|
+
const { options, logger } = await socksCase({ proxyUrl: 'socks5://testuser:testpass@proxy.example.com:1080', host: 'mail.example.com' });
|
|
518
|
+
|
|
519
|
+
test.equal(options[0].proxy.userId, 'testuser');
|
|
520
|
+
test.equal(options[0].proxy.password, 'testpass');
|
|
521
|
+
test.ok(!logger._logs.info[0].proxyUrl.includes('testpass'));
|
|
522
|
+
test.ok(logger._logs.info[0].proxyUrl.includes('(hidden)'));
|
|
482
523
|
test.done();
|
|
483
524
|
};
|
|
484
525
|
|
|
485
|
-
module.exports['Proxy Connection:
|
|
526
|
+
module.exports['Proxy Connection: SOCKS with username only'] = async test => {
|
|
527
|
+
const { options } = await socksCase({ proxyUrl: 'socks5://testuser@proxy.example.com:1080', host: 'mail.example.com' });
|
|
528
|
+
test.equal(options[0].proxy.userId, 'testuser');
|
|
529
|
+
test.equal(options[0].proxy.password, '', 'empty string from URL parsing');
|
|
530
|
+
test.done();
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
module.exports['Proxy Connection: SOCKS default port'] = async test => {
|
|
534
|
+
const { options } = await socksCase({ proxyUrl: 'socks5://proxy.example.com', host: 'mail.example.com' });
|
|
535
|
+
test.equal(options[0].proxy.port, 1080);
|
|
536
|
+
test.done();
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
module.exports['Proxy Connection: SOCKS failure is reported and redacted'] = async test => {
|
|
486
540
|
const logger = createMockLogger();
|
|
541
|
+
const testError = new Error('SOCKS connection failed');
|
|
487
542
|
|
|
488
|
-
const { proxyConnection } =
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
}
|
|
492
|
-
socks: { SocksClient: {} },
|
|
493
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
494
|
-
net: { isIP: () => true }
|
|
543
|
+
const { proxyConnection } = loadProxyModule({
|
|
544
|
+
socksCreateConnection: async () => {
|
|
545
|
+
throw testError;
|
|
546
|
+
}
|
|
495
547
|
});
|
|
496
548
|
|
|
497
|
-
|
|
549
|
+
let err = await proxyConnection(logger, 'socks5://user:secret@proxy.example.com:1080', 'mail.example.com', 993)
|
|
550
|
+
.then(() => null)
|
|
551
|
+
.catch(e => e);
|
|
498
552
|
|
|
499
|
-
test.equal(
|
|
500
|
-
|
|
501
|
-
test.
|
|
553
|
+
test.equal(err, testError, 'the dependency error is preserved');
|
|
554
|
+
test.equal(logger._logs.error.length, 1);
|
|
555
|
+
test.ok(!logger._logs.error[0].proxyUrl.includes('secret'));
|
|
556
|
+
test.ok(logger._logs.error[0].proxyUrl.includes('(hidden)'));
|
|
502
557
|
test.done();
|
|
503
558
|
};
|
|
504
559
|
|
|
505
|
-
module.exports['Proxy Connection:
|
|
506
|
-
const mockSocket = createMockSocket();
|
|
560
|
+
module.exports['Proxy Connection: SOCKS returning no socket fails'] = async test => {
|
|
507
561
|
const logger = createMockLogger();
|
|
508
|
-
let usedHost = null;
|
|
509
562
|
|
|
510
|
-
const { proxyConnection } =
|
|
511
|
-
'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
|
|
512
|
-
usedHost = host;
|
|
513
|
-
cb(null, mockSocket);
|
|
514
|
-
},
|
|
515
|
-
socks: { SocksClient: {} },
|
|
516
|
-
dns: {
|
|
517
|
-
promises: {
|
|
518
|
-
resolve: async () => [] // Empty result
|
|
519
|
-
}
|
|
520
|
-
},
|
|
521
|
-
net: { isIP: () => false }
|
|
522
|
-
});
|
|
563
|
+
const { proxyConnection } = loadProxyModule({ socksCreateConnection: async () => ({}) });
|
|
523
564
|
|
|
524
|
-
await proxyConnection(logger, '
|
|
565
|
+
let err = await proxyConnection(logger, 'socks5://proxy.example.com:1080', 'mail.example.com', 993)
|
|
566
|
+
.then(() => null)
|
|
567
|
+
.catch(e => e);
|
|
525
568
|
|
|
526
|
-
|
|
527
|
-
test.equal(
|
|
569
|
+
test.ok(err, 'a missing socket is an error rather than an undefined return');
|
|
570
|
+
test.equal(err.code, 'EPROXY');
|
|
528
571
|
test.done();
|
|
529
572
|
};
|
|
530
573
|
|
|
531
|
-
module.exports['Proxy Connection: SOCKS
|
|
532
|
-
const mockSocket = createMockSocket();
|
|
574
|
+
module.exports['Proxy Connection: the SOCKS dependency timeout is normalized to CONNECT_TIMEOUT'] = async test => {
|
|
533
575
|
const logger = createMockLogger();
|
|
534
576
|
|
|
535
|
-
const { proxyConnection } =
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
test.equal(opts.proxy.userId, 'testuser');
|
|
541
|
-
test.equal(opts.proxy.password, ''); // Empty string from URL parsing
|
|
542
|
-
return { socket: mockSocket };
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
},
|
|
546
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
547
|
-
net: { isIP: () => true }
|
|
577
|
+
const { proxyConnection } = loadProxyModule({
|
|
578
|
+
socksCreateConnection: async () => {
|
|
579
|
+
// the shape the socks client uses for its own expiry
|
|
580
|
+
throw new Error('Proxy connection timed out');
|
|
581
|
+
}
|
|
548
582
|
});
|
|
549
583
|
|
|
550
|
-
await proxyConnection(logger, 'socks5://
|
|
584
|
+
let err = await proxyConnection(logger, 'socks5://proxy.example.com:1080', 'mail.example.com', 993, { connectionTimeout: 5000 })
|
|
585
|
+
.then(() => null)
|
|
586
|
+
.catch(e => e);
|
|
587
|
+
|
|
588
|
+
test.equal(err.code, 'CONNECT_TIMEOUT', 'SOCKS and HTTP expose the same timeout code');
|
|
589
|
+
test.equal(err.details.connectionTimeout, 5000, 'the normalized error carries the configured timeout');
|
|
590
|
+
test.ok(err._err, 'the dependency error is kept for diagnostics');
|
|
551
591
|
test.done();
|
|
552
592
|
};
|
|
553
593
|
|
|
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();
|
|
594
|
+
module.exports['Proxy Connection: a stalled SOCKS negotiation hits the shared deadline'] = async test => {
|
|
560
595
|
const logger = createMockLogger();
|
|
561
596
|
|
|
562
|
-
const { proxyConnection } =
|
|
563
|
-
|
|
564
|
-
cb(null, mockSocket);
|
|
565
|
-
},
|
|
566
|
-
socks: { SocksClient: {} },
|
|
567
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
568
|
-
net: { isIP: () => true }
|
|
597
|
+
const { proxyConnection } = loadProxyModule({
|
|
598
|
+
socksCreateConnection: () => new Promise(() => {}) // never settles
|
|
569
599
|
});
|
|
570
600
|
|
|
571
|
-
|
|
601
|
+
let err = await proxyConnection(logger, 'socks5://proxy.example.com:1080', 'mail.example.com', 993, { connectionTimeout: 60 })
|
|
602
|
+
.then(() => null)
|
|
603
|
+
.catch(e => e);
|
|
572
604
|
|
|
573
|
-
|
|
605
|
+
test.equal(err.code, 'CONNECT_TIMEOUT', 'the shared deadline bounds the dependency');
|
|
574
606
|
test.done();
|
|
575
607
|
};
|
|
576
608
|
|
|
577
|
-
module.exports['Proxy Connection:
|
|
578
|
-
const mockSocket = createMockEmitterSocket();
|
|
609
|
+
module.exports['Proxy Connection: SOCKS4 local lookup runs inside the deadline'] = async test => {
|
|
579
610
|
const logger = createMockLogger();
|
|
580
611
|
|
|
581
|
-
const { proxyConnection } =
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
SocksClient: {
|
|
585
|
-
createConnection: async () => ({ socket: mockSocket })
|
|
586
|
-
}
|
|
587
|
-
},
|
|
588
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
589
|
-
net: { isIP: () => true }
|
|
612
|
+
const { proxyConnection } = loadProxyModule({
|
|
613
|
+
socksCreateConnection: async () => ({ socket: createFakeSocket() }),
|
|
614
|
+
dnsStub: { promises: { resolve4: () => new Promise(() => {}), resolve: () => new Promise(() => {}) } }
|
|
590
615
|
});
|
|
591
616
|
|
|
592
|
-
|
|
617
|
+
let err = await proxyConnection(logger, 'socks4://proxy.example.com:1080', 'mail.example.com', 993, { connectionTimeout: 60 })
|
|
618
|
+
.then(() => null)
|
|
619
|
+
.catch(e => e);
|
|
593
620
|
|
|
594
|
-
|
|
621
|
+
test.equal(err.code, 'CONNECT_TIMEOUT', 'a stalled DNS lookup cannot outlive the connection timeout');
|
|
595
622
|
test.done();
|
|
596
623
|
};
|
|
597
624
|
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
|
|
601
|
-
|
|
625
|
+
// ============================================
|
|
626
|
+
// Edge cases
|
|
627
|
+
// ============================================
|
|
628
|
+
|
|
629
|
+
module.exports['Proxy Connection: Unknown protocol returns undefined'] = async test => {
|
|
602
630
|
const logger = createMockLogger();
|
|
631
|
+
const { proxyConnection } = loadProxyModule({});
|
|
603
632
|
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
});
|
|
633
|
+
const result = await proxyConnection(logger, 'ftp://proxy.example.com:21', 'mail.example.com', 993);
|
|
634
|
+
|
|
635
|
+
test.equal(result, undefined);
|
|
636
|
+
test.done();
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
module.exports['Proxy Connection: detachEarlyErrorHandler removes the early guard'] = async test => {
|
|
640
|
+
const { proxyConnection, detachEarlyErrorHandler, sockets, logger } = httpCase();
|
|
641
|
+
|
|
642
|
+
const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
|
|
643
|
+
await tick();
|
|
644
|
+
respondOk(sockets[0]);
|
|
645
|
+
const socket = await promise;
|
|
612
646
|
|
|
613
|
-
const socket = await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
|
|
614
647
|
test.ok(socket.listenerCount('error') >= 1, 'early error handler attached on return');
|
|
615
648
|
test.ok(typeof socket._earlyErrorHandler === 'function', 'handler reference stored on the socket');
|
|
616
649
|
|
|
@@ -621,7 +654,85 @@ module.exports['Proxy Connection: detachEarlyErrorHandler removes the early guar
|
|
|
621
654
|
|
|
622
655
|
// Detaching again (or on a bare socket) must be a safe no-op.
|
|
623
656
|
test.doesNotThrow(() => detachEarlyErrorHandler(socket));
|
|
624
|
-
test.doesNotThrow(() => detachEarlyErrorHandler(
|
|
657
|
+
test.doesNotThrow(() => detachEarlyErrorHandler(createFakeSocket()));
|
|
658
|
+
|
|
659
|
+
test.done();
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
module.exports['Proxy Connection: socket without .on skips early error handler attach'] = async test => {
|
|
663
|
+
const logger = createMockLogger();
|
|
664
|
+
const { proxyConnection } = loadProxyModule({ socksCreateConnection: async () => ({ socket: { write() {} } }) });
|
|
665
|
+
|
|
666
|
+
const socket = await proxyConnection(logger, 'socks5://proxy.example.com:1080', 'mail.example.com', 993);
|
|
667
|
+
|
|
668
|
+
test.ok(socket, 'a socket-like object without an event emitter interface is still returned');
|
|
669
|
+
test.equal(socket._earlyErrorHandler, undefined, 'no handler is attached to it');
|
|
670
|
+
test.done();
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
// ============================================
|
|
674
|
+
// Credential handling and malformed userinfo
|
|
675
|
+
// ============================================
|
|
676
|
+
|
|
677
|
+
module.exports['Proxy Connection: a proxy password with a bare percent sign still connects'] = async test => {
|
|
678
|
+
// URL userinfo is percent-encoded, so a bare '%' is not valid encoding. Decoding it must not
|
|
679
|
+
// throw out of the connect callback, which would both crash the process and leave the
|
|
680
|
+
// connection promise pending forever.
|
|
681
|
+
const { proxyConnection, sockets, logger } = httpCase();
|
|
682
|
+
|
|
683
|
+
const promise = proxyConnection(logger, 'http://user:p%ss@proxy.example.com:8080', 'mail.example.com', 993);
|
|
684
|
+
await tick();
|
|
685
|
+
|
|
686
|
+
const authHeader = sockets[0].writes[0].match(/Proxy-Authorization: Basic (\S+)/);
|
|
687
|
+
test.ok(authHeader, 'credentials are still sent');
|
|
688
|
+
test.equal(Buffer.from(authHeader[1], 'base64').toString(), 'user:p%ss', 'the undecodable value is used as it came in');
|
|
689
|
+
|
|
690
|
+
respondOk(sockets[0]);
|
|
691
|
+
const socket = await promise;
|
|
692
|
+
test.ok(socket, 'the tunnel is established');
|
|
693
|
+
test.done();
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
module.exports['Proxy Connection: a SOCKS failure never carries the proxy password'] = async test => {
|
|
697
|
+
// The socks client attaches its full options object - password included - to the errors it
|
|
698
|
+
// throws, and a logger that serializes error properties would write it out in clear text.
|
|
699
|
+
const logger = createMockLogger();
|
|
700
|
+
|
|
701
|
+
const { proxyConnection } = loadProxyModule({
|
|
702
|
+
socksCreateConnection: async opts => {
|
|
703
|
+
let err = new Error('Socks5 proxy rejected connection - Failure');
|
|
704
|
+
err.options = opts; // this is what socks does
|
|
705
|
+
throw err;
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
let err = await proxyConnection(logger, 'socks5://user:secretpass@proxy.example.com:1080', 'mail.example.com', 993)
|
|
710
|
+
.then(() => null)
|
|
711
|
+
.catch(e => e);
|
|
712
|
+
|
|
713
|
+
test.ok(err, 'the failure still propagates');
|
|
714
|
+
test.equal(err.options, undefined, 'the credential-bearing options object is stripped from the error');
|
|
715
|
+
test.ok(!JSON.stringify(logger._logs.error).includes('secretpass'), 'the password does not reach the log');
|
|
716
|
+
test.done();
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
module.exports['Proxy Connection: a normalized SOCKS timeout carries no credentials either'] = async test => {
|
|
720
|
+
const logger = createMockLogger();
|
|
721
|
+
|
|
722
|
+
const { proxyConnection } = loadProxyModule({
|
|
723
|
+
socksCreateConnection: async opts => {
|
|
724
|
+
let err = new Error('Proxy connection timed out');
|
|
725
|
+
err.options = opts;
|
|
726
|
+
throw err;
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
let err = await proxyConnection(logger, 'socks5://user:secretpass@proxy.example.com:1080', 'mail.example.com', 993, { connectionTimeout: 5000 })
|
|
731
|
+
.then(() => null)
|
|
732
|
+
.catch(e => e);
|
|
625
733
|
|
|
734
|
+
test.equal(err.code, 'CONNECT_TIMEOUT');
|
|
735
|
+
test.equal(err._err.options, undefined, 'the retained diagnostic error is stripped too');
|
|
736
|
+
test.ok(!JSON.stringify(logger._logs.error).includes('secretpass'), 'the password does not reach the log');
|
|
626
737
|
test.done();
|
|
627
738
|
};
|