Skip to content

Commit 2161a35

Browse files
committed
Add error simulation to websocket passthrough & fix WS rejection crash
Previously it was possible to crash the proxy if the remote server rejected a websocket connection under some circumstances. That's now resolved, and mirroring of connection errors in general is improved in proxied websockets when the (HTTP-equivalent) simulateConnectionErrors option is provided.
1 parent bc10695 commit 2161a35

File tree

4 files changed

+78
-49
lines changed

4 files changed

+78
-49
lines changed

src/rules/passthrough-handling-definitions.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,29 @@ export interface PassThroughHandlerConnectionOptions {
108108
* cacheable-lookup module, which will cache responses.
109109
*/
110110
lookupOptions?: PassThroughLookupOptions;
111+
112+
/**
113+
* Whether to simulate connection errors back to the client.
114+
*
115+
* By default (in most cases - see below) when an upstream request fails
116+
* outright a 502 "Bad Gateway" response is sent to the downstream client,
117+
* explicitly indicating the failure and containing the error that caused
118+
* the issue in the response body.
119+
*
120+
* Only in the case of upstream HTTP connection reset errors is a connection
121+
* reset normally sent back downstream to existing clients (this behaviour
122+
* exists for backward compatibility, and will change to match other error
123+
* behaviour in a future version).
124+
*
125+
* When this option is set to `true`, low-level connection failures will
126+
* always trigger a downstream connection close/reset, rather than a 502
127+
* response.
128+
*
129+
* This includes DNS failures, TLS connection errors, TCP connection resets,
130+
* etc (but not HTTP non-200 responses, which are still proxied as normal).
131+
* This is less convenient for debugging in a testing environment or when
132+
* using a proxy intentionally, but can be more accurate when trying to
133+
* transparently proxy network traffic, errors and all.
134+
*/
135+
simulateConnectionErrors?: boolean;
111136
}

src/rules/requests/request-handler-definitions.ts

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -479,31 +479,6 @@ export interface PassThroughResponse {
479479
}
480480

