diff --git a/app/client/components/GristClientSocket.ts b/app/client/components/GristClientSocket.ts new file mode 100644 index 0000000000..9024452c96 --- /dev/null +++ b/app/client/components/GristClientSocket.ts @@ -0,0 +1,150 @@ +import WS from 'ws'; +import {Socket as EIOSocket} from 'engine.io-client'; + +export interface GristClientSocketOptions { + headers?: Record; +} + +export class GristClientSocket { + private _wsSocket: WS.WebSocket | WebSocket | undefined; + private _eioSocket: EIOSocket | undefined; + + // Set to true when the connection process is complete, either succesfully or + // after the WebSocket and polling transports have both failed. Events from + // the underlying socket are not forwarded to the client until that point. + private _openDone: boolean = false; + + private _messageHandler: null | ((data: string) => void); + private _openHandler: null | (() => void); + private _errorHandler: null | ((err: Error) => void); + private _closeHandler: null | (() => void); + + constructor(private _url: string, private _options?: GristClientSocketOptions) { + this._createWSSocket(); + } + + public set onmessage(cb: null | ((data: string) => void)) { + this._messageHandler = cb; + } + + public set onopen(cb: null | (() => void)) { + this._openHandler = cb; + } + + public set onerror(cb: null | ((err: Error) => void)) { + this._errorHandler = cb; + } + + public set onclose(cb: null | (() => void)) { + this._closeHandler = cb; + } + + public close() { + if (this._wsSocket) { + this._wsSocket.close(); + } else { + this._eioSocket!.close(); + } + } + + public send(data: string) { + if (this._wsSocket) { + this._wsSocket.send(data); + } else { + this._eioSocket!.send(data); + } + } + + // pause() and resume() are used for tests and assume a WS.WebSocket transport + public pause() { + (this._wsSocket as WS.WebSocket)?.pause(); + } + + public resume() { + (this._wsSocket as WS.WebSocket)?.resume(); + } + + private _createWSSocket() { + if(typeof WebSocket !== 'undefined') { + this._wsSocket = new WebSocket(this._url); + } else { + this._wsSocket = new WS(this._url, undefined, this._options); + } + this._wsSocket.onmessage = this._onWSMessage.bind(this); + this._wsSocket.onopen = this._onWSOpen.bind(this); + this._wsSocket.onerror = this._onWSError.bind(this); + this._wsSocket.onclose = this._onWSClose.bind(this); + } + + private _destroyWSSocket() { + if (this._wsSocket) { + this._wsSocket.onmessage = null; + this._wsSocket.onopen = null; + this._wsSocket.onerror = null; + this._wsSocket.onclose = null; + this._wsSocket = undefined; + } + } + + private _onWSMessage(event: WS.MessageEvent | MessageEvent) { + if (this._openDone) { + // event.data is guaranteed to be a string here because we only send text frames. + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/message_event#event_properties + this._messageHandler?.(event.data); + } + } + + private _onWSOpen() { + // The connection was established successfully. Any future events can now + // be forwarded to the client. + this._openDone = true; + this._openHandler?.(); + } + + private _onWSError(ev: Event) { + // The first connection attempt failed. Trigger an attempt with another transport. + this._destroyWSSocket(); + this._createEIOSocket(); + } + + private _onWSClose() { + if (this._openDone) { + this._closeHandler?.(); + } + } + + private _createEIOSocket() { + this._eioSocket = new EIOSocket(this._url, { + path: new URL(this._url).pathname, + addTrailingSlash: false, + transports: ['polling'], + upgrade: false, + extraHeaders: this._options?.headers, + withCredentials: true, + }); + + this._eioSocket.on('message', this._onEIOMessage.bind(this)); + this._eioSocket.on('open', this._onEIOOpen.bind(this)); + this._eioSocket.on('error', this._onEIOError.bind(this)); + this._eioSocket.on('close', this._onEIOClose.bind(this)); + } + + private _onEIOMessage(data: string) { + this._messageHandler?.(data); + } + + private _onEIOOpen() { + this._openHandler?.(); + } + + private _onEIOError(ev: any) { + // We will make no further attempt to connect. Any future events can now + // be forwarded to the client. + this._openDone = true; + this._errorHandler?.(ev); + } + + private _onEIOClose() { + this._closeHandler?.(); + } +} \ No newline at end of file diff --git a/app/client/components/GristWSConnection.ts b/app/client/components/GristWSConnection.ts index d2dca26a2e..f1d7773257 100644 --- a/app/client/components/GristWSConnection.ts +++ b/app/client/components/GristWSConnection.ts @@ -9,6 +9,7 @@ import {addOrgToPath, docUrl, getGristConfig} from 'app/common/urlUtils'; import {UserAPI} from 'app/common/UserAPI'; import {Events as BackboneEvents} from 'backbone'; import {Disposable} from 'grainjs'; +import {GristClientSocket} from './GristClientSocket'; const G = getBrowserGlobals('window'); const reconnectInterval = [1000, 1000, 2000, 5000, 10000]; @@ -37,7 +38,7 @@ async function getDocWorkerUrl(assignmentId: string|null): Promise export interface GristWSSettings { // A factory function for creating the WebSocket so that we can use from node // or browser. - makeWebSocket(url: string): WebSocket; + makeWebSocket(url: string): GristClientSocket; // A function for getting the timezone, so the code can be used outside webpack - // currently a timezone library is lazy loaded in a way that doesn't quite work @@ -74,7 +75,7 @@ export interface GristWSSettings { export class GristWSSettingsBrowser implements GristWSSettings { private _sessionStorage = getSessionStorage(); - public makeWebSocket(url: string) { return new WebSocket(url); } + public makeWebSocket(url: string) { return new GristClientSocket(url); } public getTimezone() { return guessTimezone(); } public getPageUrl() { return G.window.location.href; } public async getDocWorkerUrl(assignmentId: string|null) { @@ -125,7 +126,7 @@ export class GristWSConnection extends Disposable { private _reconnectTimeout: ReturnType | null = null; private _reconnectAttempts: number = 0; private _wantReconnect: boolean = true; - private _ws: WebSocket|null = null; + private _ws: GristClientSocket|null = null; // The server sends incremental seqId numbers with each message on the connection, starting with // 0. We keep track of them to allow for seamless reconnects. @@ -211,17 +212,16 @@ export class GristWSConnection extends Disposable { } /** - * @event serverMessage Triggered when a message arrives from the server. Callbacks receive - * the raw message data as an additional argument. + * Triggered when a message arrives from the server. */ - public onmessage(ev: MessageEvent) { + public onmessage(data: string) { if (!this._ws) { // It's possible to receive a message after we disconnect, at least in tests (where // WebSocket is a node library). Ignoring is easier than unsubscribing properly. return; } this._scheduleHeartbeat(); - this._processReceivedMessage(ev.data, true); + this._processReceivedMessage(data, true); } public send(message: any) { @@ -352,8 +352,8 @@ export class GristWSConnection extends Disposable { this._ws.onmessage = this.onmessage.bind(this); - this._ws.onerror = (ev: Event|ErrorEvent) => { - this._log('GristWSConnection: onerror', 'error' in ev ? String(ev.error) : ev); + this._ws.onerror = (err: Error) => { + this._log('GristWSConnection: onerror', String(err)); }; this._ws.onclose = () => { diff --git a/app/server/lib/Client.ts b/app/server/lib/Client.ts index 111d84eef7..1aeedbfe52 100644 --- a/app/server/lib/Client.ts +++ b/app/server/lib/Client.ts @@ -22,7 +22,7 @@ import {fromCallback} from 'app/server/lib/serverUtils'; import {i18n} from 'i18next'; import * as crypto from 'crypto'; import moment from 'moment'; -import * as WebSocket from 'ws'; +import {GristServerSocket} from 'app/server/lib/GristServerSocket'; // How many messages and bytes to accumulate for a disconnected client before booting it. // The benefit is that a client who temporarily disconnects and reconnects without missing much, @@ -97,8 +97,7 @@ export class Client { private _missedMessagesTotalLength: number = 0; private _destroyTimer: NodeJS.Timer|null = null; private _destroyed: boolean = false; - private _websocket: WebSocket|null; - private _websocketEventHandlers: Array<{event: string, handler: (...args: any[]) => void}> = []; + private _websocket: GristServerSocket|null; private _org: string|null = null; private _profile: UserProfile|null = null; private _user: FullUser|undefined = undefined; @@ -131,18 +130,14 @@ export class Client { return this._locale; } - public setConnection(websocket: WebSocket, counter: string|null, browserSettings: BrowserSettings) { + public setConnection(websocket: GristServerSocket, counter: string|null, browserSettings: BrowserSettings) { this._websocket = websocket; this._counter = counter; this.browserSettings = browserSettings; - const addHandler = (event: string, handler: (...args: any[]) => void) => { - websocket.on(event, handler); - this._websocketEventHandlers.push({event, handler}); - }; - addHandler('error', (err: unknown) => this._onError(err)); - addHandler('close', () => this._onClose()); - addHandler('message', (msg: string) => this._onMessage(msg)); + websocket.onerror = (err: Error) => this._onError(err); + websocket.onclose = () => this._onClose(); + websocket.onmessage = (msg: string) => this._onMessage(msg); } /** @@ -189,7 +184,7 @@ export class Client { public interruptConnection() { if (this._websocket) { - this._removeWebsocketListeners(); + this._websocket?.removeAllListeners(); this._websocket.terminate(); // close() is inadequate when ws routed via loadbalancer this._websocket = null; } @@ -359,7 +354,7 @@ export class Client { // See also my report at https://stackoverflow.com/a/48411315/328565 await delay(250); - if (!this._destroyed && this._websocket?.readyState === WebSocket.OPEN) { + if (!this._destroyed && this._websocket?.isOpen) { await this._sendToWebsocket(JSON.stringify({...clientConnectMsg, dup: true})); } } catch (err) { @@ -604,7 +599,7 @@ export class Client { /** * Processes an error on the websocket. */ - private _onError(err: unknown) { + private _onError(err: Error) { this._log.warn(null, "onError", err); // TODO Make sure that this is followed by onClose when the connection is lost. } @@ -613,7 +608,7 @@ export class Client { * Processes the closing of a websocket. */ private _onClose() { - this._removeWebsocketListeners(); + this._websocket?.removeAllListeners(); // Remove all references to the websocket. this._websocket = null; @@ -629,15 +624,4 @@ export class Client { this._destroyTimer = setTimeout(() => this.destroy(), Deps.clientRemovalTimeoutMs); } } - - private _removeWebsocketListeners() { - if (this._websocket) { - // Avoiding websocket.removeAllListeners() because WebSocket.Server registers listeners - // internally for websockets it keeps track of, and we should not accidentally remove those. - for (const {event, handler} of this._websocketEventHandlers) { - this._websocket.off(event, handler); - } - this._websocketEventHandlers = []; - } - } } diff --git a/app/server/lib/Comm.ts b/app/server/lib/Comm.ts index 2c50c5d849..50f18567a0 100644 --- a/app/server/lib/Comm.ts +++ b/app/server/lib/Comm.ts @@ -35,7 +35,8 @@ import {EventEmitter} from 'events'; import * as http from 'http'; import * as https from 'https'; -import * as WebSocket from 'ws'; +import {GristSocketServer} from 'app/server/lib/GristSocketServer'; +import {GristServerSocket} from 'app/server/lib/GristServerSocket'; import {parseFirstUrlPart} from 'app/common/gristUrls'; import {firstDefined, safeJsonParse} from 'app/common/gutil'; @@ -50,6 +51,7 @@ import {localeFromRequest} from 'app/server/lib/ServerLocale'; import {fromCallback} from 'app/server/lib/serverUtils'; import {Sessions} from 'app/server/lib/Sessions'; import {i18n} from 'i18next'; +import { trustOrigin } from './requestUtils'; export interface CommOptions { sessions: Sessions; // A collection of all sessions for this instance of Grist @@ -74,7 +76,7 @@ export interface CommOptions { export class Comm extends EventEmitter { // Collection of all sessions; maps sessionIds to ScopedSession objects. public readonly sessions: Sessions = this._options.sessions; - private _wss: WebSocket.Server[]|null = null; + private _wss: GristSocketServer[]|null = null; private _clients = new Map(); // Maps clientIds to Client objects. @@ -146,11 +148,6 @@ export class Comm extends EventEmitter { public async testServerShutdown() { if (this._wss) { for (const wssi of this._wss) { - // Terminate all clients. WebSocket.Server used to do it automatically in close() but no - // longer does (see https://github.com/websockets/ws/pull/1904#discussion_r668844565). - for (const ws of wssi.clients) { - ws.terminate(); - } await fromCallback((cb) => wssi.close(cb)); } this._wss = null; @@ -204,7 +201,7 @@ export class Comm extends EventEmitter { /** * Processes a new websocket connection, and associates the websocket and a Client object. */ - private async _onWebSocketConnection(websocket: WebSocket, req: http.IncomingMessage) { + private async _onWebSocketConnection(websocket: GristServerSocket, req: http.IncomingMessage) { if (this._options.hosts) { // DocWorker ID (/dw/) and version tag (/v/) may be present in this request but are not // needed. addOrgInfo assumes req.url starts with /o/ if present. @@ -255,15 +252,18 @@ export class Comm extends EventEmitter { if (this._options.httpsServer) { servers.push(this._options.httpsServer); } const wss = []; for (const server of servers) { - const wssi = new WebSocket.Server({server}); - wssi.on('connection', async (websocket: WebSocket, req) => { + const wssi = new GristSocketServer(server, { + verifyClient: (req) => trustOrigin(req) + }); + + wssi.onconnection = async (websocket: GristServerSocket, req) => { try { await this._onWebSocketConnection(websocket, req); } catch (e) { log.error("Comm connection for %s threw exception: %s", req.url, e.message); websocket.terminate(); // close() is inadequate when ws routed via loadbalancer } - }); + }; wss.push(wssi); } return wss; diff --git a/app/server/lib/GristServerSocket.ts b/app/server/lib/GristServerSocket.ts new file mode 100644 index 0000000000..011bc2a22b --- /dev/null +++ b/app/server/lib/GristServerSocket.ts @@ -0,0 +1,136 @@ +import * as WS from 'ws'; +import * as EIO from 'engine.io'; + +export abstract class GristServerSocket { + public abstract set onerror(handler: (err: Error) => void); + public abstract set onclose(handler: () => void); + public abstract set onmessage(handler: (data: string) => void); + public abstract removeAllListeners(): void; + public abstract close(): void; + public abstract terminate(): void; + public abstract get isOpen(): boolean; + public abstract send(data: string, cb?: (err?: Error) => void): void; +} + +export class GristServerSocketEIO extends GristServerSocket { + private _eventHandlers: Array<{ event: string, handler: (...args: any[]) => void }> = []; + private _messageCounter = 0; + + // Engine.IO only invokes send() callbacks on success. We keep a map of + // send callbacks for messages in flight so that we can invoke them for + // any messages still unsent upon receiving a "close" event. + private _messageCallbacks: Map void> = new Map(); + + constructor(private _socket: EIO.Socket) { super(); } + + public set onerror(handler: (err: Error) => void) { + // Note that as far as I can tell, Engine.IO sockets never emit "error" + // but instead include error information in the "close" event. + this._socket.on('error', handler); + this._eventHandlers.push({ event: 'error', handler }); + } + + public set onclose(handler: () => void) { + const wrappedHandler = (reason: string, description: any) => { + // In practice, when available, description has more error details, + // possibly in the form of an Error object. + const maybeErr = description ?? reason; + const err = maybeErr instanceof Error ? maybeErr : new Error(maybeErr); + for (const cb of this._messageCallbacks.values()) { + cb(err); + } + this._messageCallbacks.clear(); + + handler(); + }; + this._socket.on('close', wrappedHandler); + this._eventHandlers.push({ event: 'close', handler: wrappedHandler }); + } + + public set onmessage(handler: (data: string) => void) { + const wrappedHandler = (msg: Buffer) => { + handler(msg.toString()); + }; + this._socket.on('message', wrappedHandler); + this._eventHandlers.push({ event: 'message', handler: wrappedHandler }); + } + + public removeAllListeners() { + for (const { event, handler } of this._eventHandlers) { + this._socket.off(event, handler); + } + this._eventHandlers = []; + } + + public close() { + this._socket.close(); + } + + // Terminates the connection without waiting for the client to close its own side. + public terminate() { + // Trigger a normal close. For the polling transport, this is sufficient and instantaneous. + this._socket.close(/* discard */ true); + } + + public get isOpen() { + return this._socket.readyState === 'open'; + } + + public send(data: string, cb?: (err?: Error) => void) { + const msgNum = this._messageCounter++; + if (cb) { + this._messageCallbacks.set(msgNum, cb); + } + this._socket.send(data, {}, () => { + if (cb && this._messageCallbacks.delete(msgNum)) { + cb(); + } + }); + } +} + +export class GristServerSocketWS extends GristServerSocket { + private _eventHandlers: Array<{ event: string, handler: (...args: any[]) => void }> = []; + + constructor(private _ws: WS.WebSocket) { super(); } + + public set onerror(handler: (err: Error) => void) { + this._ws.on('error', handler); + this._eventHandlers.push({ event: 'error', handler }); + } + + public set onclose(handler: () => void) { + this._ws.on('close', handler); + this._eventHandlers.push({ event: 'close', handler }); + } + + public set onmessage(handler: (data: string) => void) { + this._ws.on('message', (msg: Buffer) => handler(msg.toString())); + this._eventHandlers.push({ event: 'message', handler }); + } + + public removeAllListeners() { + // Avoiding websocket.removeAllListeners() because WS.Server registers listeners + // internally for websockets it keeps track of, and we should not accidentally remove those. + for (const { event, handler } of this._eventHandlers) { + this._ws.off(event, handler); + } + this._eventHandlers = []; + } + + public close() { + this._ws.close(); + } + + public terminate() { + this._ws.terminate(); + } + + public get isOpen() { + return this._ws.readyState === WS.OPEN; + } + + public send(data: string, cb?: (err?: Error) => void) { + this._ws.send(data, cb); + } +} diff --git a/app/server/lib/GristSocketServer.ts b/app/server/lib/GristSocketServer.ts new file mode 100644 index 0000000000..0aaef266cf --- /dev/null +++ b/app/server/lib/GristSocketServer.ts @@ -0,0 +1,111 @@ +import * as http from 'http'; +import * as WS from 'ws'; +import * as EIO from 'engine.io'; +import {GristServerSocket, GristServerSocketEIO, GristServerSocketWS} from './GristServerSocket'; +import * as net from 'net'; + +const MAX_PAYLOAD = 100e6; + +export interface GristSocketServerOptions { + verifyClient?: (request: http.IncomingMessage) => boolean; +} + +export class GristSocketServer { + private _wsServer: WS.Server; + private _eioServer: EIO.Server; + private _connectionHandler: (socket: GristServerSocket, req: http.IncomingMessage) => void; + + constructor(server: http.Server, private _options?: GristSocketServerOptions) { + this._wsServer = new WS.Server({ noServer: true, maxPayload: MAX_PAYLOAD }); + + this._eioServer = new EIO.Server({ + // We only use Engine.IO for its polling transport, + // so we disable the built-in Engine.IO upgrade mechanism. + allowUpgrades: false, + transports: ['polling'], + maxHttpBufferSize: MAX_PAYLOAD, + cors: { + // This will cause Engine.IO to reflect any client-provided Origin into + // the Access-Control-Allow-Origin header, essentially disabling the + // protection offered by the Same-Origin Policy. This sounds insecure + // but is actually the security model of native WebSockets (they are + // not covered by SOP; any webpage can open a WebSocket connecting to + // any other domain, including the target domain's cookies; it is up to + // the receiving server to check the request's Origin header). Since + // the connection attempt is validated in `verifyClient` later, + // it is safe to let any client attempt a connection here. + origin: true, + // We need to allow the client to send its cookies. See above for the + // reasoning on why it is safe to do so. + credentials: true, + methods: ["GET", "POST"], + }, + }); + + this._eioServer.on('connection', this._onEIOConnection.bind(this)); + + this._attach(server); + } + + public set onconnection(handler: (socket: GristServerSocket, req: http.IncomingMessage) => void) { + this._connectionHandler = handler; + } + + public close(cb: (...args: any[]) => void) { + this._eioServer.close(); + + // Terminate all clients. WS.Server used to do it automatically in close() but no + // longer does (see https://github.com/websockets/ws/pull/1904#discussion_r668844565). + for (const ws of this._wsServer.clients) { + ws.terminate(); + } + this._wsServer.close(cb); + } + + private _attach(server: http.Server) { + // Forward all WebSocket upgrade requests to WS + server.on('upgrade', (request, socket, head) => { + if (this._options?.verifyClient && !this._options.verifyClient(request)) { + // Because we are handling an "upgrade" event, we don't have access to + // a "response" object, just the raw socket. We can still construct + // a well-formed HTTP error response. + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } + this._wsServer.handleUpgrade(request, socket as net.Socket, head, (client) => { + this._connectionHandler?.(new GristServerSocketWS(client), request); + }); + }); + + // At this point an Express app is installed as the handler for the server's + // "request" event. We need to install our own listener instead, to intercept + // requests that are meant for the Engine.IO polling implementation. + const listeners = [...server.listeners("request")]; + server.removeAllListeners("request"); + server.on("request", (req, res) => { + // Intercept requests that have transport=polling in their querystring + if (/[&?]transport=polling(&|$)/.test(req.url ?? '')) { + if (this._options?.verifyClient && !this._options.verifyClient(req)) { + res.writeHead(403).end(); + return; + } + + this._eioServer.handleRequest(req, res); + } else { + // Otherwise fallback to the pre-existing listener(s) + for (const listener of listeners) { + listener.call(server, req, res); + } + } + }); + + server.on("close", this.close.bind(this)); + } + + private _onEIOConnection(socket: EIO.Socket) { + const req = socket.request; + (socket as any).request = null; // Free initial request as recommended in the Engine.IO documentation + this._connectionHandler?.(new GristServerSocketEIO(socket), req); + } +} \ No newline at end of file diff --git a/package.json b/package.json index 4342fffee1..7b5207d605 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,8 @@ "diff-match-patch": "1.0.5", "dompurify": "3.0.6", "double-ended-queue": "2.1.0-0", + "engine.io": "^6.5.4", + "engine.io-client": "^6.5.3", "exceljs": "4.2.1", "express": "4.18.2", "file-type": "16.5.4", diff --git a/test/server/Comm.ts b/test/server/Comm.ts index b6e6c50561..4cd33bea56 100644 --- a/test/server/Comm.ts +++ b/test/server/Comm.ts @@ -4,11 +4,11 @@ import {assert} from 'chai'; import * as http from 'http'; import {AddressInfo} from 'net'; import * as sinon from 'sinon'; -import WebSocket from 'ws'; import * as path from 'path'; import * as tmp from 'tmp'; import {GristWSConnection, GristWSSettings} from 'app/client/components/GristWSConnection'; +import {GristClientSocket, GristClientSocketOptions} from 'app/client/components/GristClientSocket'; import {Comm as ClientComm} from 'app/client/components/Comm'; import * as log from 'app/client/lib/log'; import {Comm} from 'app/server/lib/Comm'; @@ -58,7 +58,11 @@ describe('Comm', function() { async function stopComm() { comm?.destroyAllClients(); - return fromCallback(cb => server.close(cb)); + await comm?.testServerShutdown(); + await fromCallback(cb => { + server.close(cb); + server.closeAllConnections(); + }); } const assortedMethods: {[name: string]: ClientMethod} = { @@ -95,34 +99,43 @@ describe('Comm', function() { sandbox.restore(); }); - function getMessages(ws: WebSocket, count: number): Promise { + function getMessages(ws: GristClientSocket, count: number): Promise { return new Promise((resolve, reject) => { const messages: object[] = []; - ws.on('error', reject); - ws.on('message', (msg: string) => { - messages.push(JSON.parse(msg)); + ws.onerror = (err) => { + ws.onmessage = null; + reject(err); + }; + ws.onmessage = (data: string) => { + messages.push(JSON.parse(data)); if (messages.length >= count) { + ws.onerror = null; + ws.onmessage = null; resolve(messages); - ws.removeListener('error', reject); - ws.removeAllListeners('message'); } - }); + }; }); } /** * Returns a promise for the connected websocket. */ - function connect() { - const ws = new WebSocket('ws://localhost:' + (server.address() as AddressInfo).port); - return new Promise((resolve, reject) => { - ws.on('open', () => resolve(ws)); - ws.on('error', reject); + function connect(options?: GristClientSocketOptions): Promise { + const ws = new GristClientSocket('ws://localhost:' + (server.address() as AddressInfo).port, options); + return new Promise((resolve, reject) => { + ws.onopen = () => { + ws.onerror = null; + resolve(ws); + }; + ws.onerror = (err) => { + ws.onopen = null; + reject(err); + }; }); } describe("server methods", function() { - let ws: WebSocket; + let ws: GristClientSocket; beforeEach(async function() { await startComm(assortedMethods); ws = await connect(); @@ -370,7 +383,8 @@ describe('Comm', function() { // Intercept the call to _onClose to know when it occurs, since we are trying to hit a // situation where 'close' and 'failedSend' events happen in either order. const stubOnClose = sandbox.stub(Client.prototype as any, '_onClose') - .callsFake(function(this: Client) { + .callsFake(async function(this: Client) { + if (!options.closeHappensFirst) { await delay(10); } eventsSeen.push('close'); return (stubOnClose as any).wrappedMethod.apply(this, arguments); }); @@ -462,6 +476,9 @@ describe('Comm', function() { if (options.useSmallMsgs) { assert.deepEqual(eventsSeen, ['close']); } else { + // Make sure to have waited long enough for the 'close' event we may have delayed + await delay(20); + // Large messages now cause a send to fail, after filling up buffer, and close the socket. assert.deepEqual(eventsSeen, ['close', 'close']); } @@ -490,6 +507,34 @@ describe('Comm', function() { assert.deepEqual(eventSpy.getCalls().map(call => call.args[0].n), [n - 1]); } }); + + describe("Allowed Origin", function() { + beforeEach(async function () { + await startComm(assortedMethods); + }); + + afterEach(async function() { + await stopComm(); + }); + + it('should allow only example.com', async () => { + async function checkOrigin(headers: { origin: string, host: string }, allowed: boolean) { + const promise = connect({ headers }); + if (allowed) { + await assert.isFulfilled(promise, `${headers.host} should allow ${headers.origin}`); + } else { + await assert.isRejected(promise, /.*/, `${headers.host} should reject ${headers.origin}`); + } + } + + await checkOrigin({origin: "https://www.toto.com", host: "worker.example.com"}, false); + await checkOrigin({origin: "https://badexample.com", host: "worker.example.com"}, false); + await checkOrigin({origin: "https://bad.com/example.com", host: "worker.example.com"}, false); + await checkOrigin({origin: "https://front.example.com", host: "worker.example.com"}, true); + await checkOrigin({origin: "https://front.example.com:3000", host: "worker.example.com"}, true); + await checkOrigin({origin: "https://example.com", host: "example.com"}, true); + }); + }); }); // Waits for condFunc() to return true, for up to timeoutMs milliseconds, sleeping for stepMs @@ -513,7 +558,7 @@ function getWSSettings(docWorkerUrl: string): GristWSSettings { let clientId: string = 'clientid-abc'; let counter: number = 0; return { - makeWebSocket(url: string): any { return new WebSocket(url, undefined, {}); }, + makeWebSocket(url: string): any { return new GristClientSocket(url); }, async getTimezone() { return 'UTC'; }, getPageUrl() { return "http://localhost"; }, async getDocWorkerUrl() { return docWorkerUrl; }, diff --git a/test/server/gristClient.ts b/test/server/gristClient.ts index b24c7af64e..911e7e8d5a 100644 --- a/test/server/gristClient.ts +++ b/test/server/gristClient.ts @@ -4,7 +4,7 @@ import { SchemaTypes } from 'app/common/schema'; import { FlexServer } from 'app/server/lib/FlexServer'; import axios from 'axios'; import pick = require('lodash/pick'); -import WebSocket from 'ws'; +import {GristClientSocket} from 'app/client/components/GristClientSocket'; interface GristRequest { reqId: number; @@ -34,9 +34,9 @@ export class GristClient { private _consumer: () => void; private _ignoreTrivialActions: boolean = false; - constructor(public ws: any) { - ws.onmessage = (data: any) => { - const msg = pick(JSON.parse(data.data), + constructor(public ws: GristClientSocket) { + ws.onmessage = (data: string) => { + const msg = pick(JSON.parse(data), ['reqId', 'error', 'errorCode', 'data', 'type', 'docFD']); if (this._ignoreTrivialActions && msg.type === 'docUserAction' && msg.data?.actionGroup?.internal === true && @@ -149,7 +149,6 @@ export class GristClient { } public async close() { - this.ws.terminate(); this.ws.close(); } @@ -180,17 +179,19 @@ export async function openClient(server: FlexServer, email: string, org: string, } else { headers[emailHeader] = email; } - const ws = new WebSocket('ws://localhost:' + server.getOwnPort() + `/o/${org}`, { + const ws = new GristClientSocket('ws://localhost:' + server.getOwnPort() + `/o/${org}`, { headers }); const client = new GristClient(ws); await new Promise(function(resolve, reject) { - ws.on('open', function() { + ws.onopen = function() { + ws.onerror = null; resolve(ws); - }); - ws.on('error', function(err: any) { + }; + ws.onerror = function(err: Error) { + ws.onopen = null; reject(err); - }); + }; }); return client; } diff --git a/test/server/lib/GristSockets.ts b/test/server/lib/GristSockets.ts new file mode 100644 index 0000000000..e9f4804546 --- /dev/null +++ b/test/server/lib/GristSockets.ts @@ -0,0 +1,158 @@ +import { assert } from 'chai'; +import * as http from 'http'; +import { GristClientSocket } from 'app/client/components/GristClientSocket'; +import { GristSocketServer } from 'app/server/lib/GristSocketServer'; +import { fromCallback, listenPromise } from 'app/server/lib/serverUtils'; +import { AddressInfo } from 'net'; +import httpProxy from 'http-proxy'; + +describe(`GristSockets`, function () { + + beforeEach(async function () { + await startSocketServer(); + }); + + afterEach(async function () { + await stopSocketServer(); + }); + + let server: http.Server | null; + let serverPort: number; + let wsAddress: string; + let socketServer: GristSocketServer | null; + + async function startSocketServer() { + server = http.createServer((req, res) => res.writeHead(404).end()); + socketServer = new GristSocketServer(server); + await listenPromise(server.listen(0, 'localhost')); + serverPort = (server.address() as AddressInfo).port; + wsAddress = 'ws://localhost:' + serverPort; + } + + async function stopSocketServer() { + //await delay(90_000); + await fromCallback(cb => socketServer?.close(cb)); + await fromCallback(cb => { server?.close(); server?.closeAllConnections(); server?.on("close", cb); }); + socketServer = server = null; + } + + function getMessages(ws: GristClientSocket, count: number): Promise { + return new Promise((resolve, reject) => { + const messages: string[] = []; + ws.onerror = (err) => { + ws.onerror = ws.onmessage = null; + reject(err); + }; + ws.onmessage = (data: string) => { + messages.push(data); + if (messages.length >= count) { + ws.onerror = ws.onmessage = null; + resolve(messages); + } + }; + }); + } + + /** + * Returns a promise for the connected websocket. + */ + function connectClient(url: string): Promise { + const ws = new GristClientSocket(url); + return new Promise((resolve, reject) => { + ws.onopen = () => { + ws.onerror = null; + resolve(ws); + }; + ws.onerror = (err) => { + ws.onopen = null; + reject(err); + }; + }); + } + + it("should expose initial request", async function () { + const connectionPromise = new Promise((resolve) => { + socketServer!.onconnection = (socket, req) => { + resolve(req); + }; + }); + new GristClientSocket(wsAddress + "/path?query=value", { + headers: { "cookie": "session=1234" } + }); + const req = await connectionPromise; + + // Engine.IO may append extra query parameters, so we check only the start of the URL + assert.match(req.url!, /^\/path\?query=value/); + + assert.equal(req.headers.cookie, "session=1234"); + }); + + it("should receive and send messages", async function () { + socketServer!.onconnection = (socket, req) => { + socket.onmessage = (data) => { + socket.send("hello, " + data); + socket.close(); + }; + }; + const clientWs = await connectClient(wsAddress); + clientWs.send("world"); + assert.deepEqual(await getMessages(clientWs, 1), ["hello, world"]); + }); + + it("should invoke send callbacks", async function () { + const connectionPromise = new Promise((resolve) => { + socketServer!.onconnection = (socket, req) => { + socket.send("hello", () => resolve()); + }; + }); + await connectClient(wsAddress); + await connectionPromise; + }); + + let proxy: httpProxy | null; + let proxyServer: http.Server | null; + let proxyPort: number; + + // Start an HTTP proxy that does not support WebSockets + async function startProxyServer() { + proxy = httpProxy.createProxy({ + target: `http://localhost:${serverPort}`, + ws: false, + timeout: 1000, + }); + proxy.on('error', () => { }); + proxyServer = http.createServer(proxy.web.bind(proxy)); + proxyServer.on('upgrade', (req, socket) => socket.destroy()); + + await listenPromise(proxyServer.listen(0, 'localhost')); + proxyPort = (proxyServer.address() as AddressInfo).port; + } + + async function stopProxyServer() { + proxy?.close(); + await fromCallback(cb => { proxyServer?.close(cb); proxyServer?.closeAllConnections(); }); + proxyServer = proxy = null; + } + + beforeEach(async function () { + await startProxyServer(); + }); + + afterEach(async function () { + await stopProxyServer(); + }); + + describe("GristClientSocket", function () { + it("can fall back to polling", async function () { + socketServer!.onconnection = (socket, req) => { + socket.onmessage = (data) => { + socket.send("hello, " + data); + }; + }; + const clientWs = await connectClient(`ws://localhost:${proxyPort}`); + clientWs.send("world"); + assert.deepEqual(await getMessages(clientWs, 1), ["hello, world"]); + clientWs.close(); + }); + }); +}); \ No newline at end of file diff --git a/test/server/lib/ManyFetches.ts b/test/server/lib/ManyFetches.ts index 2f3812a042..0565bf889a 100644 --- a/test/server/lib/ManyFetches.ts +++ b/test/server/lib/ManyFetches.ts @@ -12,7 +12,7 @@ import {createTestDir, EnvironmentSnapshot, setTmpLogLevel} from 'test/server/te import {assert} from 'chai'; import * as cookie from 'cookie'; import fetch from 'node-fetch'; -import WebSocket from 'ws'; +import {GristClientSocket} from 'app/client/components/GristClientSocket'; describe('ManyFetches', function() { this.timeout(30000); @@ -244,7 +244,7 @@ describe('ManyFetches', function() { return function createConnectionFunc() { let clientId: string = '0'; return GristWSConnection.create(null, { - makeWebSocket(url: string): any { return new WebSocket(url, undefined, { headers }); }, + makeWebSocket(url: string) { return new GristClientSocket(url, { headers }); }, getTimezone() { return Promise.resolve('UTC'); }, getPageUrl() { return pageUrl; }, getDocWorkerUrl() { return Promise.resolve(docWorkerUrl); }, diff --git a/yarn.lock b/yarn.lock index 079d80a64f..580c5ad0d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -544,6 +544,11 @@ resolved "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz" integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ== +"@socket.io/component-emitter@~3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" + integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg== + "@sqltools/formatter@^1.2.2": version "1.2.3" resolved "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz" @@ -626,6 +631,18 @@ resolved "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.2.tgz" integrity sha1-GMyw/RJoCOSYqCDLgUnAByorkGY= +"@types/cookie@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" + integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== + +"@types/cors@^2.8.12": + version "2.8.17" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b" + integrity sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA== + dependencies: + "@types/node" "*" + "@types/d3@^3": version "3.5.44" resolved "https://registry.npmjs.org/@types/d3/-/d3-3.5.44.tgz" @@ -844,6 +861,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4" integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg== +"@types/node@>=10.0.0": + version "20.11.16" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.16.tgz#4411f79411514eb8e2926f036c86c9f0e4ec6708" + integrity sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ== + dependencies: + undici-types "~5.26.4" + "@types/node@^14.0.1": version "14.17.6" resolved "https://registry.npmjs.org/@types/node/-/node-14.17.6.tgz" @@ -1253,7 +1277,7 @@ accept-language-parser@1.5.0: resolved "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz" integrity sha1-iHfFQECo3LWeCgfZwf3kIpgzR5E= -accepts@~1.3.8: +accepts@~1.3.4, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== @@ -1667,6 +1691,11 @@ base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +base64id@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6" + integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== + basic-auth@~2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz" @@ -2573,11 +2602,24 @@ cookie@0.5.0: resolved "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +cookie@~0.4.1: + version "0.4.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" + integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +cors@~2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + crc-32@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz" @@ -2737,7 +2779,7 @@ debug@4: dependencies: ms "2.1.2" -debug@4.3.4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: +debug@4.3.4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@~4.3.1: version "4.3.4" resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -3072,6 +3114,38 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" +engine.io-client@^6.5.3: + version "6.5.3" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.5.3.tgz#4cf6fa24845029b238f83c628916d9149c399bc5" + integrity sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.11.0" + xmlhttprequest-ssl "~2.0.0" + +engine.io-parser@~5.2.1: + version "5.2.2" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.2.tgz#37b48e2d23116919a3453738c5720455e64e1c49" + integrity sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw== + +engine.io@^6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.4.tgz#6822debf324e781add2254e912f8568508850cdc" + integrity sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg== + dependencies: + "@types/cookie" "^0.4.1" + "@types/cors" "^2.8.12" + "@types/node" ">=10.0.0" + accepts "~1.3.4" + base64id "2.0.0" + cookie "~0.4.1" + cors "~2.8.5" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.11.0" + enhanced-resolve@^5.9.3: version "5.9.3" resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz" @@ -5894,7 +5968,7 @@ nwsapi@^2.2.7: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30" integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ== -object-assign@^4.0.1, object-assign@^4.1.1: +object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= @@ -7863,6 +7937,11 @@ underscore@1.12.1, underscore@>=1.8.3, underscore@^1.8.0: resolved "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz" integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw== +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + unique-filename@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" @@ -8053,7 +8132,7 @@ value-or-function@^3.0.0: resolved "https://registry.yarnpkg.com/value-or-function/-/value-or-function-3.0.0.tgz#1c243a50b595c1be54a754bfece8563b9ff8d813" integrity sha512-jdBB2FrWvQC/pnPtIqcLsMaQgjhdb6B7tk1MMyTKapox+tQZbdRP4uLxu/JY0t7fbfDCUMnuelzEYv5GsxHhdg== -vary@~1.1.2: +vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= @@ -8369,6 +8448,11 @@ ws@^8.14.2: resolved "https://registry.yarnpkg.com/ws/-/ws-8.15.0.tgz#db080a279260c5f532fc668d461b8346efdfcf86" integrity sha512-H/Z3H55mrcrgjFwI+5jKavgXvwQLtfPCUEp6pi35VhoB0pfcHnSoyuTzkBEZpzq49g1193CUEwIvmsjcotenYw== +ws@~8.11.0: + version "8.11.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143" + integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg== + xdg-basedir@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz" @@ -8438,6 +8522,11 @@ xmldom@^0.1.0, xmldom@~0.1.15: resolved "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz" integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ== +xmlhttprequest-ssl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67" + integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A== + xpath.js@>=0.0.3: version "1.1.0" resolved "https://registry.npmjs.org/xpath.js/-/xpath.js-1.1.0.tgz"