Skip to content

Commit e6adb19

Browse files
committed
fix: add onTLSClientError handler
- Fix a datarace for error handler - Add a regression test that verify datarace fix - Add TLS defaults for better security
1 parent 313f535 commit e6adb19

File tree

3 files changed

+213
-9
lines changed

3 files changed

+213
-9
lines changed

src/server.ts

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { EventEmitter } from 'node:events';
55
import http from 'node:http';
66
import https from 'node:https';
77
import type net from 'node:net';
8+
import type tls from 'node:tls';
89
import { URL } from 'node:url';
910
import util from 'node:util';
1011

@@ -19,7 +20,7 @@ import type { HandlerOpts as ForwardOpts } from './forward';
1920
import { forward } from './forward';
2021
import { forwardSocks } from './forward_socks';
2122
import { RequestError } from './request_error';
22-
import type { Socket } from './socket';
23+
import type { Socket, TLSSocket } from './socket';
2324
import { badGatewayStatusCodes } from './statuses';
2425
import { getTargetStats } from './utils/count_target_bytes';
2526
import { nodeify } from './utils/nodeify';
@@ -115,6 +116,7 @@ export type ServerOptions = HttpServerOptions | HttpsServerOptions;
115116
* Represents the proxy server.
116117
* It emits the 'requestFailed' event on unexpected request errors, with the following parameter `{ error, request }`.
117118
* It emits the 'connectionClosed' event when connection to proxy server is closed, with parameter `{ connectionId, stats }`.
119+
* It emits the 'tlsError' event on TLS handshake failures (HTTPS servers only), with parameter `{ error, socket }`.
118120
*/
119121
export class Server extends EventEmitter {
120122
port: number;
@@ -193,23 +195,49 @@ export class Server extends EventEmitter {
193195
if (!options.httpsOptions) {
194196
throw new Error('httpsOptions is required when serverType is "https"');
195197
}
196-
this.server = https.createServer(options.httpsOptions);
198+
199+
// Apply secure TLS defaults (user options can override)
200+
// This prevents users from accidentally configuring insecure TLS settings
201+
const secureDefaults: https.ServerOptions = {
202+
minVersion: 'TLSv1.2', // Disable TLS 1.0 and 1.1 (deprecated, insecure)
203+
maxVersion: 'TLSv1.3', // Enable modern TLS 1.3
204+
// Strong cipher suites (TLS 1.3 and TLS 1.2)
205+
ciphers: [
206+
// TLS 1.3 ciphers (always enabled with TLS 1.3)
207+
'TLS_AES_128_GCM_SHA256',
208+
'TLS_AES_256_GCM_SHA384',
209+
'TLS_CHACHA20_POLY1305_SHA256',
210+
// TLS 1.2 ciphers (strong only)
211+
'ECDHE-RSA-AES128-GCM-SHA256',
212+
'ECDHE-RSA-AES256-GCM-SHA384',
213+
].join(':'),
214+
honorCipherOrder: true, // Server chooses cipher (prevents downgrade attacks)
215+
...options.httpsOptions, // User options override defaults
216+
};
217+
218+
this.server = https.createServer(secureDefaults);
197219
this.serverType = 'https';
198220
} else {
199221
this.server = http.createServer();
200222
this.serverType = 'http';
201223
}
202224

203-
// Attach event handlers (same for both HTTP and HTTPS)
225+
// Attach common event handlers (same for both HTTP and HTTPS)
204226
this.server.on('clientError', this.onClientError.bind(this));
205227
this.server.on('request', this.onRequest.bind(this));
206228
this.server.on('connect', this.onConnect.bind(this));
207-
this.server.on('connection', this.onConnection.bind(this));
208229

209-
// For HTTPS servers, also listen to secureConnection for proper TLS socket handling
210-
// This ensures connection tracking works correctly with TLS sockets
230+
// Attach connection tracking based on server type
231+
// CRITICAL: Only listen to ONE connection event to avoid double registration
211232
if (this.serverType === 'https') {
233+
// For HTTPS: Track only post-TLS-handshake sockets (secureConnection)
234+
// This ensures we track the TLS-wrapped socket with correct bytesRead/bytesWritten
212235
this.server.on('secureConnection', this.onConnection.bind(this));
236+
// Handle TLS handshake errors to prevent server crashes
237+
this.server.on('tlsClientError', this.onTLSClientError.bind(this));
238+
} else {
239+
// For HTTP: Track raw TCP sockets (connection)
240+
this.server.on('connection', this.onConnection.bind(this));
213241
}
214242

215243
this.lastHandlerId = 0;
@@ -232,14 +260,38 @@ export class Server extends EventEmitter {
232260
onClientError(err: NodeJS.ErrnoException, socket: Socket): void {
233261
this.log(socket.proxyChainId, `onClientError: ${err}`);
234262

263+
// HTTP protocol error occurred after TLS handshake succeeded (in case HTTPS server is used)
235264
// https://nodejs.org/api/http.html#http_event_clienterror
236265
if (err.code === 'ECONNRESET' || !socket.writable) {
237266
return;
238267
}
239268

269+
// Can send HTTP response because HTTP protocol layer is active
240270
this.sendSocketResponse(socket, 400, {}, 'Invalid request');
241271
}
242272

273+
/**
274+
* Handles TLS handshake errors for HTTPS servers.
275+
* Without this handler, unhandled TLS errors can crash the server.
276+
* Common errors: ECONNRESET, ERR_SSL_SSLV3_ALERT_CERTIFICATE_UNKNOWN,
277+
* ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION, ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE
278+
*/
279+
onTLSClientError(err: NodeJS.ErrnoException, tlsSocket: tls.TLSSocket): void {
280+
const connectionId = (tlsSocket as TLSSocket).proxyChainId;
281+
this.log(connectionId, `TLS handshake failed: ${err.message}`);
282+
283+
// If connection already reset or socket not writable, nothing to do
284+
if (err.code === 'ECONNRESET' || !tlsSocket.writable) {
285+
return;
286+
}
287+
288+
// TLS handshake failed before HTTP, cannot send HTTP response
289+
tlsSocket.destroy(err);
290+
291+
// Emit event for user monitoring/metrics
292+
this.emit('tlsError', { error: err, socket: tlsSocket });
293+
}
294+
243295
/**
244296
* Assigns a unique ID to the socket and keeps the register up to date.
245297
* Needed for abrupt close of the server.
@@ -280,8 +332,12 @@ export class Server extends EventEmitter {
280332
// We need to consume socket errors, because the handlers are attached asynchronously.
281333
// See https://github.com/apify/proxy-chain/issues/53
282334
socket.on('error', (err) => {
283-
// Handle errors only if there's no other handler
284-
if (this.listenerCount('error') === 1) {
335+
// Prevent duplicate error handling for the same socket
336+
if (socket.proxyChainErrorHandled) return;
337+
socket.proxyChainErrorHandled = true;
338+
339+
// Log errors only if there are no user-provided error handlers
340+
if (this.listenerCount('error') === 0) {
285341
this.log(socket.proxyChainId, `Source socket emitted error: ${err.stack || err}`);
286342
}
287343
});
@@ -654,6 +710,7 @@ export class Server extends EventEmitter {
654710

655711
// For TLS sockets, bytesRead/bytesWritten might not be immediately available
656712
// Use nullish coalescing to ensure we always have valid numeric values
713+
// Note: Even destroyed sockets retain their byte count properties
657714
const result = {
658715
srcTxBytes: socket.bytesWritten ?? 0,
659716
srcRxBytes: socket.bytesRead ?? 0,

src/socket.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type net from 'node:net';
22
import type tls from 'node:tls';
33

4-
type AdditionalProps = { proxyChainId?: number };
4+
type AdditionalProps = {
5+
proxyChainId?: number;
6+
proxyChainErrorHandled?: boolean;
7+
};
58

69
export type Socket = net.Socket & AdditionalProps;
710
export type TLSSocket = tls.TLSSocket & AdditionalProps;

test/server.js

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,6 +1437,150 @@ it('supports localAddress', async () => {
14371437
}
14381438
});
14391439

1440+
it('prevents duplicate socket error handling (regression test for race condition)', async () => {
1441+
// This test proves that the socket error handler race condition is fixed.
1442+
// Before the fix: Multiple error events on same socket would cause duplicate log entries
1443+
// After the fix: The proxyChainErrorHandled flag prevents duplicate handling
1444+
1445+
const server = new Server({
1446+
port: 0,
1447+
verbose: false, // Disable verbose to control logging ourselves
1448+
});
1449+
1450+
await server.listen();
1451+
1452+
try {
1453+
// Track how many times error handler logic executes
1454+
let errorHandlerExecutionCount = 0;
1455+
const loggedErrors = [];
1456+
1457+
// Monkey-patch the log method to capture error logs
1458+
const originalLog = server.log.bind(server);
1459+
server.log = (connectionId, message) => {
1460+
if (message.includes('Source socket emitted error:')) {
1461+
errorHandlerExecutionCount++;
1462+
loggedErrors.push(message);
1463+
}
1464+
originalLog(connectionId, message);
1465+
};
1466+
1467+
// Create a raw socket connection to trigger the onConnection handler
1468+
const socket = await new Promise((resolve, reject) => {
1469+
const client = net.connect({
1470+
host: '127.0.0.1',
1471+
port: server.port,
1472+
}, () => {
1473+
resolve(client);
1474+
});
1475+
client.on('error', reject);
1476+
});
1477+
1478+
// Give Node.js time to register the socket and attach error handlers
1479+
await wait(100);
1480+
1481+
// Get the server-side socket from the connection
1482+
// The server tracks all connections, we can get the most recent one
1483+
const serverSockets = Array.from(server.connections.values());
1484+
expect(serverSockets.length).to.equal(1);
1485+
const serverSocket = serverSockets[0];
1486+
1487+
// Verify the socket has been registered with an ID
1488+
expect(serverSocket.proxyChainId).to.be.a('number');
1489+
1490+
// Verify flag is not set initially
1491+
expect(serverSocket.proxyChainErrorHandled).to.be.undefined;
1492+
1493+
// Emit multiple error events on the same socket
1494+
// This simulates the race condition where multiple errors fire
1495+
const testError1 = new Error('Test error 1');
1496+
const testError2 = new Error('Test error 2');
1497+
const testError3 = new Error('Test error 3');
1498+
1499+
serverSocket.emit('error', testError1);
1500+
serverSocket.emit('error', testError2);
1501+
serverSocket.emit('error', testError3);
1502+
1503+
// Give time for all error handlers to fire
1504+
await wait(50);
1505+
1506+
// The flag should be set after first error
1507+
expect(serverSocket.proxyChainErrorHandled).to.equal(true);
1508+
1509+
// Error handler logic should execute exactly ONCE, not three times
1510+
expect(errorHandlerExecutionCount).to.equal(1);
1511+
1512+
// Only ONE error should be logged, not three
1513+
expect(loggedErrors.length).to.equal(1);
1514+
expect(loggedErrors[0]).to.include('Test error 1'); // First error is logged
1515+
1516+
// Subsequent errors should be silently ignored (not logged)
1517+
expect(loggedErrors[0]).to.not.include('Test error 2');
1518+
expect(loggedErrors[0]).to.not.include('Test error 3');
1519+
1520+
socket.destroy();
1521+
} finally {
1522+
await server.close();
1523+
}
1524+
});
1525+
1526+
it('socket error handler respects user-provided error handlers', async () => {
1527+
// Verify that when user provides error handler, the default logging is suppressed
1528+
// but the flag still prevents duplicate handling
1529+
1530+
let userErrorHandlerCallCount = 0;
1531+
1532+
const server = new Server({
1533+
port: 0,
1534+
verbose: false,
1535+
});
1536+
1537+
// User provides custom error handler
1538+
server.on('error', () => {
1539+
userErrorHandlerCallCount++;
1540+
});
1541+
1542+
await server.listen();
1543+
1544+
try {
1545+
let defaultLogCallCount = 0;
1546+
const originalLog = server.log.bind(server);
1547+
server.log = (connectionId, message) => {
1548+
if (message.includes('Source socket emitted error:')) {
1549+
defaultLogCallCount++;
1550+
}
1551+
originalLog(connectionId, message);
1552+
};
1553+
1554+
const socket = await new Promise((resolve, reject) => {
1555+
const client = net.connect({
1556+
host: '127.0.0.1',
1557+
port: server.port,
1558+
}, () => resolve(client));
1559+
client.on('error', reject);
1560+
});
1561+
1562+
await wait(100);
1563+
1564+
const serverSocket = Array.from(server.connections.values())[0];
1565+
1566+
// Emit multiple errors
1567+
serverSocket.emit('error', new Error('User handler test 1'));
1568+
serverSocket.emit('error', new Error('User handler test 2'));
1569+
1570+
await wait(50);
1571+
1572+
// When user provides error handler, default logging should NOT happen
1573+
expect(defaultLogCallCount).to.equal(0);
1574+
1575+
// Flag should still prevent duplicate handling
1576+
expect(serverSocket.proxyChainErrorHandled).to.equal(true);
1577+
1578+
socket.destroy();
1579+
} finally {
1580+
await server.close();
1581+
}
1582+
});
1583+
14401584
it('supports https proxy relay', async () => {
14411585
const target = https.createServer(() => {
14421586
});

0 commit comments

Comments
 (0)