UNPKG

mockttp

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

414 lines 20.7 kB
"use strict"; 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