Skip to content

Commit 44d675e

Browse files
committed
https: fix readable listener leak during proxy CONNECT
establishTunnel() reused the same function as both the initial reader and the 'readable' listener, and re-subscribed it at the end of every call. Fixes: #62904 Signed-off-by: Ali Hassan <[email protected]>
1 parent 5f92b6d commit 44d675e

2 files changed

Lines changed: 98 additions & 1 deletion

File tree

lib/https.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ function establishTunnel(agent, socket, options, tunnelConfig, afterSocket) {
221221
break;
222222
}
223223
}
224-
socket.on('readable', read);
225224
}
226225

227226
function cleanup() {
@@ -315,6 +314,7 @@ function establishTunnel(agent, socket, options, tunnelConfig, afterSocket) {
315314

316315
socket.on('error', onProxyError);
317316
socket.on('end', onProxyEnd);
317+
socket.on('readable', read);
318318
socket.write(proxyTunnelPayload);
319319

320320
read();
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Regression test for https://github.com/nodejs/node/issues/62904.
2+
// This test drives that scenario by writing the CONNECT response one byte at
3+
// a time with a short delay between writes, and asserts that no warning is
4+
// ever emitted while the full HTTPS response is received correctly.
5+
6+
import * as common from '../common/index.mjs';
7+
import fixtures from '../common/fixtures.js';
8+
import assert from 'node:assert';
9+
import http from 'node:http';
10+
import net from 'node:net';
11+
import { once } from 'events';
12+
13+
if (!common.hasCrypto)
14+
common.skip('missing crypto');
15+
16+
// https must be dynamically imported so that builds without crypto support
17+
// can skip it.
18+
const { default: https } = await import('node:https');
19+
20+
// Target HTTPS server the client ultimately wants to reach.
21+
const server = https.createServer({
22+
cert: fixtures.readKey('agent8-cert.pem'),
23+
key: fixtures.readKey('agent8-key.pem'),
24+
}, common.mustCall((req, res) => {
25+
res.end('Hello world');
26+
}));
27+
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
28+
server.listen(0);
29+
await once(server, 'listening');
30+
31+
// Proxy server that writes the CONNECT response one byte at a time. Using the
32+
// built-in HTTP server avoids having to parse the CONNECT request ourselves
33+
// (which cannot rely on a single TCP chunk containing the whole request line).
34+
const proxy = http.createServer();
35+
proxy.on('connect', common.mustCall(async (req, res, head) => {
36+
const { hostname, port } = new URL(`https://${req.url}`);
37+
const normalizedHost = hostname.startsWith('[') && hostname.endsWith(']') ?
38+
hostname.slice(1, -1) : hostname;
39+
40+
const upstream = net.connect(Number(port), normalizedHost);
41+
upstream.on('error', () => res.destroy());
42+
res.on('error', () => upstream.destroy());
43+
await once(upstream, 'connect');
44+
45+
const response = 'HTTP/1.1 200 Connection Established\r\n' +
46+
'Proxy-agent: Node.js-Proxy\r\n' +
47+
'\r\n';
48+
// Feed the response byte by byte with a macrotask boundary between writes
49+
// so the client sees many separate 'readable' events during tunnel setup.
50+
// This is what triggers the listener leak described in the issue.
51+
for (let i = 0; i < response.length; i++) {
52+
if (res.destroyed) {
53+
upstream.destroy();
54+
return;
55+
}
56+
res.write(response[i]);
57+
await new Promise((resolve) => setImmediate(resolve));
58+
}
59+
if (head?.length) upstream.write(head);
60+
res.pipe(upstream);
61+
upstream.pipe(res);
62+
}, 1));
63+
proxy.on('error', common.mustNotCall((err) => { console.error('Proxy error', err); }));
64+
proxy.listen(0);
65+
await once(proxy, 'listening');
66+
67+
// No warning should be emitted during the proxied request. The pre-fix code
68+
// emits MaxListenersExceededWarning; any other warning here would also be
69+
// unexpected and should fail the test.
70+
process.on('warning',
71+
common.mustNotCall('unexpected warning during proxied request'));
72+
73+
const agent = new https.Agent({
74+
proxyEnv: {
75+
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
76+
},
77+
ca: fixtures.readKey('fake-startcom-root-cert.pem'),
78+
});
79+
80+
const req = https.request({
81+
hostname: 'localhost',
82+
port: server.address().port,
83+
path: '/test',
84+
agent,
85+
}, common.mustCall((res) => {
86+
let data = '';
87+
res.setEncoding('utf8');
88+
res.on('data', (chunk) => { data += chunk; });
89+
res.on('end', common.mustCall(() => {
90+
assert.strictEqual(data, 'Hello world');
91+
assert.strictEqual(res.statusCode, 200);
92+
proxy.close();
93+
server.close();
94+
}));
95+
}));
96+
req.on('error', common.mustNotCall());
97+
req.end();

0 commit comments

Comments
 (0)