mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
Support HTTP long polling as an alternative to WebSockets (#859)
The motivation for supporting an alternative to WebSockets is that while all browsers supported by Grist offer native WebSocket support, some networking environments do not allow WebSocket traffic. Engine.IO is used as the underlying implementation of HTTP long polling. The Grist client will first attempt a regular WebSocket connection, using the same protocol and endpoints as before, but fall back to long polling using Engine.IO if the WebSocket connection fails. Include these changes: - CORS websocket requests are now rejected as a stronger security measure. This shouldn’t affect anything in practice; but previously it could be possible to make unauthenticated websocket requests from another origin. - GRIST_HOST variable no longer affects CORS responses (also should not affect anything in practice, as it wasn't serving a useful purpose)
This commit is contained in:
parent
b4c2562029
commit
96b652fb52
152
app/client/components/GristClientSocket.ts
Normal file
152
app/client/components/GristClientSocket.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import WS from 'ws';
|
||||
import { Socket as EIOSocket } from 'engine.io-client';
|
||||
|
||||
export interface GristClientSocketOptions {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class GristClientSocket {
|
||||
// Exactly one of _wsSocket and _eioSocket will be set at any one time.
|
||||
private _wsSocket: WS.WebSocket | WebSocket | undefined;
|
||||
private _eioSocket: EIOSocket | undefined;
|
||||
|
||||
// Set to true if a WebSocket connection (in _wsSocket) was succesfully
|
||||
// established. Errors from the underlying WebSocket are not forwarded to
|
||||
// the client until that point, in case we end up downgrading to Engine.IO.
|
||||
private _wsConnected: 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<any>) {
|
||||
// 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._wsConnected = true;
|
||||
this._openHandler?.();
|
||||
}
|
||||
|
||||
private _onWSError(ev: Event) {
|
||||
if (!this._wsConnected) {
|
||||
// The WebSocket connection attempt failed. Switch to Engine.IO.
|
||||
this._destroyWSSocket();
|
||||
this._createEIOSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
// WebSocket error events are deliberately void of information,
|
||||
// see https://websockets.spec.whatwg.org/#eventdef-websocket-error,
|
||||
// so we ignore the incoming event.
|
||||
this._errorHandler?.(new Error("WebSocket error"));
|
||||
}
|
||||
|
||||
private _onWSClose() {
|
||||
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(err: string | Error) {
|
||||
this._errorHandler?.(typeof err === "string" ? new Error(err) : err);
|
||||
}
|
||||
|
||||
private _onEIOClose() {
|
||||
this._closeHandler?.();
|
||||
}
|
||||
}
|
@ -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<string|null>
|
||||
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<typeof setTimeout> | 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) {
|
||||
@ -317,7 +317,7 @@ export class GristWSConnection extends Disposable {
|
||||
private _sendHeartbeat() {
|
||||
this.send(JSON.stringify({
|
||||
beat: 'alive',
|
||||
url: G.window.location.href,
|
||||
url: this._settings.getPageUrl(),
|
||||
docId: this._assignmentId,
|
||||
}));
|
||||
}
|
||||
@ -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 = () => {
|
||||
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<string, Client>(); // 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,14 +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) {
|
||||
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.
|
||||
req.url = parseFirstUrlPart('dw', req.url!).path;
|
||||
req.url = parseFirstUrlPart('v', req.url).path;
|
||||
await this._options.hosts.addOrgInfo(req);
|
||||
}
|
||||
private async _onWebSocketConnection(websocket: GristServerSocket, req: http.IncomingMessage) {
|
||||
|
||||
// Parse the cookie in the request to get the sessionId.
|
||||
const sessionId = this.sessions.getSessionIdFromRequest(req);
|
||||
@ -255,15 +245,28 @@ 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: async (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.
|
||||
req.url = parseFirstUrlPart('dw', req.url!).path;
|
||||
req.url = parseFirstUrlPart('v', req.url).path;
|
||||
await this._options.hosts.addOrgInfo(req);
|
||||
}
|
||||
|
||||
return 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;
|
||||
|
138
app/server/lib/GristServerSocket.ts
Normal file
138
app/server/lib/GristServerSocket.ts
Normal file
@ -0,0 +1,138 @@
|
||||
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<number, (err: Error) => 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)) {
|
||||
// send was successful: pass no Error to callback
|
||||
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) {
|
||||
const wrappedHandler = (msg: Buffer) => handler(msg.toString());
|
||||
this._ws.on('message', wrappedHandler);
|
||||
this._eventHandlers.push({ event: 'message', handler: wrappedHandler });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
111
app/server/lib/GristSocketServer.ts
Normal file
111
app/server/lib/GristSocketServer.ts
Normal file
@ -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) => Promise<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', async (request, socket, head) => {
|
||||
if (this._options?.verifyClient && !await 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", async (req, res) => {
|
||||
// Intercept requests that have transport=polling in their querystring
|
||||
if (/[&?]transport=polling(&|$)/.test(req.url ?? '')) {
|
||||
if (this._options?.verifyClient && !await 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);
|
||||
}
|
||||
}
|
@ -8,7 +8,9 @@ import {RequestWithGrist} from 'app/server/lib/GristServer';
|
||||
import log from 'app/server/lib/log';
|
||||
import {Permit} from 'app/server/lib/Permit';
|
||||
import {Request, Response} from 'express';
|
||||
import { IncomingMessage } from 'http';
|
||||
import {Writable} from 'stream';
|
||||
import { TLSSocket } from 'tls';
|
||||
|
||||
// log api details outside of dev environment (when GRIST_HOSTED_VERSION is set)
|
||||
const shouldLogApiDetails = Boolean(process.env.GRIST_HOSTED_VERSION);
|
||||
@ -75,33 +77,33 @@ export function getOrgUrl(req: Request, path: string = '/') {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true for requests from permitted origins. For such requests, an
|
||||
* "Access-Control-Allow-Origin" header is added to the response. Vary: Origin
|
||||
* is also set to reflect the fact that the headers are a function of the origin,
|
||||
* to prevent inappropriate caching on the browser's side.
|
||||
* Returns true for requests from permitted origins. For such requests, if
|
||||
* a Response object is provided, an "Access-Control-Allow-Origin" header is added
|
||||
* to the response. Vary: Origin is also set to reflect the fact that the headers
|
||||
* are a function of the origin, to prevent inappropriate caching on the browser's side.
|
||||
*/
|
||||
export function trustOrigin(req: Request, resp: Response): boolean {
|
||||
export function trustOrigin(req: IncomingMessage, resp?: Response): boolean {
|
||||
// TODO: We may want to consider changing allowed origin values in the future.
|
||||
// Note that the request origin is undefined for non-CORS requests.
|
||||
const origin = req.get('origin');
|
||||
const origin = req.headers.origin;
|
||||
if (!origin) { return true; } // Not a CORS request.
|
||||
if (process.env.GRIST_HOST && req.hostname === process.env.GRIST_HOST) { return true; }
|
||||
if (!allowHost(req, new URL(origin))) { return false; }
|
||||
|
||||
if (resp) {
|
||||
// For a request to a custom domain, the full hostname must match.
|
||||
resp.header("Access-Control-Allow-Origin", origin);
|
||||
resp.header("Vary", "Origin");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns whether req satisfies the given allowedHost. Unless req is to a custom domain, it is
|
||||
// enough if only the base domains match. Differing ports are allowed, which helps in dev/testing.
|
||||
export function allowHost(req: Request, allowedHost: string|URL) {
|
||||
const mreq = req as RequestWithOrg;
|
||||
export function allowHost(req: IncomingMessage, allowedHost: string|URL) {
|
||||
const proto = getEndUserProtocol(req);
|
||||
const actualUrl = new URL(getOriginUrl(req));
|
||||
const allowedUrl = (typeof allowedHost === 'string') ? new URL(`${proto}://${allowedHost}`) : allowedHost;
|
||||
if (mreq.isCustomHost) {
|
||||
if ((req as RequestWithOrg).isCustomHost) {
|
||||
// For a request to a custom domain, the full hostname must match.
|
||||
return actualUrl.hostname === allowedUrl.hostname;
|
||||
} else {
|
||||
@ -330,8 +332,8 @@ export interface RequestWithGristInfo extends Request {
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
|
||||
* https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/x-forwarded-headers.html
|
||||
*/
|
||||
export function getOriginUrl(req: Request) {
|
||||
const host = req.get('host')!;
|
||||
export function getOriginUrl(req: IncomingMessage) {
|
||||
const host = req.headers.host;
|
||||
const protocol = getEndUserProtocol(req);
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
@ -342,11 +344,13 @@ export function getOriginUrl(req: Request) {
|
||||
* otherwise X-Forwarded-Proto is set on the provided request, otherwise
|
||||
* the protocol of the request itself.
|
||||
*/
|
||||
export function getEndUserProtocol(req: Request) {
|
||||
export function getEndUserProtocol(req: IncomingMessage) {
|
||||
if (process.env.APP_HOME_URL) {
|
||||
return new URL(process.env.APP_HOME_URL).protocol.replace(':', '');
|
||||
}
|
||||
return req.get("X-Forwarded-Proto") || req.protocol;
|
||||
// TODO we shouldn't blindly trust X-Forwarded-Proto. See the Express approach:
|
||||
// https://expressjs.com/en/5x/api.html#trust.proxy.options.table
|
||||
return req.headers["x-forwarded-proto"] || ((req.socket as TLSSocket).encrypted ? 'https' : 'http');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -82,7 +82,7 @@
|
||||
"@types/tmp": "0.0.33",
|
||||
"@types/uuid": "3.4.4",
|
||||
"@types/which": "2.0.1",
|
||||
"@types/ws": "^6",
|
||||
"@types/ws": "^8",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"app-module-path": "2.2.0",
|
||||
@ -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",
|
||||
|
@ -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';
|
||||
@ -21,10 +21,28 @@ import {Sessions} from 'app/server/lib/Sessions';
|
||||
import {TcpForwarder} from 'test/server/tcpForwarder';
|
||||
import * as testUtils from 'test/server/testUtils';
|
||||
import * as session from '@gristlabs/express-session';
|
||||
import { Hosts, RequestOrgInfo } from 'app/server/lib/extractOrg';
|
||||
|
||||
const SQLiteStore = require('@gristlabs/connect-sqlite3')(session);
|
||||
promisifyAll(SQLiteStore.prototype);
|
||||
|
||||
|
||||
// Just enough implementation of Hosts to be able to fake using a custom host.
|
||||
class FakeHosts {
|
||||
|
||||
public isCustomHost = false;
|
||||
|
||||
public get asHosts() { return this as unknown as Hosts; }
|
||||
|
||||
public async addOrgInfo<T extends http.IncomingMessage>(req: T): Promise<T & RequestOrgInfo> {
|
||||
return Object.assign(req, {
|
||||
isCustomHost: this.isCustomHost,
|
||||
org: "example",
|
||||
url: req.url!
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('Comm', function() {
|
||||
|
||||
testUtils.setTmpLogLevel(process.env.VERBOSE ? 'debug' : 'warn');
|
||||
@ -34,6 +52,7 @@ describe('Comm', function() {
|
||||
|
||||
let server: http.Server;
|
||||
let sessions: Sessions;
|
||||
let fakeHosts: FakeHosts;
|
||||
let comm: Comm|null = null;
|
||||
const sandbox = sinon.createSandbox();
|
||||
|
||||
@ -51,14 +70,19 @@ describe('Comm', function() {
|
||||
|
||||
function startComm(methods: {[name: string]: ClientMethod}) {
|
||||
server = http.createServer();
|
||||
comm = new Comm(server, {sessions});
|
||||
fakeHosts = new FakeHosts();
|
||||
comm = new Comm(server, {sessions, hosts: fakeHosts.asHosts});
|
||||
comm.registerMethods(methods);
|
||||
return listenPromise(server.listen(0, 'localhost'));
|
||||
}
|
||||
|
||||
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 +119,43 @@ describe('Comm', function() {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
function getMessages(ws: WebSocket, count: number): Promise<any[]> {
|
||||
function getMessages(ws: GristClientSocket, count: number): Promise<any[]> {
|
||||
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<WebSocket>((resolve, reject) => {
|
||||
ws.on('open', () => resolve(ws));
|
||||
ws.on('error', reject);
|
||||
function connect(options?: GristClientSocketOptions): Promise<GristClientSocket> {
|
||||
const ws = new GristClientSocket('ws://localhost:' + (server.address() as AddressInfo).port, options);
|
||||
return new Promise<GristClientSocket>((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 +403,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 +496,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,17 +527,54 @@ 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();
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
it('origin should match base domain of host', async () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('with custom domains, origin should match the full hostname', async () => {
|
||||
fakeHosts.isCustomHost = true;
|
||||
|
||||
// For a request to a custom domain, the full hostname must match.
|
||||
await checkOrigin({origin: "https://front.example.com", host: "worker.example.com"}, false);
|
||||
await checkOrigin({origin: "https://front.example.com", host: "front.example.com"}, true);
|
||||
await checkOrigin({origin: "https://front.example.com:3000", host: "front.example.com"}, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Waits for condFunc() to return true, for up to timeoutMs milliseconds, sleeping for stepMs
|
||||
// between checks. Returns true if succeeded, false if failed.
|
||||
async function waitForCondition(condFunc: () => boolean, timeoutMs = 1000, stepMs = 10): Promise<boolean> {
|
||||
// between checks. Returns if succeeded, throws if failed.
|
||||
async function waitForCondition(condFunc: () => boolean, timeoutMs = 1000, stepMs = 10): Promise<void> {
|
||||
const end = Date.now() + timeoutMs;
|
||||
while (Date.now() < end) {
|
||||
if (condFunc()) { return true; }
|
||||
if (condFunc()) { return; }
|
||||
await delay(stepMs);
|
||||
}
|
||||
return false;
|
||||
throw new Error(`Condition not met after ${timeoutMs}ms: ${condFunc.toString()}`);
|
||||
}
|
||||
|
||||
// Returns a range of count consecutive numbers starting with start.
|
||||
@ -513,7 +587,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; },
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -4879,11 +4879,17 @@ function testDocApi() {
|
||||
delete chimpyConfig.headers["X-Requested-With"];
|
||||
delete anonConfig.headers["X-Requested-With"];
|
||||
|
||||
// Target a more realistic Host than "localhost:port"
|
||||
anonConfig.headers.Host = chimpyConfig.headers.Host = 'api.example.com';
|
||||
|
||||
const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;
|
||||
const data = {records: [{fields: {}}]};
|
||||
const data = { records: [{ fields: {} }] };
|
||||
|
||||
const allowedOrigin = 'http://front.example.com';
|
||||
const forbiddenOrigin = 'http://evil.com';
|
||||
|
||||
// Normal same origin requests
|
||||
anonConfig.headers.Origin = serverUrl;
|
||||
anonConfig.headers.Origin = allowedOrigin;
|
||||
let response: AxiosResponse;
|
||||
for (response of [
|
||||
await axios.post(url, data, anonConfig),
|
||||
@ -4893,13 +4899,13 @@ function testDocApi() {
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers['access-control-allow-methods'], 'GET, PATCH, PUT, POST, DELETE, OPTIONS');
|
||||
assert.equal(response.headers['access-control-allow-headers'], 'Authorization, Content-Type, X-Requested-With');
|
||||
assert.equal(response.headers['access-control-allow-origin'], serverUrl);
|
||||
assert.equal(response.headers['access-control-allow-origin'], allowedOrigin);
|
||||
assert.equal(response.headers['access-control-allow-credentials'], 'true');
|
||||
}
|
||||
|
||||
// Cross origin requests from untrusted origin.
|
||||
for (const config of [anonConfig, chimpyConfig]) {
|
||||
config.headers.Origin = "https://evil.com/";
|
||||
config.headers.Origin = forbiddenOrigin;
|
||||
for (response of [
|
||||
await axios.post(url, data, config),
|
||||
await axios.get(url, config),
|
||||
|
170
test/server/lib/GristSockets.ts
Normal file
170
test/server/lib/GristSockets.ts
Normal file
@ -0,0 +1,170 @@
|
||||
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 () {
|
||||
|
||||
for (const webSocketsSupported of [true, false]) {
|
||||
describe(`when the networks ${webSocketsSupported ? "supports" : "does not support"} WebSockets`, function () {
|
||||
|
||||
let server: http.Server | null;
|
||||
let serverPort: number;
|
||||
let socketServer: GristSocketServer | null;
|
||||
let proxy: httpProxy | null;
|
||||
let proxyServer: http.Server | null;
|
||||
let proxyPort: number;
|
||||
let wsAddress: string;
|
||||
|
||||
beforeEach(async function () {
|
||||
await startSocketServer();
|
||||
await startProxyServer();
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
await stopProxyServer();
|
||||
await stopSocketServer();
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function stopSocketServer() {
|
||||
await fromCallback(cb => socketServer?.close(cb));
|
||||
await fromCallback(cb => { server?.close(); server?.closeAllConnections(); server?.on("close", cb); });
|
||||
socketServer = server = null;
|
||||
}
|
||||
|
||||
// Start an HTTP proxy that supports WebSockets or not
|
||||
async function startProxyServer() {
|
||||
proxy = httpProxy.createProxy({
|
||||
target: `http://localhost:${serverPort}`,
|
||||
ws: webSocketsSupported,
|
||||
timeout: 1000,
|
||||
});
|
||||
proxy.on('error', () => { });
|
||||
proxyServer = http.createServer();
|
||||
|
||||
if (webSocketsSupported) {
|
||||
// prevent non-WebSocket requests
|
||||
proxyServer.on('request', (req, res) => res.writeHead(404).end());
|
||||
// proxy WebSocket requests
|
||||
proxyServer.on('upgrade', (req, socket, head) => proxy!.ws(req, socket, head));
|
||||
} else {
|
||||
// proxy non-WebSocket requests
|
||||
proxyServer.on('request', (req, res) => proxy!.web(req, res));
|
||||
// don't leave WebSocket connection attempts hanging
|
||||
proxyServer.on('upgrade', (req, socket, head) => socket.destroy());
|
||||
}
|
||||
|
||||
await listenPromise(proxyServer.listen(0, 'localhost'));
|
||||
proxyPort = (proxyServer.address() as AddressInfo).port;
|
||||
wsAddress = `ws://localhost:${proxyPort}`;
|
||||
}
|
||||
|
||||
async function stopProxyServer() {
|
||||
if (proxy) {
|
||||
proxy.close();
|
||||
proxy = null;
|
||||
}
|
||||
if (proxyServer) {
|
||||
const server = proxyServer;
|
||||
await fromCallback(cb => { server.close(cb); server.closeAllConnections(); });
|
||||
}
|
||||
proxyServer = null;
|
||||
}
|
||||
|
||||
function getMessages(ws: GristClientSocket, count: number): Promise<string[]> {
|
||||
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<GristClientSocket> {
|
||||
const socket = new GristClientSocket(url);
|
||||
return new Promise<GristClientSocket>((resolve, reject) => {
|
||||
socket.onopen = () => {
|
||||
socket.onerror = null;
|
||||
resolve(socket);
|
||||
};
|
||||
socket.onerror = (err) => {
|
||||
socket.onopen = null;
|
||||
reject(err);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
it("should expose initial request", async function () {
|
||||
const connectionPromise = new Promise<http.IncomingMessage>((resolve) => {
|
||||
socketServer!.onconnection = (socket, req) => {
|
||||
resolve(req);
|
||||
};
|
||||
});
|
||||
const clientWs = new GristClientSocket(wsAddress + "/path?query=value", {
|
||||
headers: { "cookie": "session=1234" }
|
||||
});
|
||||
const req = await connectionPromise;
|
||||
clientWs.close();
|
||||
|
||||
// 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);
|
||||
};
|
||||
};
|
||||
const clientWs = await connectClient(wsAddress);
|
||||
clientWs.send("world");
|
||||
assert.deepEqual(await getMessages(clientWs, 1), ["hello, world"]);
|
||||
clientWs.close();
|
||||
});
|
||||
|
||||
it("should invoke send callbacks", async function () {
|
||||
const connectionPromise = new Promise<void>((resolve) => {
|
||||
socketServer!.onconnection = (socket, req) => {
|
||||
socket.send("hello", () => resolve());
|
||||
};
|
||||
});
|
||||
const clientWs = await connectClient(wsAddress);
|
||||
await connectionPromise;
|
||||
clientWs.close();
|
||||
});
|
||||
|
||||
it("should emit close event for client", async function () {
|
||||
const clientWs = await connectClient(wsAddress);
|
||||
const closePromise = new Promise<void>(resolve => {
|
||||
clientWs.onclose = resolve;
|
||||
});
|
||||
clientWs.close();
|
||||
await closePromise;
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
});
|
@ -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); },
|
||||
|
105
yarn.lock
105
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"
|
||||
@ -988,10 +1012,10 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/ws@^6":
|
||||
version "6.0.4"
|
||||
resolved "https://registry.npmjs.org/@types/ws/-/ws-6.0.4.tgz"
|
||||
integrity sha512-PpPrX7SZW9re6+Ha8ojZG4Se8AZXgf0GK6zmfqEuCsY49LFDNXO3SByp44X3dFEqtB73lkCDAdUazhAjVPiNwg==
|
||||
"@types/ws@^8":
|
||||
version "8.5.10"
|
||||
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787"
|
||||
integrity sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user