From a94de665b8787149f822a8e6a12a76334b8fc70b Mon Sep 17 00:00:00 2001 From: theluckystrike <51033404+theluckystrike@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:06:03 +0700 Subject: [PATCH] Add a logUpstreamErrors passthrough option to quiet upstream WebSocket errors Defaults to true. When false, the WebSocket passthrough skips its console output for upstream failures (pipeWebSocket, mirrorRejection and the upstream request handlers). Request passthrough accepts and serializes the same shared option. Fixes #148 --- src/rules/passthrough-handling-definitions.ts | 11 +++ .../requests/request-step-definitions.ts | 5 ++ src/rules/requests/request-step-impls.ts | 1 + .../websockets/websocket-step-definitions.ts | 4 + src/rules/websockets/websocket-step-impls.ts | 42 +++++++--- test/integration/websockets.spec.ts | 83 +++++++++++++++++++ 6 files changed, 133 insertions(+), 13 deletions(-) diff --git a/src/rules/passthrough-handling-definitions.ts b/src/rules/passthrough-handling-definitions.ts index 999b1fa7a..d51a118e5 100644 --- a/src/rules/passthrough-handling-definitions.ts +++ b/src/rules/passthrough-handling-definitions.ts @@ -135,6 +135,17 @@ export interface PassThroughStepConnectionOptions { * Defaults to false. */ mirrorTlsFingerprint?: boolean; + + /** + * Whether to log low-level upstream connection errors (e.g. ECONNRESET, EPIPE) to the + * console when a passthrough rule fails to reach the target server. + * + * These errors are often expected in normal use (clients disconnect, servers reset + * connections, etc), so this can be set to `false` to keep the console output clean. + * + * Defaults to true. + */ + logUpstreamErrors?: boolean; } /** diff --git a/src/rules/requests/request-step-definitions.ts b/src/rules/requests/request-step-definitions.ts index 7cc1fb987..e4233f605 100644 --- a/src/rules/requests/request-step-definitions.ts +++ b/src/rules/requests/request-step-definitions.ts @@ -683,6 +683,7 @@ export interface SerializedPassThroughData { lookupOptions?: PassThroughLookupOptions; simulateConnectionErrors?: boolean; mirrorTlsFingerprint?: boolean; + logUpstreamErrors?: boolean; transformRequest?: Replace { // We use toString to make sure that buffers always end up as diff --git a/src/rules/requests/request-step-impls.ts b/src/rules/requests/request-step-impls.ts index 89e04b634..3980e3d1a 100644 --- a/src/rules/requests/request-step-impls.ts +++ b/src/rules/requests/request-step-impls.ts @@ -1435,6 +1435,7 @@ export class PassThroughStepImpl extends PassThroughStep { lookupOptions: data.lookupOptions, simulateConnectionErrors: !!data.simulateConnectionErrors, mirrorTlsFingerprint: !!data.mirrorTlsFingerprint, + logUpstreamErrors: data.logUpstreamErrors ?? true, ignoreHostHttpsErrors: data.ignoreHostCertificateErrors, additionalTrustedCAs: data.extraCACertificates, clientCertificateHostMap: _.mapValues(data.clientCertificateHostMap, diff --git a/src/rules/websockets/websocket-step-definitions.ts b/src/rules/websockets/websocket-step-definitions.ts index 6537a15bd..f3efc8c8a 100644 --- a/src/rules/websockets/websocket-step-definitions.ts +++ b/src/rules/websockets/websocket-step-definitions.ts @@ -79,6 +79,7 @@ export interface SerializedPassThroughWebSocketData { proxyConfig?: SerializedProxyConfig; simulateConnectionErrors?: boolean; mirrorTlsFingerprint?: boolean; + logUpstreamErrors?: boolean; ignoreHostCertificateErrors?: string[] | boolean; // Doesn't match option name, backward compat extraCACertificates?: Array<{ cert: string } | { certPath: string }>; clientCertificateHostMap?: { [host: string]: { pfx: string, passphrase?: string } }; @@ -103,6 +104,7 @@ export class PassThroughWebSocketStep extends Serializable implements WebSocketS public readonly proxyConfig?: ProxyConfig; public readonly simulateConnectionErrors: boolean; public readonly mirrorTlsFingerprint: boolean; + public readonly logUpstreamErrors: boolean; public readonly ignoreHostHttpsErrors: string[] | boolean = []; public readonly clientCertificateHostMap: { @@ -125,6 +127,7 @@ export class PassThroughWebSocketStep extends Serializable implements WebSocketS this.proxyConfig = options.proxyConfig; this.simulateConnectionErrors = !!options.simulateConnectionErrors; this.mirrorTlsFingerprint = !!options.mirrorTlsFingerprint; + this.logUpstreamErrors = options.logUpstreamErrors ?? true; this.extraCACertificates = options.additionalTrustedCAs || []; this.clientCertificateHostMap = options.clientCertificateHostMap || {}; @@ -182,6 +185,7 @@ export class PassThroughWebSocketStep extends Serializable implements WebSocketS proxyConfig: serializeProxyConfig(this.proxyConfig, channel), simulateConnectionErrors: this.simulateConnectionErrors, mirrorTlsFingerprint: this.mirrorTlsFingerprint, + logUpstreamErrors: this.logUpstreamErrors, ignoreHostCertificateErrors: this.ignoreHostHttpsErrors, extraCACertificates: this.extraCACertificates.map((certObject) => { // We use toString to make sure that buffers always end up as diff --git a/src/rules/websockets/websocket-step-impls.ts b/src/rules/websockets/websocket-step-impls.ts index 9af1c507a..199457412 100644 --- a/src/rules/websockets/websocket-step-impls.ts +++ b/src/rules/websockets/websocket-step-impls.ts @@ -118,12 +118,14 @@ function isValidStatusCode(code: number) { const INVALID_STATUS_REGEX = /Invalid WebSocket frame: invalid status code (\d+)/; -function pipeWebSocket(inSocket: WebSocket, outSocket: WebSocket) { +function pipeWebSocket(inSocket: WebSocket, outSocket: WebSocket, logUpstreamErrors: boolean) { const onPipeFailed = (op: string) => (err?: Error) => { if (!err) return; inSocket.close(); - console.error(`Websocket ${op} failed`, err); + if (logUpstreamErrors) { + console.error(`Websocket ${op} failed`, err); + } }; inSocket.on('message', (msg, isBinary) => { @@ -137,7 +139,9 @@ function pipeWebSocket(inSocket: WebSocket, outSocket: WebSocket) { try { outSocket.close(num, reason); } catch (e) { - console.warn(e); + if (logUpstreamErrors) { + console.warn(e); + } outSocket.close(); } } else { @@ -156,7 +160,9 @@ function pipeWebSocket(inSocket: WebSocket, outSocket: WebSocket) { // If either socket has an general error (connection failure, but also could be invalid WS // frames) then we kill the raw connection upstream to simulate a generic connection error: inSocket.on('error', (err) => { - console.log(`Error in proxied WebSocket:`, err); + if (logUpstreamErrors) { + console.log(`Error in proxied WebSocket:`, err); + } const rawOutSocket = outSocket as any; if (err.message.match(INVALID_STATUS_REGEX)) { @@ -186,7 +192,8 @@ function pipeWebSocket(inSocket: WebSocket, outSocket: WebSocket) { function mirrorRejection( downstreamSocket: net.Socket, upstreamRejectionResponse: http.IncomingMessage, - simulateConnectionErrors: boolean + simulateConnectionErrors: boolean, + logUpstreamErrors: boolean ) { return new Promise((resolve) => { if (downstreamSocket.writable) { @@ -199,7 +206,9 @@ function mirrorRejection( upstreamRejectionResponse.pipe(downstreamSocket); upstreamRejectionResponse.on('end', resolve); upstreamRejectionResponse.on('error', (error) => { - console.warn('Error receiving WebSocket upstream rejection response:', error); + if (logUpstreamErrors) { + console.warn('Error receiving WebSocket upstream rejection response:', error); + } if (simulateConnectionErrors) { resetOrDestroy(downstreamSocket); } else { @@ -275,8 +284,8 @@ export class PassThroughWebSocketStepImpl extends PassThroughWebSocketStep { skipUTF8Validation: true // Preserve even invalid weird stuff }); this.wsServer.on('connection', (ws: InterceptedWebSocket) => { - pipeWebSocket(ws, ws.upstreamWebSocket); - pipeWebSocket(ws.upstreamWebSocket, ws); + pipeWebSocket(ws, ws.upstreamWebSocket, this.logUpstreamErrors); + pipeWebSocket(ws.upstreamWebSocket, ws, this.logUpstreamErrors); }); } @@ -474,7 +483,9 @@ export class PassThroughWebSocketStepImpl extends PassThroughWebSocketStep { } } } catch (e) { - console.warn('Failed to negotiate WebSocket extensions:', e); + if (this.logUpstreamErrors) { + console.warn('Failed to negotiate WebSocket extensions:', e); + } upstreamSocket.destroy(); incomingSocket.end(); return; @@ -509,12 +520,16 @@ export class PassThroughWebSocketStepImpl extends PassThroughWebSocketStep { }); upstreamReq.on('response', (upstreamRes) => { - console.log(`Unexpected websocket response from ${wsUrl}: ${upstreamRes.statusCode}`); - mirrorRejection(incomingSocket, upstreamRes, this.simulateConnectionErrors); + if (this.logUpstreamErrors) { + console.log(`Unexpected websocket response from ${wsUrl}: ${upstreamRes.statusCode}`); + } + mirrorRejection(incomingSocket, upstreamRes, this.simulateConnectionErrors, this.logUpstreamErrors); }); upstreamReq.on('error', (e) => { - console.warn(e); + if (this.logUpstreamErrors) { + console.warn(e); + } if (this.simulateConnectionErrors) { resetOrDestroy(incomingSocket); } else { @@ -557,6 +572,7 @@ export class PassThroughWebSocketStepImpl extends PassThroughWebSocketStep { proxyConfig: deserializeProxyConfig(data.proxyConfig, channel, ruleParams), simulateConnectionErrors: data.simulateConnectionErrors ?? false, mirrorTlsFingerprint: data.mirrorTlsFingerprint ?? false, + logUpstreamErrors: data.logUpstreamErrors ?? true, extraCACertificates: data.extraCACertificates || [], ignoreHostHttpsErrors: data.ignoreHostCertificateErrors, clientCertificateHostMap: _.mapValues(data.clientCertificateHostMap, @@ -594,7 +610,7 @@ export class EchoWebSocketStepImpl extends EchoWebSocketStep { skipUTF8Validation: true // Preserve even invalid weird stuff }); this.wsServer.on('connection', (ws: WebSocket) => { - pipeWebSocket(ws, ws); + pipeWebSocket(ws, ws, true); }); } diff --git a/test/integration/websockets.spec.ts b/test/integration/websockets.spec.ts index 8f4a94930..d1cc72e1b 100644 --- a/test/integration/websockets.spec.ts +++ b/test/integration/websockets.spec.ts @@ -348,6 +348,89 @@ nodeOnly(() => { wsErrors = []; // Clear this, so the test passes, since it's expected }); + describe("with upstream error logging control", () => { + + let originalConsoleMethods: { + warn: (...args: any[]) => void, + error: (...args: any[]) => void, + log: (...args: any[]) => void + }; + let consoleCalls: string[]; + + // Capture console output so we can assert it's gated as expected + beforeEach(() => { + consoleCalls = []; + originalConsoleMethods = { + warn: console.warn, + error: console.error, + log: console.log + }; + console.warn = (...args: any[]) => consoleCalls.push(`warn: ${args[0]}`); + console.error = (...args: any[]) => consoleCalls.push(`error: ${args[0]}`); + console.log = (...args: any[]) => consoleCalls.push(`log: ${args[0]}`); + }); + + afterEach(() => { + console.warn = originalConsoleMethods.warn; + console.error = originalConsoleMethods.error; + console.log = originalConsoleMethods.log; + // We deliberately trigger upstream WebSocket errors; clear the + // expected error so the outer afterEach's empty-check passes. + wsErrors = []; + }); + + // Trigger an upstream WebSocket error by sending an invalid client frame, + // then killing the raw socket (mirrors the test above). This makes the + // mockttp proxy's pipeWebSocket error handler want to log to the console. + async function triggerUpstreamWsError(): Promise { + const ws = new WebSocket(`ws://localhost:${wsPort}`, { + agent: new HttpProxyAgent(`http://localhost:${mockServer.port}`) + }); + + await new Promise((resolve) => { + ws.on('open', () => { + const rawWs = ws as any; + + // Only measure console output caused by the error itself: + consoleCalls.length = 0; + + const buf = Buffer.allocUnsafe(2); + buf.writeUInt16BE(0); + const sender = rawWs._sender; + sender.sendFrame(sender.constructor.frame(buf, { + fin: true, + rsv1: false, + opcode: 0x08, + mask: true, + readOnly: false + }), () => { + rawWs._socket.end(); + resolve(); + }); + }); + }); + wsErrors = []; + } + + it("logs nothing for upstream errors when logUpstreamErrors is false", async () => { + mockServer.forAnyWebSocket().thenPassThrough({ logUpstreamErrors: false }); + + await triggerUpstreamWsError(); + await delay(50); + + expect(consoleCalls).to.be.empty; + }); + + it("still logs upstream errors by default (logUpstreamErrors true)", async () => { + mockServer.forAnyWebSocket().thenPassThrough(); + + await triggerUpstreamWsError(); + await delay(50); + + expect(consoleCalls.length).to.be.greaterThan(0); + }); + }); + it('can be passed through successfully over HTTPS', async () => { mockServer.forAnyWebSocket().thenPassThrough();