From d477972c68fc8c8e8d610aa7287db87ba90e55c7 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 3 Mar 2023 09:12:34 +0000 Subject: [PATCH] Add origin checks to web sockets (#6048) * Move splitOnFirstEquals to util I will be making use of this to parse the forwarded header. * Type splitOnFirstEquals with two items Also add some test cases. * Check origin header on web sockets * Update changelog with origin check * Fix web sockets not closing with error code --- CHANGELOG.md | 12 +++ src/common/http.ts | 1 + src/node/cli.ts | 23 ++--- src/node/http.ts | 75 +++++++++++++- src/node/routes/domainProxy.ts | 6 +- src/node/routes/errors.ts | 8 +- src/node/routes/pathProxy.ts | 3 +- src/node/routes/vscode.ts | 4 +- src/node/util.ts | 10 ++ src/node/wsRouter.ts | 3 + test/unit/node/cli.test.ts | 26 ----- test/unit/node/http.test.ts | 147 +++++++++++++++++++-------- test/unit/node/proxy.test.ts | 48 +++++++-- test/unit/node/routes/health.test.ts | 4 +- test/unit/node/routes/vscode.test.ts | 30 ++++++ test/unit/node/util.test.ts | 38 +++++++ test/utils/httpserver.ts | 16 ++- 17 files changed, 353 insertions(+), 101 deletions(-) create mode 100644 test/unit/node/routes/vscode.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 36bd14847d36..9663b9cf6dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,18 @@ Code v99.99.999 --> +## Unreleased + +Code v1.75.1 + +### Security + +Add an origin check to web sockets to prevent a cross-site hijacking attack that +affects those who use older or niche browsers that do not support SameSite +cookies and those who access code-server under a shared domain with other users +on separate sub-domains. The check requires the host header to be set so if you +use a reverse proxy ensure it forwards that information. + ## [4.10.0](https://github.com/coder/code-server/releases/tag/v4.10.0) - 2023-02-15 Code v1.75.1 diff --git a/src/common/http.ts b/src/common/http.ts index 5709c455c842..4235df172cec 100644 --- a/src/common/http.ts +++ b/src/common/http.ts @@ -4,6 +4,7 @@ export enum HttpCode { NotFound = 404, BadRequest = 400, Unauthorized = 401, + Forbidden = 403, LargePayload = 413, ServerError = 500, } diff --git a/src/node/cli.ts b/src/node/cli.ts index b8498c1eb4ef..75e4ad200414 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -3,7 +3,15 @@ import { promises as fs } from "fs" import { load } from "js-yaml" import * as os from "os" import * as path from "path" -import { canConnect, generateCertificate, generatePassword, humanPath, paths, isNodeJSErrnoException } from "./util" +import { + canConnect, + generateCertificate, + generatePassword, + humanPath, + paths, + isNodeJSErrnoException, + splitOnFirstEquals, +} from "./util" const DEFAULT_SOCKET_PATH = path.join(os.tmpdir(), "vscode-ipc") @@ -292,19 +300,6 @@ export const optionDescriptions = (opts: Partial export const self = (req: express.Request): string => { return normalize(`${req.baseUrl}${req.originalUrl.endsWith("/") ? "/" : ""}`, true) } + +function getFirstHeader(req: http.IncomingMessage, headerName: string): string | undefined { + const val = req.headers[headerName] + return Array.isArray(val) ? val[0] : val +} + +/** + * Throw an error if origin checks fail. Call `next` if provided. + */ +export function ensureOrigin(req: express.Request, _?: express.Response, next?: express.NextFunction): void { + if (!authenticateOrigin(req)) { + throw new HttpError("Forbidden", HttpCode.Forbidden) + } + if (next) { + next() + } +} + +/** + * Authenticate the request origin against the host. + */ +export function authenticateOrigin(req: express.Request): boolean { + // A missing origin probably means the source is non-browser. Not sure we + // have a use case for this but let it through. + const originRaw = getFirstHeader(req, "origin") + if (!originRaw) { + return true + } + + let origin: string + try { + origin = new URL(originRaw).host.trim().toLowerCase() + } catch (error) { + return false // Malformed URL. + } + + // Honor Forwarded if present. + const forwardedRaw = getFirstHeader(req, "forwarded") + if (forwardedRaw) { + const parts = forwardedRaw.split(/[;,]/) + for (let i = 0; i < parts.length; ++i) { + const [key, value] = splitOnFirstEquals(parts[i]) + if (key.trim().toLowerCase() === "host" && value) { + return origin === value.trim().toLowerCase() + } + } + } + + // Honor X-Forwarded-Host if present. + const xHost = getFirstHeader(req, "x-forwarded-host") + if (xHost) { + return origin === xHost.trim().toLowerCase() + } + + // A missing host likely means the reverse proxy has not been configured to + // forward the host which means we cannot perform the check. Emit a warning + // so an admin can fix the issue. + const host = getFirstHeader(req, "host") + if (!host) { + logger.warn(`no host headers found; blocking request to ${req.originalUrl}`) + return false + } + + return origin === host.trim().toLowerCase() +} diff --git a/src/node/routes/domainProxy.ts b/src/node/routes/domainProxy.ts index 83194b8c18c1..3d8273c49260 100644 --- a/src/node/routes/domainProxy.ts +++ b/src/node/routes/domainProxy.ts @@ -1,6 +1,6 @@ import { Request, Router } from "express" import { HttpCode, HttpError } from "../../common/http" -import { authenticated, ensureAuthenticated, redirect, self } from "../http" +import { authenticated, ensureAuthenticated, ensureOrigin, redirect, self } from "../http" import { proxy } from "../proxy" import { Router as WsRouter } from "../wsRouter" @@ -78,10 +78,8 @@ wsRouter.ws("*", async (req, _, next) => { if (!port) { return next() } - - // Must be authenticated to use the proxy. + ensureOrigin(req) await ensureAuthenticated(req) - proxy.ws(req, req.ws, req.head, { ignorePath: true, target: `http://0.0.0.0:${port}${req.originalUrl}`, diff --git a/src/node/routes/errors.ts b/src/node/routes/errors.ts index 9b5fdae97211..712bba604e5c 100644 --- a/src/node/routes/errors.ts +++ b/src/node/routes/errors.ts @@ -63,5 +63,11 @@ export const errorHandler: express.ErrorRequestHandler = async (err, req, res, n export const wsErrorHandler: express.ErrorRequestHandler = async (err, req, res, next) => { logger.error(`${err.message} ${err.stack}`) - ;(req as WebsocketRequest).ws.end() + let statusCode = 500 + if (errorHasStatusCode(err)) { + statusCode = err.statusCode + } else if (errorHasCode(err) && notFoundCodes.includes(err.code)) { + statusCode = HttpCode.NotFound + } + ;(req as WebsocketRequest).ws.end(`HTTP/1.1 ${statusCode} ${err.message}\r\n\r\n`) } diff --git a/src/node/routes/pathProxy.ts b/src/node/routes/pathProxy.ts index e21b849ecca6..f574a4494548 100644 --- a/src/node/routes/pathProxy.ts +++ b/src/node/routes/pathProxy.ts @@ -3,7 +3,7 @@ import * as path from "path" import * as qs from "qs" import * as pluginapi from "../../../typings/pluginapi" import { HttpCode, HttpError } from "../../common/http" -import { authenticated, ensureAuthenticated, redirect, self } from "../http" +import { authenticated, ensureAuthenticated, ensureOrigin, redirect, self } from "../http" import { proxy as _proxy } from "../proxy" const getProxyTarget = (req: Request, passthroughPath?: boolean): string => { @@ -50,6 +50,7 @@ export async function wsProxy( passthroughPath?: boolean }, ): Promise { + ensureOrigin(req) await ensureAuthenticated(req) _proxy.ws(req, req.ws, req.head, { ignorePath: true, diff --git a/src/node/routes/vscode.ts b/src/node/routes/vscode.ts index b372531ee53c..1de3e7cec81a 100644 --- a/src/node/routes/vscode.ts +++ b/src/node/routes/vscode.ts @@ -7,7 +7,7 @@ import { WebsocketRequest } from "../../../typings/pluginapi" import { logError } from "../../common/util" import { CodeArgs, toCodeArgs } from "../cli" import { isDevMode } from "../constants" -import { authenticated, ensureAuthenticated, redirect, replaceTemplates, self } from "../http" +import { authenticated, ensureAuthenticated, ensureOrigin, redirect, replaceTemplates, self } from "../http" import { SocketProxyProvider } from "../socket" import { isFile, loadAMDModule } from "../util" import { Router as WsRouter } from "../wsRouter" @@ -173,7 +173,7 @@ export class CodeServerRouteWrapper { this.router.get("/", this.ensureCodeServerLoaded, this.$root) this.router.get("/manifest.json", this.manifest) this.router.all("*", ensureAuthenticated, this.ensureCodeServerLoaded, this.$proxyRequest) - this._wsRouterWrapper.ws("*", ensureAuthenticated, this.ensureCodeServerLoaded, this.$proxyWebsocket) + this._wsRouterWrapper.ws("*", ensureOrigin, ensureAuthenticated, this.ensureCodeServerLoaded, this.$proxyWebsocket) } dispose() { diff --git a/src/node/util.ts b/src/node/util.ts index 698fe6045037..6abb3805bc95 100644 --- a/src/node/util.ts +++ b/src/node/util.ts @@ -541,3 +541,13 @@ export const loadAMDModule = async (amdPath: string, exportName: string): Pro return module[exportName] as T } + +/** + * Split a string on the first equals. The result will always be an array with + * two items regardless of how many equals there are. The second item will be + * undefined if empty or missing. + */ +export function splitOnFirstEquals(str: string): [string, string | undefined] { + const split = str.split(/=(.+)?/, 2) + return [split[0], split[1]] +} diff --git a/src/node/wsRouter.ts b/src/node/wsRouter.ts index 2f6f18ba3b53..67907ecc1dc4 100644 --- a/src/node/wsRouter.ts +++ b/src/node/wsRouter.ts @@ -32,6 +32,9 @@ export class WebsocketRouter { /** * Handle a websocket at this route. Note that websockets are immediately * paused when they come in. + * + * If the origin header exists it must match the host or the connection will + * be prevented. */ public ws(route: expressCore.PathParams, ...handlers: pluginapi.WebSocketHandler[]): void { this.router.get( diff --git a/test/unit/node/cli.test.ts b/test/unit/node/cli.test.ts index 3bb1024dcd32..7e7fa29a4e45 100644 --- a/test/unit/node/cli.test.ts +++ b/test/unit/node/cli.test.ts @@ -11,7 +11,6 @@ import { readSocketPath, setDefaults, shouldOpenInExistingInstance, - splitOnFirstEquals, toCodeArgs, optionDescriptions, options, @@ -535,31 +534,6 @@ describe("cli", () => { }) }) -describe("splitOnFirstEquals", () => { - it("should split on the first equals", () => { - const testStr = "enabled-proposed-api=test=value" - const actual = splitOnFirstEquals(testStr) - const expected = ["enabled-proposed-api", "test=value"] - expect(actual).toEqual(expect.arrayContaining(expected)) - }) - it("should split on first equals regardless of multiple equals signs", () => { - const testStr = - "hashed-password=$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY" - const actual = splitOnFirstEquals(testStr) - const expected = [ - "hashed-password", - "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY", - ] - expect(actual).toEqual(expect.arrayContaining(expected)) - }) - it("should always return the first element before an equals", () => { - const testStr = "auth=" - const actual = splitOnFirstEquals(testStr) - const expected = ["auth"] - expect(actual).toEqual(expect.arrayContaining(expected)) - }) -}) - describe("shouldSpawnCliProcess", () => { it("should return false if no 'extension' related args passed in", async () => { const args = {} diff --git a/test/unit/node/http.test.ts b/test/unit/node/http.test.ts index b5dc20402d67..9c4626cc2c10 100644 --- a/test/unit/node/http.test.ts +++ b/test/unit/node/http.test.ts @@ -1,55 +1,118 @@ import { getMockReq } from "@jest-mock/express" -import { constructRedirectPath, relativeRoot } from "../../../src/node/http" +import * as http from "../../../src/node/http" +import { mockLogger } from "../../utils/helpers" describe("http", () => { + beforeEach(() => { + mockLogger() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + it("should construct a relative path to the root", () => { - expect(relativeRoot("/")).toStrictEqual(".") - expect(relativeRoot("/foo")).toStrictEqual(".") - expect(relativeRoot("/foo/")).toStrictEqual("./..") - expect(relativeRoot("/foo/bar ")).toStrictEqual("./..") - expect(relativeRoot("/foo/bar/")).toStrictEqual("./../..") + expect(http.relativeRoot("/")).toStrictEqual(".") + expect(http.relativeRoot("/foo")).toStrictEqual(".") + expect(http.relativeRoot("/foo/")).toStrictEqual("./..") + expect(http.relativeRoot("/foo/bar ")).toStrictEqual("./..") + expect(http.relativeRoot("/foo/bar/")).toStrictEqual("./../..") }) -}) -describe("constructRedirectPath", () => { - it("should preserve slashes in queryString so they are human-readable", () => { - const mockReq = getMockReq({ - originalUrl: "localhost:8080", + describe("origin", () => { + ;[ + { + origin: "", + host: "", + expected: true, + }, + { + origin: "http://localhost:8080", + host: "", + expected: false, + }, + { + origin: "http://localhost:8080", + host: "localhost:8080", + expected: true, + }, + { + origin: "http://localhost:8080", + host: "localhost:8081", + expected: false, + }, + { + origin: "localhost:8080", + host: "localhost:8080", + expected: false, // Gets parsed as host: localhost and path: 8080. + }, + { + origin: "test.org", + host: "localhost:8080", + expected: false, // Parsing fails completely. + }, + ].forEach((test) => { + ;[ + ["host", test.host], + ["x-forwarded-host", test.host], + ["forwarded", `for=127.0.0.1, host=${test.host}, proto=http`], + ["forwarded", `for=127.0.0.1;proto=http;host=${test.host}`], + ["forwarded", `proto=http;host=${test.host}, for=127.0.0.1`], + ].forEach(([key, value]) => { + it(`${test.origin} -> [${key}: ${value}]`, () => { + const req = getMockReq({ + originalUrl: "localhost:8080", + headers: { + origin: test.origin, + [key]: value, + }, + }) + expect(http.authenticateOrigin(req)).toBe(test.expected) + }) + }) }) - const mockQueryParams = { folder: "/Users/jp/dev/coder" } - const mockTo = "" - const actual = constructRedirectPath(mockReq, mockQueryParams, mockTo) - const expected = "./?folder=/Users/jp/dev/coder" - expect(actual).toBe(expected) }) - it("should use an empty string if no query params", () => { - const mockReq = getMockReq({ - originalUrl: "localhost:8080", + + describe("constructRedirectPath", () => { + it("should preserve slashes in queryString so they are human-readable", () => { + const mockReq = getMockReq({ + originalUrl: "localhost:8080", + }) + const mockQueryParams = { folder: "/Users/jp/dev/coder" } + const mockTo = "" + const actual = http.constructRedirectPath(mockReq, mockQueryParams, mockTo) + const expected = "./?folder=/Users/jp/dev/coder" + expect(actual).toBe(expected) }) - const mockQueryParams = {} - const mockTo = "" - const actual = constructRedirectPath(mockReq, mockQueryParams, mockTo) - const expected = "./" - expect(actual).toBe(expected) - }) - it("should append the 'to' path relative to the originalUrl", () => { - const mockReq = getMockReq({ - originalUrl: "localhost:8080", + it("should use an empty string if no query params", () => { + const mockReq = getMockReq({ + originalUrl: "localhost:8080", + }) + const mockQueryParams = {} + const mockTo = "" + const actual = http.constructRedirectPath(mockReq, mockQueryParams, mockTo) + const expected = "./" + expect(actual).toBe(expected) }) - const mockQueryParams = {} - const mockTo = "vscode" - const actual = constructRedirectPath(mockReq, mockQueryParams, mockTo) - const expected = "./vscode" - expect(actual).toBe(expected) - }) - it("should append append queryParams after 'to' path", () => { - const mockReq = getMockReq({ - originalUrl: "localhost:8080", + it("should append the 'to' path relative to the originalUrl", () => { + const mockReq = getMockReq({ + originalUrl: "localhost:8080", + }) + const mockQueryParams = {} + const mockTo = "vscode" + const actual = http.constructRedirectPath(mockReq, mockQueryParams, mockTo) + const expected = "./vscode" + expect(actual).toBe(expected) + }) + it("should append append queryParams after 'to' path", () => { + const mockReq = getMockReq({ + originalUrl: "localhost:8080", + }) + const mockQueryParams = { folder: "/Users/jp/dev/coder" } + const mockTo = "vscode" + const actual = http.constructRedirectPath(mockReq, mockQueryParams, mockTo) + const expected = "./vscode?folder=/Users/jp/dev/coder" + expect(actual).toBe(expected) }) - const mockQueryParams = { folder: "/Users/jp/dev/coder" } - const mockTo = "vscode" - const actual = constructRedirectPath(mockReq, mockQueryParams, mockTo) - const expected = "./vscode?folder=/Users/jp/dev/coder" - expect(actual).toBe(expected) }) }) diff --git a/test/unit/node/proxy.test.ts b/test/unit/node/proxy.test.ts index 55ea4367274a..9502c9429cd1 100644 --- a/test/unit/node/proxy.test.ts +++ b/test/unit/node/proxy.test.ts @@ -4,21 +4,26 @@ import * as http from "http" import nodeFetch from "node-fetch" import { HttpCode } from "../../../src/common/http" import { proxy } from "../../../src/node/proxy" -import { getAvailablePort } from "../../utils/helpers" +import { wss, Router as WsRouter } from "../../../src/node/wsRouter" +import { getAvailablePort, mockLogger } from "../../utils/helpers" import * as httpserver from "../../utils/httpserver" import * as integration from "../../utils/integration" describe("proxy", () => { const nhooyrDevServer = new httpserver.HttpServer() + const wsApp = express.default() + const wsRouter = WsRouter() let codeServer: httpserver.HttpServer | undefined let proxyPath: string let absProxyPath: string let e: express.Express beforeAll(async () => { + wsApp.use("/", wsRouter.router) await nhooyrDevServer.listen((req, res) => { e(req, res) }) + nhooyrDevServer.listenUpgrade(wsApp) proxyPath = `/proxy/${nhooyrDevServer.port()}/wsup` absProxyPath = proxyPath.replace("/proxy/", "/absproxy/") }) @@ -29,6 +34,7 @@ describe("proxy", () => { beforeEach(() => { e = express.default() + mockLogger() }) afterEach(async () => { @@ -36,6 +42,7 @@ describe("proxy", () => { await codeServer.dispose() codeServer = undefined } + jest.clearAllMocks() }) it("should rewrite the base path", async () => { @@ -151,6 +158,35 @@ describe("proxy", () => { expect(resp.status).toBe(500) expect(resp.statusText).toMatch("Internal Server Error") }) + + it("should pass origin check", async () => { + wsRouter.ws("/wsup", async (req) => { + wss.handleUpgrade(req, req.ws, req.head, (ws) => { + ws.send("hello") + req.ws.resume() + }) + }) + codeServer = await integration.setup(["--auth=none"], "") + const ws = await codeServer.wsWait(proxyPath, { + headers: { + host: "localhost:8080", + origin: "https://localhost:8080", + }, + }) + ws.terminate() + }) + + it("should fail origin check", async () => { + await expect(async () => { + codeServer = await integration.setup(["--auth=none"], "") + await codeServer.wsWait(proxyPath, { + headers: { + host: "localhost:8080", + origin: "https://evil.org", + }, + }) + }).rejects.toThrow() + }) }) // NOTE@jsjoeio @@ -190,18 +226,18 @@ describe("proxy (standalone)", () => { }) // Start both servers - await proxyTarget.listen(PROXY_PORT) - await testServer.listen(PORT) + proxyTarget.listen(PROXY_PORT) + testServer.listen(PORT) }) afterEach(async () => { - await testServer.close() - await proxyTarget.close() + testServer.close() + proxyTarget.close() }) it("should return a 500 when proxy target errors ", async () => { // Close the proxy target so that proxy errors - await proxyTarget.close() + proxyTarget.close() const errorResp = await nodeFetch(`${URL}/error`) expect(errorResp.status).toBe(HttpCode.ServerError) expect(errorResp.statusText).toBe("Internal Server Error") diff --git a/test/unit/node/routes/health.test.ts b/test/unit/node/routes/health.test.ts index 77dd6a942235..5f56b4a1ffac 100644 --- a/test/unit/node/routes/health.test.ts +++ b/test/unit/node/routes/health.test.ts @@ -23,7 +23,9 @@ describe("health", () => { codeServer = await integration.setup(["--auth=none"], "") const ws = codeServer.ws("/healthz") const message = await new Promise((resolve, reject) => { - ws.on("error", console.error) + ws.on("error", (err) => { + console.error("[healthz]", err) + }) ws.on("message", (message) => { try { const j = JSON.parse(message.toString()) diff --git a/test/unit/node/routes/vscode.test.ts b/test/unit/node/routes/vscode.test.ts new file mode 100644 index 000000000000..ac6fb36daf11 --- /dev/null +++ b/test/unit/node/routes/vscode.test.ts @@ -0,0 +1,30 @@ +import * as httpserver from "../../../utils/httpserver" +import * as integration from "../../../utils/integration" +import { mockLogger } from "../../../utils/helpers" + +describe("vscode", () => { + let codeServer: httpserver.HttpServer | undefined + beforeEach(() => { + mockLogger() + }) + + afterEach(async () => { + if (codeServer) { + await codeServer.dispose() + codeServer = undefined + } + jest.clearAllMocks() + }) + + it("should fail origin check", async () => { + await expect(async () => { + codeServer = await integration.setup(["--auth=none"], "") + await codeServer.wsWait("/vscode", { + headers: { + host: "localhost:8080", + origin: "https://evil.org", + }, + }) + }).rejects.toThrow() + }) +}) diff --git a/test/unit/node/util.test.ts b/test/unit/node/util.test.ts index e386676731a2..dcb790209f4e 100644 --- a/test/unit/node/util.test.ts +++ b/test/unit/node/util.test.ts @@ -601,3 +601,41 @@ describe("constructOpenOptions", () => { expect(urlSearch).toBe("?q=^&test") }) }) + +describe("splitOnFirstEquals", () => { + const tests = [ + { + name: "empty", + key: "", + value: "", + }, + { + name: "split on first equals", + key: "foo", + value: "bar", + }, + { + name: "split on first equals even with multiple equals", + key: "foo", + value: "bar=baz", + }, + { + name: "split with empty value", + key: "foo", + value: "", + }, + { + name: "split with no value", + key: "foo", + value: undefined, + }, + ] + tests.forEach((test) => { + it("should ${test.name}", () => { + const input = test.key && typeof test.value !== "undefined" ? `${test.key}=${test.value}` : test.key + const [key, value] = util.splitOnFirstEquals(input) + expect(key).toStrictEqual(test.key) + expect(value).toStrictEqual(test.value || undefined) + }) + }) +}) diff --git a/test/utils/httpserver.ts b/test/utils/httpserver.ts index 53d43de9a81d..595ed348078c 100644 --- a/test/utils/httpserver.ts +++ b/test/utils/httpserver.ts @@ -7,7 +7,6 @@ import { Disposable } from "../../src/common/emitter" import * as util from "../../src/common/util" import { ensureAddress } from "../../src/node/app" import { disposer } from "../../src/node/http" - import { handleUpgrade } from "../../src/node/wsRouter" // Perhaps an abstraction similar to this should be used in app.ts as well. @@ -76,14 +75,25 @@ export class HttpServer { /** * Open a websocket against the request path. */ - public ws(requestPath: string): Websocket { + public ws(requestPath: string, options?: Websocket.ClientOptions): Websocket { const address = ensureAddress(this.hs, "ws") if (typeof address === "string") { throw new Error("Cannot open websocket to socket path") } address.pathname = requestPath - return new Websocket(address.toString()) + return new Websocket(address.toString(), options) + } + + /** + * Open a websocket and wait for it to fully open. + */ + public wsWait(requestPath: string, options?: Websocket.ClientOptions): Promise { + const ws = this.ws(requestPath, options) + return new Promise((resolve, reject) => { + ws.on("error", (err) => reject(err)) + ws.on("open", () => resolve(ws)) + }) } public port(): number {