Path: blob/master/lib/rammerhead/src/classes/RammerheadProxy.js
5253 views
const http = require('http');1const https = require('https');2const stream = require('stream');3const fs = require('fs');4const path = require('path');5const { getPathname } = require('testcafe-hammerhead/lib/utils/url');6const { Proxy } = require('testcafe-hammerhead');7const WebSocket = require('ws');8const httpResponse = require('../util/httpResponse');9const streamToString = require('../util/streamToString');10const URLPath = require('../util/URLPath');11const RammerheadLogging = require('../classes/RammerheadLogging');1213require('../util/fixCorsHeader');14require('../util/fixWebsocket');15require('../util/addMoreErrorGuards');16require('../util/addUrlShuffling');17require('../util/patchAsyncResourceProcessor');18let addJSDiskCache = function (path, size) {19require('../util/addJSDiskCache')(path, size);20// modification only works once21addJSDiskCache = () => {};22};2324/**25* taken directly from26* https://github.com/DevExpress/testcafe-hammerhead/blob/a9fbf7746ff347f7bdafe1f80cf7135eeac21e34/src/typings/proxy.d.ts#L127* @typedef {object} ServerInfo28* @property {string} hostname29* @property {number} port30* @property {number} crossDomainPort31* @property {string} protocol32* @property {string} domain33* @property {boolean} cacheRequests34*/3536/**37* @typedef {object} RammerheadServerInfo38* @property {string} hostname39* @property {number} port40* @property {'https:'|'http:'} protocol41*/4243/**44* @private45* @typedef {import('./RammerheadSession')} RammerheadSession46*/4748/**49* wrapper for hammerhead's Proxy50*/51class RammerheadProxy extends Proxy {52/**53*54* @param {object} options55* @param {RammerheadLogging|undefined} options.logger56* @param {(req: http.IncomingMessage) => string} options.loggerGetIP - use custom logic to get IP, either from headers or directly57* @param {string} options.bindingAddress - hostname for proxy to bind to58* @param {number} options.port - port for proxy to listen to59* @param {number|null} options.crossDomainPort - crossDomain port to simulate cross origin requests. set to null60* to disable using this. highly not recommended to disable this because it breaks sites that check for the origin header61* @param {boolean} options.dontListen - avoid calling http.listen() if you need to use sticky-session to load balance62* @param {http.ServerOptions} options.ssl - set to null to disable ssl63* @param {(req: http.IncomingMessage) => RammerheadServerInfo} options.getServerInfo - force hammerhead to rewrite using specified64* server info (server info includes hostname, port, and protocol). Useful for a reverse proxy setup like nginx where you65* need to rewrite the hostname/port/protocol66* @param {boolean} options.disableLocalStorageSync - disables localStorage syncing (default: false)67* @param {string} options.diskJsCachePath - set to null to disable disk cache and use memory instead (disabled by default)68* @param {number} options.jsCacheSize - in bytes. default: 50mb69*/70constructor({71loggerGetIP = (req) => req.socket.remoteAddress,72logger = new RammerheadLogging({ logLevel: 'disabled' }),73bindingAddress = '127.0.0.1',74port = 8080,75crossDomainPort = 8081,76dontListen = false,77ssl = null,78getServerInfo = (req) => {79const { hostname, port } = new URL('http://' + req.headers.host);80return {81hostname,82port,83protocol: req.socket.encrypted ? 'https:' : 'http:'84};85},86disableLocalStorageSync = false,87diskJsCachePath = null,88jsCacheSize = 50 * 1024 * 102489} = {}) {90if (!crossDomainPort) {91const httpOrHttps = ssl ? https : http;92const proxyHttpOrHttps = http;93const originalProxyCreateServer = proxyHttpOrHttps.createServer;94const originalCreateServer = httpOrHttps.createServer; // handle recursion case if proxyHttpOrHttps and httpOrHttps are the same95let onlyOneHttpServer = null;9697// a hack to force testcafe-hammerhead's proxy library into using only one http port.98// a downside to using only one proxy server is that crossdomain requests99// will not be simulated correctly100proxyHttpOrHttps.createServer = function (...args) {101const emptyFunc = () => {};102if (onlyOneHttpServer) {103// createServer for server1 already called. now we return a mock http server for server2104return { on: emptyFunc, listen: emptyFunc, close: emptyFunc };105}106if (args.length !== 2) throw new Error('unexpected argument length coming from hammerhead');107return (onlyOneHttpServer = originalCreateServer(...args));108};109110// now, we force the server to listen to a specific port and a binding address, regardless of what111// hammerhead server.listen(anything)112const originalListen = http.Server.prototype.listen;113http.Server.prototype.listen = function (_proxyPort) {114if (dontListen) return;115originalListen.call(this, port, bindingAddress);116};117118// actual proxy initialization119// the values don't matter (except for developmentMode), since we'll be rewriting serverInfo anyway120super('hostname', 'port', 'port', {121ssl,122developmentMode: true,123cache: true124});125126// restore hooked functions to their original state127proxyHttpOrHttps.createServer = originalProxyCreateServer;128http.Server.prototype.listen = originalListen;129} else {130// just initialize the proxy as usual, since we don't need to do hacky stuff like the above.131// we still need to make sure the proxy binds to the correct address though132const originalListen = http.Server.prototype.listen;133http.Server.prototype.listen = function (portArg) {134if (dontListen) return;135originalListen.call(this, portArg, bindingAddress);136};137super('doesntmatter', port, crossDomainPort, {138ssl,139developmentMode: true,140cache: true141});142this.crossDomainPort = crossDomainPort;143http.Server.prototype.listen = originalListen;144}145146this._setupRammerheadServiceRoutes();147this._setupLocalStorageServiceRoutes(disableLocalStorageSync);148149this.onRequestPipeline = [];150this.onUpgradePipeline = [];151this.websocketRoutes = [];152this.rewriteServerHeaders = {153'permissions-policy': (headerValue) => headerValue && headerValue.replace(/sync-xhr/g, 'sync-yes'),154'feature-policy': (headerValue) => headerValue && headerValue.replace(/sync-xhr/g, 'sync-yes'),155'referrer-policy': () => 'no-referrer-when-downgrade',156'report-to': () => undefined,157'cross-origin-embedder-policy': () => undefined158};159160this.getServerInfo = getServerInfo;161this.serverInfo1 = null; // make sure no one uses these serverInfo162this.serverInfo2 = null;163164this.loggerGetIP = loggerGetIP;165this.logger = logger;166167addJSDiskCache(diskJsCachePath, jsCacheSize);168}169170// add WS routing171/**172* since we have .GET and .POST, why not add in a .WS also173* @param {string|RegExp} route - can be '/route/to/things' or /^\\/route\\/(this)|(that)\\/things$/174* @param {(ws: WebSocket, req: http.IncomingMessage) => WebSocket} handler - ws is the connection between the client and the server175* @param {object} websocketOptions - read https://www.npmjs.com/package/ws for a list of Websocket.Server options. Note that176* the { noServer: true } will always be applied177* @returns {WebSocket.Server}178*/179WS(route, handler, websocketOptions = {}) {180if (this.checkIsRoute(route)) {181throw new TypeError('WS route already exists');182}183184const wsServer = new WebSocket.Server({185...websocketOptions,186noServer: true187});188this.websocketRoutes.push({ route, handler, wsServer });189190return wsServer;191}192unregisterWS(route) {193if (!this.getWSRoute(route, true)) {194throw new TypeError('websocket route does not exist');195}196}197/**198* @param {string} path199* @returns {{ route: string|RegExp, handler: (ws: WebSocket, req: http.IncomingMessage) => WebSocket, wsServer: WebSocket.Server}|null}200*/201getWSRoute(path, doDelete = false) {202for (let i = 0; i < this.websocketRoutes.length; i++) {203if (204(typeof this.websocketRoutes[i].route === 'string' && this.websocketRoutes[i].route === path) ||205(this.websocketRoutes[i] instanceof RegExp && this.websocketRoutes[i].route.test(path))206) {207const route = this.websocketRoutes[i];208if (doDelete) {209this.websocketRoutes.splice(i, 1);210i--;211}212return route;213}214}215return null;216}217/**218* @private219*/220_WSRouteHandler(req, socket, head) {221const route = this.getWSRoute(req.url);222if (route) {223// RH stands for rammerhead. RHROUTE is a custom implementation by rammerhead that is224// unrelated to hammerhead225this.logger.traffic(`WSROUTE UPGRADE ${this.loggerGetIP(req)} ${req.url}`);226route.wsServer.handleUpgrade(req, socket, head, (client, req) => {227this.logger.traffic(`WSROUTE OPEN ${this.loggerGetIP(req)} ${req.url}`);228client.once('close', () => {229this.logger.traffic(`WSROUTE CLOSE ${this.loggerGetIP(req)} ${req.url}`);230});231route.handler(client, req);232});233return true;234}235}236237// manage pipelines //238/**239* @param {(req: http.IncomingMessage,240* res: http.ServerResponse,241* serverInfo: ServerInfo,242* isRoute: boolean,243* isWebsocket: boolean) => Promise<boolean>} onRequest - return true to terminate handoff to proxy.244* There is an isWebsocket even though there is an onUpgrade pipeline already. This is because hammerhead245* processes the onUpgrade and then passes it directly to onRequest, but without the "head" Buffer argument.246* The onUpgrade pipeline is to solve that lack of the "head" argument issue in case one needs it.247* @param {boolean} beginning - whether to add it to the beginning of the pipeline248*/249addToOnRequestPipeline(onRequest, beginning = false) {250if (beginning) {251this.onRequestPipeline.push(onRequest);252} else {253this.onRequestPipeline.unshift(onRequest);254}255}256/**257* @param {(req: http.IncomingMessage,258* socket: stream.Duplex,259* head: Buffer,260* serverInfo: ServerInfo,261* isRoute: boolean) => Promise<boolean>} onUpgrade - return true to terminate handoff to proxy262* @param {boolean} beginning - whether to add it to the beginning of the pipeline263*/264addToOnUpgradePipeline(onUpgrade, beginning = false) {265if (beginning) {266this.onUpgradePipeline.push(onUpgrade);267} else {268this.onUpgradePipeline.unshift(onUpgrade);269}270}271272// override hammerhead's proxy functions to use the pipeline //273checkIsRoute(req) {274if (req instanceof RegExp) {275return !!this.getWSRoute(req);276}277// code modified from278// https://github.com/DevExpress/testcafe-hammerhead/blob/879d6ae205bb711dfba8c1c88db635e8803b8840/src/proxy/router.ts#L95279const routerQuery = `${req.method} ${getPathname(req.url || '')}`;280const route = this.routes.get(routerQuery);281if (route) {282return true;283}284for (const routeWithParams of this.routesWithParams) {285const routeMatch = routerQuery.match(routeWithParams.re);286if (routeMatch) {287return true;288}289}290return !!this.getWSRoute(req.url);291}292/**293* @param {http.IncomingMessage} req294* @param {http.ServerResponse} res295* @param {ServerInfo} serverInfo296*/297async _onRequest(req, res, serverInfo) {298serverInfo = this._rewriteServerInfo(req);299300const isWebsocket = res instanceof stream.Duplex;301302if (!isWebsocket) {303// strip server headers304const originalWriteHead = res.writeHead;305const self = this;306res.writeHead = function (statusCode, statusMessage, headers) {307if (!headers) {308headers = statusMessage;309statusMessage = undefined;310}311312if (headers) {313const alreadyRewrittenHeaders = [];314if (Array.isArray(headers)) {315// [content-type, text/html, headerKey, headerValue, ...]316for (let i = 0; i < headers.length - 1; i += 2) {317const header = headers[i].toLowerCase();318if (header in self.rewriteServerHeaders) {319alreadyRewrittenHeaders.push(header);320headers[i + 1] =321self.rewriteServerHeaders[header] &&322self.rewriteServerHeaders[header](headers[i + 1]);323if (!headers[i + 1]) {324headers.splice(i, 2);325i -= 2;326}327}328}329for (const header in self.rewriteServerHeaders) {330if (alreadyRewrittenHeaders.includes(header)) continue;331// if user wants to add headers, they can do that here332const value = self.rewriteServerHeaders[header] && self.rewriteServerHeaders[header]();333if (value) {334headers.push(header, value);335}336}337} else {338for (const header in headers) {339if (header in self.rewriteServerHeaders) {340alreadyRewrittenHeaders.push(header);341headers[header] =342self.rewriteServerHeaders[header] && self.rewriteServerHeaders[header]();343if (!headers[header]) {344delete headers[header];345}346}347}348for (const header in self.rewriteServerHeaders) {349if (alreadyRewrittenHeaders.includes(header)) continue;350const value = self.rewriteServerHeaders[header] && self.rewriteServerHeaders[header]();351if (value) {352headers[header] = value;353}354}355}356}357358if (statusMessage) {359originalWriteHead.call(this, statusCode, statusMessage, headers);360} else {361originalWriteHead.call(this, statusCode, headers);362}363};364}365366const isRoute = this.checkIsRoute(req);367const ip = this.loggerGetIP(req);368369this.logger.traffic(`${isRoute ? 'ROUTE ' : ''}${ip} ${req.url}`);370for (const handler of this.onRequestPipeline) {371if ((await handler.call(this, req, res, serverInfo, isRoute, isWebsocket)) === true) {372return;373}374}375// hammerhead's routing does not support websockets. Allowing it376// will result in an error thrown377if (isRoute && isWebsocket) {378httpResponse.badRequest(this.logger, req, res, ip, 'Rejected unsupported websocket request');379return;380}381super._onRequest(req, res, serverInfo);382}383/**384* @param {http.IncomingMessage} req385* @param {stream.Duplex} socket386* @param {Buffer} head387* @param {ServerInfo} serverInfo388*/389async _onUpgradeRequest(req, socket, head, serverInfo) {390serverInfo = this._rewriteServerInfo(req);391for (const handler of this.onUpgradePipeline) {392const isRoute = this.checkIsRoute(req);393if ((await handler.call(this, req, socket, head, serverInfo, isRoute)) === true) {394return;395}396}397if (this._WSRouteHandler(req, socket, head)) return;398super._onUpgradeRequest(req, socket, head, serverInfo);399}400401/**402* @private403* @param {http.IncomingMessage} req404* @returns {ServerInfo}405*/406_rewriteServerInfo(req) {407const serverInfo = this.getServerInfo(req);408return {409hostname: serverInfo.hostname,410port: serverInfo.port,411crossDomainPort: serverInfo.crossDomainPort || this.crossDomainPort || serverInfo.port,412protocol: serverInfo.protocol,413domain: `${serverInfo.protocol}//${serverInfo.hostname}:${serverInfo.port}`,414cacheRequests: false415};416}417/**418* @private419*/420_setupRammerheadServiceRoutes() {421this.GET('/rammerhead.js', {422content: fs.readFileSync(423path.join(__dirname, '../client/rammerhead' + (process.env.DEVELOPMENT ? '.js' : '.min.js'))424),425contentType: 'application/x-javascript'426});427this.GET('/api/shuffleDict', (req, res) => {428const { id } = new URLPath(req.url).getParams();429if (!id || !this.openSessions.has(id)) {430return httpResponse.badRequest(this.logger, req, res, this.loggerGetIP(req), 'Invalid session id');431}432res.end(JSON.stringify(this.openSessions.get(id).shuffleDict) || '');433});434}435/**436* @private437*/438_setupLocalStorageServiceRoutes(disableSync) {439this.POST('/syncLocalStorage', async (req, res) => {440if (disableSync) {441res.writeHead(404);442res.end('server disabled localStorage sync');443return;444}445const badRequest = (msg) => httpResponse.badRequest(this.logger, req, res, this.loggerGetIP(req), msg);446const respondJson = (obj) => res.end(JSON.stringify(obj));447const { sessionId, origin } = new URLPath(req.url).getParams();448449if (!sessionId || !this.openSessions.has(sessionId)) {450return badRequest('Invalid session id');451}452if (!origin) {453return badRequest('Invalid origin');454}455456let parsed;457try {458parsed = JSON.parse(await streamToString(req));459} catch (e) {460return badRequest('bad client body');461}462463const now = Date.now();464const session = this.openSessions.get(sessionId, false);465if (!session.data.localStorage) session.data.localStorage = {};466467switch (parsed.type) {468case 'sync':469if (parsed.fetch) {470// client is syncing for the first time471if (!session.data.localStorage[origin]) {472// server does not have any data on origin, so create an empty record473// and send an empty object back474session.data.localStorage[origin] = { data: {}, timestamp: now };475return respondJson({476timestamp: now,477data: {}478});479} else {480// server does have data, so send data back481return respondJson({482timestamp: session.data.localStorage[origin].timestamp,483data: session.data.localStorage[origin].data484});485}486} else {487// sync server and client localStorage488489parsed.timestamp = parseInt(parsed.timestamp);490if (isNaN(parsed.timestamp)) return badRequest('must specify valid timestamp');491if (parsed.timestamp > now) return badRequest('cannot specify timestamp in the future');492if (!parsed.data || typeof parsed.data !== 'object')493return badRequest('data must be an object');494495for (const prop in parsed.data) {496if (typeof parsed.data[prop] !== 'string') {497return badRequest('data[prop] must be a string');498}499}500501if (!session.data.localStorage[origin]) {502// server does not have data, so use client's503session.data.localStorage[origin] = { data: parsed.data, timestamp: now };504return respondJson({});505} else if (session.data.localStorage[origin].timestamp <= parsed.timestamp) {506// server data is either the same as client or outdated, but we507// sync even if timestamps are the same in case the client changed the localStorage508// without updating509session.data.localStorage[origin].data = parsed.data;510session.data.localStorage[origin].timestamp = parsed.timestamp;511return respondJson({});512} else {513// client data is stale514return respondJson({515timestamp: session.data.localStorage[origin].timestamp,516data: session.data.localStorage[origin].data517});518}519}520case 'update':521if (!session.data.localStorage[origin])522return badRequest('must perform sync first on a new origin');523if (!parsed.updateData || typeof parsed.updateData !== 'object')524return badRequest('updateData must be an object');525for (const prop in parsed.updateData) {526if (!parsed.updateData[prop] || typeof parsed.updateData[prop] !== 'string')527return badRequest('updateData[prop] must be a non-empty string');528}529for (const prop in parsed.updateData) {530session.data.localStorage[origin].data[prop] = parsed.updateData[prop];531}532session.data.localStorage[origin].timestamp = now;533return respondJson({534timestamp: now535});536default:537return badRequest('unknown type ' + parsed.type);538}539});540}541542openSession() {543throw new TypeError('unimplemented. please use a RammerheadSessionStore and use their .add() method');544}545close() {546super.close();547this.openSessions.close();548}549550/**551* @param {string} route552* @param {StaticContent | (req: http.IncomingMessage, res: http.ServerResponse) => void} handler553*/554GET(route, handler) {555if (route === '/hammerhead.js') {556handler.content = fs.readFileSync(557path.join(__dirname, '../client/hammerhead' + (process.env.DEVELOPMENT ? '.js' : '.min.js'))558);559}560super.GET(route, handler);561}562563// the following is to fix hamerhead's typescript definitions564/**565* @param {string} route566* @param {StaticContent | (req: http.IncomingMessage, res: http.ServerResponse) => void} handler567*/568POST(route, handler) {569super.POST(route, handler);570}571}572573module.exports = RammerheadProxy;574575576