481481
export interface PassThroughHandlerOptions extends PassThroughHandlerConnectionOptions {
482-
/**
483-
* Whether to simulate connection errors back to the client.
484-
*
485-
* By default (in most cases - see below) when an upstream request fails
486-
* outright a 502 "Bad Gateway" response is sent to the downstream client,
487-
* explicitly indicating the failure and containing the error that caused
488-
* the issue in the response body.
489-
*
490-
* Only in the case of upstream connection reset errors is a connection reset
491-
* normally sent back downstream to existing clients (this behaviour exists
492-
* for backward compatibility, and will change to match other error behaviour
493-
* in a future version).
494-
*
495-
* When this option is set to `true`, low-level connection failures will
496-
* always trigger a downstream connection close/reset, rather than a 502
497-
* response.
498-
*
499-
* This includes DNS failures, TLS connection errors, TCP connection resets,
500-
* etc (but not HTTP non-200 responses, which are still proxied as normal).
501-
* This is less convenient for debugging in a testing environment or when
502-
* using a proxy intentionally, but can be more accurate when trying to
503-
* transparently proxy network traffic, errors and all.
504-
*/
505-
simulateConnectionErrors?: boolean;
506-
507482
/**
508483
* A set of data to automatically transform a request. This includes properties
509484
* to support many transformation common use cases.

src/rules/websockets/websocket-handler-definitions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export interface SerializedPassThroughWebSocketData {
5252
forwarding?: ForwardingOptions;
5353
lookupOptions?: PassThroughLookupOptions;
5454
proxyConfig?: SerializedProxyConfig;
55+
simulateConnectionErrors?: boolean;
5556
ignoreHostCertificateErrors?: string[] | boolean; // Doesn't match option name, backward compat
5657
extraCACertificates?: Array<{ cert: string } | { certPath: string }>;
5758
clientCertificateHostMap?: { [host: string]: { pfx: string, passphrase?: string } };
@@ -63,6 +64,7 @@ export class PassThroughWebSocketHandlerDefinition extends Serializable implemen
6364
// Same lookup configuration as normal request PassThroughHandler:
6465
public readonly lookupOptions: PassThroughLookupOptions | undefined;
6566
public readonly proxyConfig?: ProxyConfig;
67+
public readonly simulateConnectionErrors: boolean;
6668

6769
public readonly forwarding?: ForwardingOptions;
6870
public readonly ignoreHostHttpsErrors: string[] | boolean = [];
@@ -98,6 +100,7 @@ export class PassThroughWebSocketHandlerDefinition extends Serializable implemen
98100

99101
this.lookupOptions = options.lookupOptions;
100102
this.proxyConfig = options.proxyConfig;
103+
this.simulateConnectionErrors = !!options.simulateConnectionErrors;
101104

102105
this.extraCACertificates =
103106
options.additionalTrustedCAs ||
@@ -121,6 +124,7 @@ export class PassThroughWebSocketHandlerDefinition extends Serializable implemen
121124
forwarding: this.forwarding,
122125
lookupOptions: this.lookupOptions,
123126
proxyConfig: serializeProxyConfig(this.proxyConfig, channel),
127+
simulateConnectionErrors: this.simulateConnectionErrors,
124128
ignoreHostCertificateErrors: this.ignoreHostHttpsErrors,
125129
extraCACertificates: this.extraCACertificates.map((certObject) => {
126130
// We use toString to make sure that buffers always end up as

src/rules/websockets/websocket-handlers.ts

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
WebSocketHandlerDefinition,
5252
WsHandlerDefinitionLookup,
5353
} from './websocket-handler-definitions';
54+
import { resetOrDestroy } from '../../util/socket-util';
5455

5556
export interface WebSocketHandler extends WebSocketHandlerDefinition {
5657
handle(
@@ -157,19 +158,38 @@ function pipeWebSocket(inSocket: WebSocket, outSocket: WebSocket) {
157158
});
158159
}
159160

160-
async function mirrorRejection(socket: net.Socket, rejectionResponse: http.IncomingMessage) {
161-
if (socket.writable) {
162-
const { statusCode, statusMessage, rawHeaders } = rejectionResponse;
163-
164-
socket.write(
165-
rawResponse(statusCode || 500, statusMessage || 'Unknown error', pairFlatRawHeaders(rawHeaders))
166-
);
167-
168-
const body = await streamToBuffer(rejectionResponse);
169-
if (socket.writable) socket.write(body);
170-
}
161+
function mirrorRejection(
162+
downstreamSocket: net.Socket,
163+
upstreamRejectionResponse: http.IncomingMessage,
164+
simulateConnectionErrors: boolean
165+
) {
166+
return new Promise<void>((resolve) => {
167+
if (downstreamSocket.writable) {
168+
const { statusCode, statusMessage, rawHeaders } = upstreamRejectionResponse;
169+
170+
downstreamSocket.write(
171+
rawResponse(statusCode || 500, statusMessage || 'Unknown error', pairFlatRawHeaders(rawHeaders))
172+
);
173+
174+
upstreamRejectionResponse.pipe(downstreamSocket);
175+
upstreamRejectionResponse.on('end', resolve);
176+
upstreamRejectionResponse.on('error', (error) => {
177+
console.warn('Error receiving WebSocket upstream rejection response:', error);
178+
if (simulateConnectionErrors) {
179+
resetOrDestroy(downstreamSocket);
180+
} else {
181+
downstreamSocket.destroy();
182+
}
183+
resolve();
184+
});
171185

172-
socket.destroy();
186+
// The socket is being optimistically written to and then killed - we don't care
187+
// about any more errors occuring here.
188+
downstreamSocket.on('error', () => {
189+
resolve();
190+
});
191+
}
192+
}).catch(() => {});
173193
}
174194

175195
const rawResponse = (
@@ -402,25 +422,29 @@ export class PassThroughWebSocketHandler extends PassThroughWebSocketHandlerDefi
402422
console.log(`Unexpected websocket response from ${wsUrl}: ${res.statusCode}`);
403423

404424
// Clean up the downstream connection
405-
mirrorRejection(incomingSocket, res);
406-
407-
// Clean up the upstream connection (WS would do this automatically, but doesn't if you listen to this event)
408-
// See https://github.com/websockets/ws/blob/45e17acea791d865df6b255a55182e9c42e5877a/lib/websocket.js#L1050
409-
// We don't match that perfectly, but this should be effectively equivalent:
410-
req.destroy();
411-
if (req.socket && !req.socket.destroyed) {
412-
res.socket.destroy();
413-
}
414-
unexpectedResponse = true; // So that we ignore this in the error handler
415-
upstreamWebSocket.terminate();
425+
mirrorRejection(incomingSocket, res, this.simulateConnectionErrors).then(() => {
426+
// Clean up the upstream connection (WS would do this automatically, but doesn't if you listen to this event)
427+
// See https://github.com/websockets/ws/blob/45e17acea791d865df6b255a55182e9c42e5877a/lib/websocket.js#L1050
428+
// We don't match that perfectly, but this should be effectively equivalent:
429+
req.destroy();
430+
if (res.socket?.destroyed === false) {
431+
res.socket.destroy();
432+
}
433+
unexpectedResponse = true; // So that we ignore this in the error handler
434+
upstreamWebSocket.terminate();
435+
});
416436
});
417437

418438
// If there's some other error, we just kill the socket:
419439
upstreamWebSocket.on('error', (e) => {
420440
if (unexpectedResponse) return; // Handled separately above
421441

422442
console.warn(e);
423-
incomingSocket.end();
443+
if (this.simulateConnectionErrors) {
444+
resetOrDestroy(incomingSocket);
445+
} else {
446+
incomingSocket.end();
447+
}
424448
});
425449

426450
incomingSocket.on('error', () => upstreamWebSocket.close(1011)); // Internal error
@@ -438,6 +462,7 @@ export class PassThroughWebSocketHandler extends PassThroughWebSocketHandlerDefi
438462
return _.create(this.prototype, {
439463
...data,
440464
proxyConfig: deserializeProxyConfig(data.proxyConfig, channel, ruleParams),
465+
simulateConnectionErrors: data.simulateConnectionErrors ?? false,
441466
extraCACertificates: data.extraCACertificates || [],
442467
ignoreHostHttpsErrors: data.ignoreHostCertificateErrors,
443468
clientCertificateHostMap: _.mapValues(data.clientCertificateHostMap,

0 commit comments

Comments
 (0)