Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/rules/passthrough-handling-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/rules/requests/request-step-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,7 @@ export interface SerializedPassThroughData {
lookupOptions?: PassThroughLookupOptions;
simulateConnectionErrors?: boolean;
mirrorTlsFingerprint?: boolean;
logUpstreamErrors?: boolean;

transformRequest?: Replace<RequestTransform, {
'replaceBody'?: string, // Serialized as base64 buffer
Expand Down Expand Up @@ -759,6 +760,8 @@ export class PassThroughStep extends Serializable implements RequestStepDefiniti

public readonly mirrorTlsFingerprint: boolean;

public readonly logUpstreamErrors: boolean;

constructor(options: PassThroughStepOptions = {}) {
super();

Expand All @@ -771,6 +774,7 @@ export class PassThroughStep extends Serializable implements RequestStepDefiniti
this.proxyConfig = options.proxyConfig;
this.simulateConnectionErrors = !!options.simulateConnectionErrors;
this.mirrorTlsFingerprint = !!options.mirrorTlsFingerprint;
this.logUpstreamErrors = options.logUpstreamErrors ?? true;

this.extraCACertificates = options.additionalTrustedCAs || [];

Expand Down Expand Up @@ -924,6 +928,7 @@ export class PassThroughStep extends Serializable implements RequestStepDefiniti
lookupOptions: this.lookupOptions,
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
Expand Down
1 change: 1 addition & 0 deletions src/rules/requests/request-step-impls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/rules/websockets/websocket-step-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
Expand All @@ -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: {
Expand All @@ -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 || {};
Expand Down Expand Up @@ -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
Expand Down
42 changes: 29 additions & 13 deletions src/rules/websockets/websocket-step-impls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 {
Expand All @@ -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)) {
Expand Down Expand Up @@ -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<void>((resolve) => {
if (downstreamSocket.writable) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
});
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
}

Expand Down
83 changes: 83 additions & 0 deletions test/integration/websockets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const ws = new WebSocket(`ws://localhost:${wsPort}`, {
agent: new HttpProxyAgent(`http://localhost:${mockServer.port}`)
});

await new Promise<void>((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();

Expand Down
Loading