CVE-2023-26114 (published 2023-03-23T05:15Z)

Versions of the package code-server before 4.10.1 are vulnerable to Missing Origin Validation in WebSockets handshakes. Exploiting this vulnerability can allow an adversary in specific scenarios to access data from and connect to the code-server instance.

CVSS v3 base score: 9.3, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N)

Weakness information:
cwe_id: CWE-346
cwe_name: Origin Validation Error
description: The product does not properly verify that the source of data or communication is valid.
extended_description: 
            
            
            
         
         

Fix information:
repo_url: https://github.com/coder/code-server
commit_details: repo_url: https://github.com/coder/code-server
author: Asher
author_date: 2023-03-03 09:12:34+00:00
msg: 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
repository: repo_url: https://github.com/coder/code-server
repo_name: coder/code-server
description: VS Code in the browser
repo_language: TypeScript
owner: coder
file_changes: filename: CHANGELOG.md
change_type: ModificationType.MODIFY
diff: @@ -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

programming_language: Markdown
filename: http.ts
change_type: ModificationType.MODIFY
diff: @@ -4,6 +4,7 @@ export enum HttpCode {
   NotFound = 404,
   BadRequest = 400,
   Unauthorized = 401,
+  Forbidden = 403,
   LargePayload = 413,
   ServerError = 500,
 }

programming_language: TypeScript
filename: cli.ts
change_type: ModificationType.MODIFY
diff: @@ -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<Options<Required<UserProvidedAr
   })
 }
 
-export function splitOnFirstEquals(str: string): string[] {
-  // we use regex instead of "=" to ensure we split at the first
-  // "=" and return the following substring with it
-  // important for the hashed-password which looks like this
-  // $argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY
-  // 2 means return two items
-  // Source: https://stackoverflow.com/a/4607799/3015595
-  // We use the ? to say the the substr after the = is optional
-  const split = str.split(/=(.+)?/, 2)
-
-  return split
-}
-
 /**
  * Parse arguments into UserProvidedArgs.  This should not go beyond checking
  * that arguments are valid types and have values when required.

programming_language: TypeScript
filename: http.ts
change_type: ModificationType.MODIFY
diff: @@ -12,7 +12,15 @@ import { version as codeServerVersion } from "./constants"
 import { Heart } from "./heart"
 import { CoderSettings, SettingsProvider } from "./settings"
 import { UpdateProvider } from "./update"
-import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString, escapeHtml, escapeJSON } from "./util"
+import {
+  getPasswordMethod,
+  IsCookieValidArgs,
+  isCookieValid,
+  sanitizeString,
+  escapeHtml,
+  escapeJSON,
+  splitOnFirstEquals,
+} from "./util"
 
 /**
  * Base options included on every page.
@@ -308,3 +316,68 @@ export const getCookieOptions = (req: express.Request): express.CookieOptions =>
 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()
+}

programming_language: TypeScript
filename: domainProxy.ts
change_type: ModificationType.MODIFY
diff: @@ -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}`,

programming_language: TypeScript
filename: errors.ts
change_type: ModificationType.MODIFY
diff: @@ -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`)
 }

programming_language: TypeScript
filename: pathProxy.ts
change_type: ModificationType.MODIFY
diff: @@ -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<void> {
+  ensureOrigin(req)
   await ensureAuthenticated(req)
   _proxy.ws(req, req.ws, req.head, {
     ignorePath: true,

programming_language: TypeScript
filename: vscode.ts
change_type: ModificationType.MODIFY
diff: @@ -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() {

programming_language: TypeScript
filename: util.ts
change_type: ModificationType.MODIFY
diff: @@ -541,3 +541,13 @@ export const loadAMDModule = async <T>(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]]
+}

programming_language: TypeScript
filename: wsRouter.ts
change_type: ModificationType.MODIFY
diff: @@ -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(

programming_language: TypeScript
filename: cli.test.ts
change_type: ModificationType.MODIFY
diff: @@ -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 = {}

programming_language: JavaScript
filename: http.test.ts
change_type: ModificationType.MODIFY
diff: @@ -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)
   })
 })

programming_language: TypeScript
filename: proxy.test.ts
change_type: ModificationType.MODIFY
diff: @@ -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")

programming_language: JavaScript
filename: health.test.ts
change_type: ModificationType.MODIFY
diff: @@ -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())

programming_language: TypeScript
filename: vscode.test.ts
change_type: ModificationType.ADD
diff: @@ -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()
+  })
+})

programming_language: TypeScript
filename: util.test.ts
change_type: ModificationType.MODIFY
diff: @@ -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)
+    })
+  })
+})

programming_language: TypeScript
filename: httpserver.ts
change_type: ModificationType.MODIFY
diff: @@ -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<Websocket> {
+    const ws = this.ws(requestPath, options)
+    return new Promise<Websocket>((resolve, reject) => {
+      ws.on("error", (err) => reject(err))
+      ws.on("open", () => resolve(ws))
+    })
   }
 
   public port(): number {

programming_language: TypeScript