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();