mockttp
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
414 lines • 20.7 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.WsStepLookup = exports.DelayStepImpl = exports.TimeoutStepImpl = exports.ResetConnectionStepImpl = exports.CloseConnectionStepImpl = exports.RejectWebSocketStepImpl = exports.ListenWebSocketStepImpl = exports.EchoWebSocketStepImpl = exports.PassThroughWebSocketStepImpl = void 0;
const buffer_1 = require("buffer");
const url = require("url");
const _ = require("lodash");
const WebSocket = require("ws");
const serialization_1 = require("../../serialization/serialization");
const request_step_impls_1 = require("../requests/request-step-impls");
Object.defineProperty(exports, "CloseConnectionStepImpl", { enumerable: true, get: function () { return request_step_impls_1.CloseConnectionStepImpl; } });
Object.defineProperty(exports, "DelayStepImpl", { enumerable: true, get: function () { return request_step_impls_1.DelayStepImpl; } });
Object.defineProperty(exports, "ResetConnectionStepImpl", { enumerable: true, get: function () { return request_step_impls_1.ResetConnectionStepImpl; } });
Object.defineProperty(exports, "TimeoutStepImpl", { enumerable: true, get: function () { return request_step_impls_1.TimeoutStepImpl; } });
const url_1 = require("../../util/url");
const socket_util_1 = require("../../util/socket-util");
const request_utils_1 = require("../../util/request-utils");
const header_utils_1 = require("../../util/header-utils");
const http_agents_1 = require("../http-agents");
const rule_parameters_1 = require("../rule-parameters");
const passthrough_handling_1 = require("../passthrough-handling");
const websocket_step_definitions_1 = require("./websocket-step-definitions");
const match_replace_1 = require("../match-replace");
function isOpen(socket) {
return socket.readyState === WebSocket.OPEN;
}
// Based on ws's validation.js
function isValidStatusCode(code) {
return ( // Standard code:
code >= 1000 &&
code <= 1014 &&
code !== 1004 &&
code !== 1005 &&
code !== 1006) || ( // Application-specific code:
code >= 3000 && code <= 4999);
}
const INVALID_STATUS_REGEX = /Invalid WebSocket frame: invalid status code (\d+)/;
function pipeWebSocket(inSocket, outSocket) {
const onPipeFailed = (op) => (err) => {
if (!err)
return;
inSocket.close();
console.error(`Websocket ${op} failed`, err);
};
inSocket.on('message', (msg, isBinary) => {
if (isOpen(outSocket)) {
outSocket.send(msg, { binary: isBinary }, onPipeFailed('message'));
}
});
inSocket.on('close', (num, reason) => {
if (isValidStatusCode(num)) {
try {
outSocket.close(num, reason);
}
catch (e) {
console.warn(e);
outSocket.close();
}
}
else {
outSocket.close();
}
});
inSocket.on('ping', (data) => {
if (isOpen(outSocket))
outSocket.ping(data, undefined, onPipeFailed('ping'));
});
inSocket.on('pong', (data) => {
if (isOpen(outSocket))
outSocket.pong(data, undefined, onPipeFailed('pong'));
});
// 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);
const rawOutSocket = outSocket;
if (err.message.match(INVALID_STATUS_REGEX)) {
const status = parseInt(INVALID_STATUS_REGEX.exec(err.message)[1]);
// Simulate errors elsewhere by messing with ws internals. This may break things,
// that's effectively on purpose: we're simulating the client going wrong:
const buf = buffer_1.Buffer.allocUnsafe(2);
buf.writeUInt16BE(status); // status comes from readUInt16BE, so always fits
const sender = rawOutSocket._sender;
sender.sendFrame(sender.constructor.frame(buf, {
fin: true,
rsv1: false,
opcode: 0x08,
mask: true,
readOnly: false
}), () => {
rawOutSocket._socket.destroy();
});
}
else {
// Unknown error, just kill the connection with no explanation
rawOutSocket._socket.destroy();
}
});
}
function mirrorRejection(downstreamSocket, upstreamRejectionResponse, simulateConnectionErrors) {
return new Promise((resolve) => {
if (downstreamSocket.writable) {
const { statusCode, statusMessage, rawHeaders } = upstreamRejectionResponse;
downstreamSocket.write(rawResponse(statusCode || 500, statusMessage || 'Unknown error', (0, header_utils_1.pairFlatRawHeaders)(rawHeaders)));
upstreamRejectionResponse.pipe(downstreamSocket);
upstreamRejectionResponse.on('end', resolve);
upstreamRejectionResponse.on('error', (error) => {
console.warn('Error receiving WebSocket upstream rejection response:', error);
if (simulateConnectionErrors) {
(0, socket_util_1.resetOrDestroy)(downstreamSocket);
}
else {
downstreamSocket.destroy();
}
resolve();
});
// The socket is being optimistically written to and then killed - we don't care
// about any more errors occuring here.
downstreamSocket.on('error', () => {
resolve();
});
}
}).catch(() => { });
}
const rawResponse = (statusCode, statusMessage, headers = []) => `HTTP/1.1 ${statusCode} ${statusMessage}\r\n` +
_.map(headers, ([key, value]) => `${key}: ${value}`).join('\r\n') +
'\r\n\r\n';
class PassThroughWebSocketStepImpl extends websocket_step_definitions_1.PassThroughWebSocketStep {
initializeWsServer() {
if (this.wsServer)
return;
this.wsServer = new WebSocket.Server({
noServer: true,
// Mirror subprotocols back to the client:
handleProtocols(protocols, request) {
return request.upstreamWebSocketProtocol
// If there's no upstream socket, default to mirroring the first protocol. This matches
// WS's default behaviour - we could be stricter, but it'd be a breaking change.
?? protocols.values().next().value
?? false; // If there were no protocols specific and this is called for some reason
},
});
this.wsServer.on('connection', (ws) => {
pipeWebSocket(ws, ws.upstreamWebSocket);
pipeWebSocket(ws.upstreamWebSocket, ws);
});
}
async trustedCACertificates() {
if (!this.extraCACertificates.length)
return undefined;
if (!this._trustedCACertificates) {
this._trustedCACertificates = (0, passthrough_handling_1.getTrustedCAs)(undefined, this.extraCACertificates);
}
return this._trustedCACertificates;
}
async handle(req, socket, head, options) {
this.initializeWsServer();
let reqUrl = req.url;
let { protocol, pathname, search: query } = url.parse(reqUrl);
let rawHeaders = req.rawHeaders;
// Actual IP address or hostname
let hostAddress = req.destination.hostname;
// Same as hostAddress, unless it's an IP, in which case it's our best guess of the
// functional 'name' for the host (from Host header or SNI).
let hostname = (0, passthrough_handling_1.getEffectiveHostname)(hostAddress, socket, rawHeaders);
let port = req.destination.port.toString();
const reqMessage = req;
const isH2Downstream = (0, request_utils_1.isHttp2)(req);
hostAddress = await (0, passthrough_handling_1.getClientRelativeHostname)(hostAddress, req.remoteIpAddress, (0, passthrough_handling_1.getDnsLookupFunction)(this.lookupOptions));
if (this.transformRequest) {
const originalHostname = hostname;
({ protocol, hostname, port, reqUrl, rawHeaders } = (0, passthrough_handling_1.applyDestinationTransforms)(this.transformRequest, {
isH2Downstream,
rawHeaders,
port,
protocol,
hostname,
pathname,
query
}));
// If you modify the hostname, we also treat that as modifying the
// resulting destination in turn:
if (hostname !== originalHostname) {
hostAddress = hostname;
}
}
const destination = {
hostname: hostAddress,
port: port
? parseInt(port, 10)
: (0, url_1.getDefaultPort)(protocol ?? 'http')
};
await this.connectUpstream(destination, reqUrl, reqMessage, rawHeaders, socket, head, options);
}
async connectUpstream(destination, wsUrl, req, rawHeaders, incomingSocket, head, options) {
const parsedUrl = url.parse(wsUrl);
const effectiveHostname = parsedUrl.hostname; // N.b. not necessarily the same as destination
const effectivePort = (0, url_1.getEffectivePort)(parsedUrl);
const trustedCAs = await this.trustedCACertificates();
const proxySettingSource = (0, rule_parameters_1.assertParamDereferenced)(this.proxyConfig);
const agent = await (0, http_agents_1.getAgent)({
protocol: parsedUrl.protocol,
hostname: effectiveHostname,
port: effectivePort,
proxySettingSource,
tryHttp2: false, // We don't support websockets over H2 yet
keepAlive: false // Not a thing for websockets: they take over the whole connection
});
// We have to flatten the headers, as WS doesn't support raw headers - it builds its own
// header object internally.
const headers = (0, header_utils_1.rawHeadersToObjectPreservingCase)(rawHeaders);
// Subprotocols have to be handled explicitly. WS takes control of the headers itself,
// and checks the response, so we need to parse the client headers and use them manually:
const originalSubprotocols = (0, header_utils_1.findRawHeaders)(rawHeaders, 'sec-websocket-protocol')
.flatMap(([_k, value]) => value.split(',').map(p => p.trim()));
// Drop empty subprotocols, to better handle mildly badly behaved clients
const filteredSubprotocols = originalSubprotocols.filter(p => !!p);
// If the subprotocols are invalid (there are some empty strings, or an entirely empty value) then
// WS will reject the upgrade. With this, we reset the header to the 'equivalent' valid version, to
// avoid unnecessarily rejecting clients who send mildly wrong headers (empty protocol values).
if (originalSubprotocols.length !== filteredSubprotocols.length) {
if (filteredSubprotocols.length) {
// Note that req.headers is auto-lowercased by Node, so we can ignore case
req.headers['sec-websocket-protocol'] = filteredSubprotocols.join(',');
}
else {
delete req.headers['sec-websocket-protocol'];
}
}
const upstreamWebSocket = new WebSocket(wsUrl, filteredSubprotocols, {
host: destination.hostname,
port: destination.port,
maxPayload: 0,
agent,
lookup: (0, passthrough_handling_1.getDnsLookupFunction)(this.lookupOptions),
headers: _.omitBy(headers, (_v, headerName) => headerName.toLowerCase().startsWith('sec-websocket') ||
headerName.toLowerCase() === 'connection' ||
headerName.toLowerCase() === 'upgrade'), // Simplify to string - doesn't matter though, only used by http module anyway
// TLS options:
...(0, passthrough_handling_1.getUpstreamTlsOptions)({
hostname: effectiveHostname,
port: effectivePort,
ignoreHostHttpsErrors: this.ignoreHostHttpsErrors,
clientCertificateHostMap: this.clientCertificateHostMap,
trustedCAs,
})
});
if (options.emitEventCallback) {
const upstreamReq = upstreamWebSocket._req;
// This is slower than req.getHeaders(), but gives us (roughly) the correct casing
// of the headers as sent. Still not perfect (loses dupe ordering) but at least it
// generally matches what's actually sent on the wire.
const rawHeaders = upstreamReq.getRawHeaderNames().map((headerName) => {
const value = upstreamReq.getHeader(headerName);
if (!value)
return [];
if (Array.isArray(value)) {
return value.map(v => [headerName, v]);
}
else {
return [[headerName, value.toString()]];
}
}).flat();
// This effectively matches the URL preprocessing logic in MockttpServer.preprocessRequest,
// so that the resulting event matches the req.url property elsewhere.
const urlHost = (0, passthrough_handling_1.getEffectiveHostname)(upstreamReq.host, req.socket, rawHeaders);
options.emitEventCallback('passthrough-websocket-connect', {
method: upstreamReq.method,
protocol: upstreamReq.protocol
.replace(/:$/, '')
.replace(/^http/, 'ws'),
hostname: urlHost,
port: effectivePort.toString(),
path: upstreamReq.path,
rawHeaders: rawHeaders,
subprotocols: filteredSubprotocols
});
}
upstreamWebSocket.once('open', () => {
// Used in the subprotocol selection handler during the upgrade:
req.upstreamWebSocketProtocol = upstreamWebSocket.protocol || false;
this.wsServer.handleUpgrade(req, incomingSocket, head, (ws) => {
ws.upstreamWebSocket = upstreamWebSocket;
incomingSocket.emit('ws-upgrade', ws);
this.wsServer.emit('connection', ws); // This pipes the connections together
});
});
// If the upstream says no, we say no too.
let unexpectedResponse = false;
upstreamWebSocket.on('unexpected-response', (req, res) => {
console.log(`Unexpected websocket response from ${wsUrl}: ${res.statusCode}`);
// Clean up the downstream connection
mirrorRejection(incomingSocket, res, this.simulateConnectionErrors).then(() => {
// Clean up the upstream connection (WS would do this automatically, but doesn't if you listen to this event)
// See https://github.com/websockets/ws/blob/45e17acea791d865df6b255a55182e9c42e5877a/lib/websocket.js#L1050
// We don't match that perfectly, but this should be effectively equivalent:
req.destroy();
if (res.socket?.destroyed === false) {
res.socket.destroy();
}
unexpectedResponse = true; // So that we ignore this in the error handler
upstreamWebSocket.terminate();
});
});
// If there's some other error, we just kill the socket:
upstreamWebSocket.on('error', (e) => {
if (unexpectedResponse)
return; // Handled separately above
console.warn(e);
if (this.simulateConnectionErrors) {
(0, socket_util_1.resetOrDestroy)(incomingSocket);
}
else {
incomingSocket.end();
}
});
incomingSocket.on('error', () => upstreamWebSocket.close(1011)); // Internal error
}
/**
* @internal
*/
static deserialize(data, channel, { ruleParams }) {
// Backward compat for old clients:
if (data.forwarding && !data.transformRequest?.replaceHost) {
const [targetHost, setProtocol] = data.forwarding.targetHost.split('://').reverse();
data.transformRequest ?? (data.transformRequest = {});
data.transformRequest.replaceHost = {
targetHost,
updateHostHeader: data.forwarding.updateHostHeader ?? true
};
data.transformRequest.setProtocol = setProtocol;
}
return _.create(this.prototype, {
...data,
proxyConfig: (0, serialization_1.deserializeProxyConfig)(data.proxyConfig, channel, ruleParams),
simulateConnectionErrors: data.simulateConnectionErrors ?? false,
extraCACertificates: data.extraCACertificates || [],
ignoreHostHttpsErrors: data.ignoreHostCertificateErrors,
clientCertificateHostMap: _.mapValues(data.clientCertificateHostMap, ({ pfx, passphrase }) => ({ pfx: (0, serialization_1.deserializeBuffer)(pfx), passphrase })),
transformRequest: data.transformRequest ? {
...data.transformRequest,
...(data.transformRequest?.matchReplaceHost !== undefined ? {
matchReplaceHost: {
...data.transformRequest.matchReplaceHost,
replacements: (0, match_replace_1.deserializeMatchReplaceConfiguration)(data.transformRequest.matchReplaceHost.replacements)
}
} : {}),
...(data.transformRequest?.matchReplacePath !== undefined ? {
matchReplacePath: (0, match_replace_1.deserializeMatchReplaceConfiguration)(data.transformRequest.matchReplacePath)
} : {}),
...(data.transformRequest?.matchReplaceQuery !== undefined ? {
matchReplaceQuery: (0, match_replace_1.deserializeMatchReplaceConfiguration)(data.transformRequest.matchReplaceQuery)
} : {}),
} : undefined
});
}
}
exports.PassThroughWebSocketStepImpl = PassThroughWebSocketStepImpl;
class EchoWebSocketStepImpl extends websocket_step_definitions_1.EchoWebSocketStep {
initializeWsServer() {
if (this.wsServer)
return;
this.wsServer = new WebSocket.Server({ noServer: true });
this.wsServer.on('connection', (ws) => {
pipeWebSocket(ws, ws);
});
}
async handle(req, socket, head) {
this.initializeWsServer();
this.wsServer.handleUpgrade(req, socket, head, (ws) => {
socket.emit('ws-upgrade', ws);
this.wsServer.emit('connection', ws);
});
}
}
exports.EchoWebSocketStepImpl = EchoWebSocketStepImpl;
class ListenWebSocketStepImpl extends websocket_step_definitions_1.ListenWebSocketStep {
initializeWsServer() {
if (this.wsServer)
return;
this.wsServer = new WebSocket.Server({ noServer: true });
this.wsServer.on('connection', (ws) => {
// Accept but ignore the incoming websocket data
ws.resume();
});
}
async handle(req, socket, head) {
this.initializeWsServer();
this.wsServer.handleUpgrade(req, socket, head, (ws) => {
socket.emit('ws-upgrade', ws);
this.wsServer.emit('connection', ws);
});
}
}
exports.ListenWebSocketStepImpl = ListenWebSocketStepImpl;
class RejectWebSocketStepImpl extends websocket_step_definitions_1.RejectWebSocketStep {
async handle(req, socket) {
socket.write(rawResponse(this.statusCode, this.statusMessage, (0, header_utils_1.objectHeadersToRaw)(this.headers)));
if (this.body)
socket.end(this.body);
socket.destroy();
}
}
exports.RejectWebSocketStepImpl = RejectWebSocketStepImpl;
exports.WsStepLookup = {
'ws-passthrough': PassThroughWebSocketStepImpl,
'ws-echo': EchoWebSocketStepImpl,
'ws-listen': ListenWebSocketStepImpl,
'ws-reject': RejectWebSocketStepImpl,
'close-connection': request_step_impls_1.CloseConnectionStepImpl,
'reset-connection': request_step_impls_1.ResetConnectionStepImpl,
'timeout': request_step_impls_1.TimeoutStepImpl,
'delay': request_step_impls_1.DelayStepImpl
};
//# sourceMappingURL=websocket-step-impls.js.map