mockttp-mvs
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
480 lines (399 loc) • 17.7 kB
text/typescript
import * as _ from 'lodash';
import net = require('net');
import * as url from 'url';
import * as tls from 'tls';
import * as http from 'http';
import * as fs from 'fs/promises';
import * as WebSocket from 'ws';
import CacheableLookup from 'cacheable-lookup';
import {
ClientServerChannel,
deserializeProxyConfig
} from "../../serialization/serialization";
import { OngoingRequest, RawHeaders } from "../../types";
import {
CloseConnectionHandler,
ResetConnectionHandler,
TimeoutHandler
} from '../requests/request-handlers';
import {
isHttp2
} from '../../util/request-utils';
import {
findRawHeader,
objectHeadersToRaw,
pairFlatRawHeaders,
rawHeadersToObjectPreservingCase
} from '../../util/header-utils';
import { streamToBuffer } from '../../util/buffer-utils';
import { isLocalhostAddress } from '../../util/socket-util';
import { MaybePromise } from '../../util/type-utils';
import { getAgent } from '../http-agents';
import { ProxySettingSource } from '../proxy-config';
import { assertParamDereferenced, RuleParameters } from '../rule-parameters';
import {
UPSTREAM_TLS_OPTIONS,
shouldUseStrictHttps
} from '../passthrough-handling';
import {
EchoWebSocketHandlerDefinition,
ListenWebSocketHandlerDefinition,
PassThroughWebSocketHandlerDefinition,
PassThroughWebSocketHandlerOptions,
RejectWebSocketHandlerDefinition,
SerializedPassThroughWebSocketData,
WebSocketHandlerDefinition,
WsHandlerDefinitionLookup,
} from './websocket-handler-definitions';
export interface WebSocketHandler extends WebSocketHandlerDefinition {
handle(
// The incoming upgrade request
request: OngoingRequest & http.IncomingMessage,
// The raw socket on which we'll be communicating
socket: net.Socket,
// Initial data received
head: Buffer
): Promise<void>;
}
export interface InterceptedWebSocket extends WebSocket {
upstreamWebSocket: WebSocket;
}
function isOpen(socket: WebSocket) {
return socket.readyState === WebSocket.OPEN;
}
// Based on ws's validation.js
function isValidStatusCode(code: number) {
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: WebSocket, outSocket: WebSocket) {
const onPipeFailed = (op: string) => (err?: Error) => {
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 as any;
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.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();
}
});
}
async function mirrorRejection(socket: net.Socket, rejectionResponse: http.IncomingMessage) {
if (socket.writable) {
const { statusCode, statusMessage, rawHeaders } = rejectionResponse;
socket.write(
rawResponse(statusCode || 500, statusMessage || 'Unknown error', pairFlatRawHeaders(rawHeaders))
);
const body = await streamToBuffer(rejectionResponse);
if (socket.writable) socket.write(body);
}
socket.destroy();
}
const rawResponse = (
statusCode: number,
statusMessage: string,
headers: RawHeaders = []
) =>
`HTTP/1.1 ${statusCode} ${statusMessage}\r\n` +
_.map(headers, ([key, value]) =>
`${key}: ${value}`
).join('\r\n') +
'\r\n\r\n';
export { PassThroughWebSocketHandlerOptions };
export class PassThroughWebSocketHandler extends PassThroughWebSocketHandlerDefinition {
private wsServer?: WebSocket.Server;
private initializeWsServer() {
if (this.wsServer) return;
this.wsServer = new WebSocket.Server({ noServer: true });
this.wsServer.on('connection', (ws: InterceptedWebSocket) => {
pipeWebSocket(ws, ws.upstreamWebSocket);
pipeWebSocket(ws.upstreamWebSocket, ws);
});
}
private _trustedCACertificates: MaybePromise<Array<string> | undefined>;
private async trustedCACertificates(): Promise<Array<string> | undefined> {
if (!this.extraCACertificates.length) return undefined;
if (!this._trustedCACertificates) {
this._trustedCACertificates = Promise.all(
(tls.rootCertificates as Array<string | Promise<string>>)
.concat(this.extraCACertificates.map(certObject => {
if ('cert' in certObject) {
return certObject.cert.toString('utf8');
} else {
return fs.readFile(certObject.certPath, 'utf8');
}
}))
);
}
return this._trustedCACertificates;
}
private _cacheableLookupInstance: CacheableLookup | undefined;
private lookup() {
if (!this.lookupOptions) return undefined;
if (!this._cacheableLookupInstance) {
this._cacheableLookupInstance = new CacheableLookup({
maxTtl: this.lookupOptions.maxTtl,
errorTtl: this.lookupOptions.errorTtl,
// As little caching of "use the fallback server" as possible:
fallbackDuration: 0
});
if (this.lookupOptions.servers) {
this._cacheableLookupInstance.servers = this.lookupOptions.servers;
}
}
return this._cacheableLookupInstance.lookup;
}
async handle(req: OngoingRequest, socket: net.Socket, head: Buffer) {
this.initializeWsServer();
let { protocol, hostname, port, path } = url.parse(req.url!);
const rawHeaders = req.rawHeaders;
const reqMessage = req as unknown as http.IncomingMessage;
const isH2Downstream = isHttp2(req);
const hostHeaderName = isH2Downstream ? ':authority' : 'host';
if (isLocalhostAddress(hostname) && req.remoteIpAddress && !isLocalhostAddress(req.remoteIpAddress)) {
// If we're proxying localhost traffic from another remote machine, then we should really be proxying
// back to that machine, not back to ourselves! Best example is docker containers: if we capture & inspect
// their localhost traffic, it should still be sent back into that docker container.
hostname = req.remoteIpAddress;
// We don't update the host header - from the POV of the target, it's still localhost traffic.
}
if (this.forwarding) {
const { targetHost, updateHostHeader } = this.forwarding;
let wsUrl: string;
if (!targetHost.includes('/')) {
// We're forwarding to a bare hostname, just overwrite that bit:
[hostname, port] = targetHost.split(':');
} else {
// Forwarding to a full URL; override the host & protocol, but never the path.
({ protocol, hostname, port } = url.parse(targetHost));
}
// Connect directly to the forwarding target URL
wsUrl = `${protocol!}//${hostname}${port ? ':' + port : ''}${path}`;
// Optionally update the host header too:
let hostHeader = findRawHeader(rawHeaders, hostHeaderName);
if (!hostHeader) {
// Should never happen really, but just in case:
hostHeader = [hostHeaderName, hostname!];
rawHeaders.unshift(hostHeader);
};
if (updateHostHeader === undefined || updateHostHeader === true) {
// If updateHostHeader is true, or just not specified, match the new target
hostHeader[1] = hostname + (port ? `:${port}` : '');
} else if (updateHostHeader) {
// If it's an explicit custom value, use that directly.
hostHeader[1] = updateHostHeader;
} // Otherwise: falsey means don't touch it.
await this.connectUpstream(wsUrl, reqMessage, rawHeaders, socket, head);
} else if (!hostname) { // No hostname in URL means transparent proxy, so use Host header
const hostHeader = req.headers[hostHeaderName];
[ hostname, port ] = hostHeader!.split(':');
// __lastHopEncrypted is set in http-combo-server, for requests that have explicitly
// CONNECTed upstream (which may then up/downgrade from the current encryption).
if (socket.__lastHopEncrypted !== undefined) {
protocol = socket.__lastHopEncrypted ? 'wss' : 'ws';
} else {
protocol = reqMessage.connection.encrypted ? 'wss' : 'ws';
}
const wsUrl = `${protocol}://${hostname}${port ? ':' + port : ''}${path}`;
await this.connectUpstream(wsUrl, reqMessage, rawHeaders, socket, head);
} else {
// Connect directly according to the specified URL
const wsUrl = `${
protocol!.replace('http', 'ws')
}//${hostname}${port ? ':' + port : ''}${path}`;
await this.connectUpstream(wsUrl, reqMessage, rawHeaders, socket, head);
}
}
private async connectUpstream(
wsUrl: string,
req: http.IncomingMessage,
rawHeaders: RawHeaders,
incomingSocket: net.Socket,
head: Buffer
) {
const parsedUrl = url.parse(wsUrl);
const checkServerCertificate = shouldUseStrictHttps(
parsedUrl.hostname!,
parsedUrl.port!,
this.ignoreHostHttpsErrors
);
const trustedCerts = await this.trustedCACertificates();
const caConfig = trustedCerts
? { ca: trustedCerts }
: {};
const effectivePort = !!parsedUrl.port
? parseInt(parsedUrl.port, 10)
: parsedUrl.protocol == 'wss:' ? 443 : 80;
const proxySettingSource = assertParamDereferenced(this.proxyConfig) as ProxySettingSource;
const agent = await getAgent({
protocol: parsedUrl.protocol as 'ws:' | 'wss:',
hostname: parsedUrl.hostname!,
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 = rawHeadersToObjectPreservingCase(rawHeaders);
const upstreamWebSocket = new WebSocket(wsUrl, {
maxPayload: 0,
agent,
lookup: this.lookup(),
headers: _.omitBy(headers, (_v, headerName) =>
headerName.toLowerCase().startsWith('sec-websocket') ||
headerName.toLowerCase() === 'connection' ||
headerName.toLowerCase() === 'upgrade'
) as { [key: string]: string }, // Simplify to string - doesn't matter though, only used by http module anyway
// TLS options:
...UPSTREAM_TLS_OPTIONS,
rejectUnauthorized: checkServerCertificate,
...caConfig
} as WebSocket.ClientOptions & { lookup: any, maxPayload: number });
upstreamWebSocket.once('open', () => {
this.wsServer!.handleUpgrade(req, incomingSocket, head, (ws) => {
(<InterceptedWebSocket> ws).upstreamWebSocket = upstreamWebSocket;
incomingSocket.emit('ws-upgrade', ws);
this.wsServer!.emit('connection', ws);
});
});
// If the upstream says no, we say no too.
upstreamWebSocket.on('unexpected-response', (req, res) => {
console.log(`Unexpected websocket response from ${wsUrl}: ${res.statusCode}`);
mirrorRejection(incomingSocket, res);
});
// If there's some other error, we just kill the socket:
upstreamWebSocket.on('error', (e) => {
console.warn(e);
incomingSocket.end();
});
incomingSocket.on('error', () => upstreamWebSocket.close(1011)); // Internal error
}
/**
* @internal
*/
static deserialize(
data: SerializedPassThroughWebSocketData,
channel: ClientServerChannel,
ruleParams: RuleParameters
): any {
// By default, we assume we just need to assign the right prototype
return _.create(this.prototype, {
...data,
extraCACertificates: data.extraCACertificates || [],
proxyConfig: deserializeProxyConfig(data.proxyConfig, channel, ruleParams),
ignoreHostHttpsErrors: data.ignoreHostCertificateErrors
});
}
}
export class EchoWebSocketHandler extends EchoWebSocketHandlerDefinition {
private wsServer?: WebSocket.Server;
private initializeWsServer() {
if (this.wsServer) return;
this.wsServer = new WebSocket.Server({ noServer: true });
this.wsServer.on('connection', (ws: WebSocket) => {
pipeWebSocket(ws, ws);
});
}
async handle(req: OngoingRequest & http.IncomingMessage, socket: net.Socket, head: Buffer) {
this.initializeWsServer();
this.wsServer!.handleUpgrade(req, socket, head, (ws) => {
socket.emit('ws-upgrade', ws);
this.wsServer!.emit('connection', ws);
});
}
}
export class ListenWebSocketHandler extends ListenWebSocketHandlerDefinition {
private wsServer?: WebSocket.Server;
private initializeWsServer() {
if (this.wsServer) return;
this.wsServer = new WebSocket.Server({ noServer: true });
this.wsServer.on('connection', (ws: WebSocket) => {
// Accept but ignore the incoming websocket data
ws.resume();
});
}
async handle(req: OngoingRequest & http.IncomingMessage, socket: net.Socket, head: Buffer) {
this.initializeWsServer();
this.wsServer!.handleUpgrade(req, socket, head, (ws) => {
socket.emit('ws-upgrade', ws);
this.wsServer!.emit('connection', ws);
});
}
}
export class RejectWebSocketHandler extends RejectWebSocketHandlerDefinition {
async handle(req: OngoingRequest, socket: net.Socket, head: Buffer) {
socket.write(rawResponse(this.statusCode, this.statusMessage, objectHeadersToRaw(this.headers)));
if (this.body) socket.write(this.body);
socket.write('\r\n');
socket.destroy();
}
}
// These three work equally well for HTTP requests as websockets, but it's
// useful to reexport there here for consistency.
export {
CloseConnectionHandler,
ResetConnectionHandler,
TimeoutHandler
};
export const WsHandlerLookup: typeof WsHandlerDefinitionLookup = {
'ws-passthrough': PassThroughWebSocketHandler,
'ws-echo': EchoWebSocketHandler,
'ws-listen': ListenWebSocketHandler,
'ws-reject': RejectWebSocketHandler,
'close-connection': CloseConnectionHandler,
'reset-connection': ResetConnectionHandler,
'timeout': TimeoutHandler
};