CVE-2023-26114: Origin Validation Error (CWE-346)
value: 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.
CWE description: The product does not properly verify that the source of data or communication is valid.
CVSS v3 base score: 9.3
Published: 2023-03-23T05:15Z
Repository: https://github.com/coder/code-server
Language: TypeScript
Files changed: CHANGELOG.md, src/common/http.ts, src/node/cli.ts, src/node/http.ts, src/node/routes/domainProxy.ts, src/node/routes/errors.ts, src/node/routes/pathProxy.ts, src/node/routes/vscode.ts, src/node/util.ts, src/node/wsRouter.ts, test/unit/node/cli.test.ts, test/unit/node/http.test.ts, test/unit/node/proxy.test.ts, test/unit/node/routes/health.test.ts, test/unit/node/routes/vscode.test.ts, test/unit/node/util.test.ts, test/utils/httpserver.ts
Commit date: 2023-03-03 03:12:34 -0600
Commit message: 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
Diff: diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36bd1484..9663b9cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,10 +18,22 @@ Code v99.99.999
 ### Fixed
 ### Security
 
 -->
 
+## 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
 
 ### Changed
diff --git a/src/common/http.ts b/src/common/http.ts
index 5709c455..4235df17 100644
--- a/src/common/http.ts
+++ b/src/common/http.ts
@@ -2,10 +2,11 @@ export enum HttpCode {
   Ok = 200,
   Redirect = 302,
   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 b8498c1e..75e4ad20 100644
--- a/src/node/cli.ts
+++ b/src/node/cli.ts
@@ -1,11 +1,19 @@
 import { field, Level, logger } from "@coder/logger"
 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")
 
 export enum Feature {
   // No current experimental features!
@@ -290,23 +298,10 @@ export const optionDescriptions = (opts: Partial<Options<Required<UserProvidedAr
       (typeof v.type === "object" ? ` [${Object.values(v.type).join(", ")}]` : "")
     )
   })
 }
 
-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.
  */
 export const parse = (
diff --git a/src/node/http.ts b/src/node/http.ts
index fa9a8425..33575c0e 100644
--- a/src/node/http.ts
+++ b/src/node/http.ts
@@ -10,11 +10,19 @@ import { normalize } from "../common/util"
 import { AuthType, DefaultedArgs } from "./cli"
 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.
  */
 export interface ClientConfiguration {
@@ -306,5 +314,70 @@ export const getCookieOptions = (req: express.Request): express.CookieOptions =>
  * Return the full path to the current page, preserving any trailing slash.
  */
 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 83194b8c..3d8273c4 100644
--- a/src/node/routes/domainProxy.ts
+++ b/src/node/routes/domainProxy.ts
@@ -1,8 +1,8 @@
 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"
 
 export const router = Router()
 
@@ -76,14 +76,12 @@ export const wsRouter = WsRouter()
 wsRouter.ws("*", async (req, _, next) => {
   const port = maybeProxy(req)
   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 9b5fdae9..712bba60 100644
--- a/src/node/routes/errors.ts
+++ b/src/node/routes/errors.ts
@@ -61,7 +61,13 @@ 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 e21b849e..f574a449 100644
--- a/src/node/routes/pathProxy.ts
+++ b/src/node/routes/pathProxy.ts
@@ -1,11 +1,11 @@
 import { Request, Response } from "express"
 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 => {
   if (passthroughPath) {
     return `http://0.0.0.0:${req.params.port}/${req.originalUrl}`
@@ -48,10 +48,11 @@ export async function wsProxy(
   req: pluginapi.WebsocketRequest,
   opts?: {
     passthroughPath?: boolean
   },
 ): Promise<void> {
+  ensureOrigin(req)
   await ensureAuthenticated(req)
   _proxy.ws(req, req.ws, req.head, {
     ignorePath: true,
     target: getProxyTarget(req, opts?.passthroughPath),
   })
diff --git a/src/node/routes/vscode.ts b/src/node/routes/vscode.ts
index b372531e..1de3e7ce 100644
--- a/src/node/routes/vscode.ts
+++ b/src/node/routes/vscode.ts
@@ -5,11 +5,11 @@ import * as net from "net"
 import * as path from "path"
 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"
 
 /**
@@ -171,11 +171,11 @@ export class CodeServerRouteWrapper {
 
   constructor() {
     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() {
     this._codeServerMain?.dispose()
     this._socketProxyProvider.stop()
diff --git a/src/node/util.ts b/src/node/util.ts
index 698fe604..6abb3805 100644
--- a/src/node/util.ts
+++ b/src/node/util.ts
@@ -539,5 +539,15 @@ export const loadAMDModule = async <T>(amdPath: string, exportName: string): Pro
     require(path.join(vsRootPath, "out/bootstrap-amd")).load(amdPath, resolve, reject)
   })
 
   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 2f6f18ba..67907ecc 100644
--- a/src/node/wsRouter.ts
+++ b/src/node/wsRouter.ts
@@ -30,10 +30,13 @@ export class WebsocketRouter {
   public readonly router = express.Router()
 
   /**
    * 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(
       route,
       ...handlers.map((handler) => {
diff --git a/test/unit/node/cli.test.ts b/test/unit/node/cli.test.ts
index 3bb1024d..7e7fa29a 100644
--- a/test/unit/node/cli.test.ts
+++ b/test/unit/node/cli.test.ts
@@ -9,11 +9,10 @@ import {
   defaultConfigFile,
   parse,
   readSocketPath,
   setDefaults,
   shouldOpenInExistingInstance,
-  splitOnFirstEquals,
   toCodeArgs,
   optionDescriptions,
   options,
   Options,
   AuthType,
@@ -533,35 +532,10 @@ describe("cli", () => {
     args.port = 8081
     expect(await shouldOpenInExistingInstance(args)).toStrictEqual(undefined)
   })
 })
 
-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 = {}
     const actual = await shouldSpawnCliProcess(args)
     const expected = false
diff --git a/test/unit/node/http.test.ts b/test/unit/node/http.test.ts
index b5dc2040..9c4626cc 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 55ea4367..9502c942 100644
--- a/test/unit/node/proxy.test.ts
+++ b/test/unit/node/proxy.test.ts
@@ -2,42 +2,49 @@ import * as bodyParser from "body-parser"
 import * as express from "express"
 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/")
   })
 
   afterAll(async () => {
     await nhooyrDevServer.dispose()
   })
 
   beforeEach(() => {
     e = express.default()
+    mockLogger()
   })
 
   afterEach(async () => {
     if (codeServer) {
       await codeServer.dispose()
       codeServer = undefined
     }
+    jest.clearAllMocks()
   })
 
   it("should rewrite the base path", async () => {
     e.get("/wsup", (req, res) => {
       res.json("asher is the best")
@@ -149,10 +156,39 @@ 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
 // Both this test suite and the one above it are very similar
 // The main difference is this one uses http and node-fetch
@@ -188,22 +224,22 @@ describe("proxy (standalone)", () => {
       res.writeHead(200, { "Content-Type": "text/plain" })
       res.end()
     })
 
     // 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 77dd6a94..5f56b4a1 100644
--- a/test/unit/node/routes/health.test.ts
+++ b/test/unit/node/routes/health.test.ts
@@ -21,11 +21,13 @@ describe("health", () => {
 
   it("/healthz (websocket)", async () => {
     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())
           resolve(j)
         } catch (error) {
diff --git a/test/unit/node/routes/vscode.test.ts b/test/unit/node/routes/vscode.test.ts
new file mode 100644
index 00000000..ac6fb36d
--- /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 e3866767..dcb79020 100644
--- a/test/unit/node/util.test.ts
+++ b/test/unit/node/util.test.ts
@@ -599,5 +599,43 @@ describe("constructOpenOptions", () => {
     expect(args).toStrictEqual(["/c", "start", '""', "/b"])
     expect(command).toBe("cmd.exe")
     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 53d43de9..595ed348 100644
--- a/test/utils/httpserver.ts
+++ b/test/utils/httpserver.ts
@@ -5,11 +5,10 @@ import nodeFetch, { RequestInit, Response } from "node-fetch"
 import Websocket from "ws"
 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.
 export class HttpServer {
   private hs: http.Server
@@ -74,18 +73,29 @@ 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 {
     const addr = this.hs.address()
     if (addr && typeof addr === "object") {

Vulnerable code: import { canConnect, generateCertificate, generatePassword, humanPath, paths, isNodeJSErrnoException } from "./util"
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
}

import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString, escapeHtml, escapeJSON } from "./util"
import { authenticated, ensureAuthenticated, redirect, self } from "../http"

  // Must be authenticated to use the proxy.

  ;(req as WebsocketRequest).ws.end()
import { authenticated, ensureAuthenticated, redirect, self } from "../http"
import { authenticated, ensureAuthenticated, redirect, replaceTemplates, self } from "../http"
    this._wsRouterWrapper.ws("*", ensureAuthenticated, this.ensureCodeServerLoaded, this.$proxyWebsocket)
  splitOnFirstEquals,
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))
  })
})

import { constructRedirectPath, relativeRoot } from "../../../src/node/http"
    expect(relativeRoot("/")).toStrictEqual(".")
    expect(relativeRoot("/foo")).toStrictEqual(".")
    expect(relativeRoot("/foo/")).toStrictEqual("./..")
    expect(relativeRoot("/foo/bar ")).toStrictEqual("./..")
    expect(relativeRoot("/foo/bar/")).toStrictEqual("./../..")
})
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 = 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",
    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",
    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",
    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)
import { getAvailablePort } from "../../utils/helpers"
    await proxyTarget.listen(PROXY_PORT)
    await testServer.listen(PORT)
    await testServer.close()
    await proxyTarget.close()
    await proxyTarget.close()
      ws.on("error", console.error)

  public ws(requestPath: string): Websocket {
    return new Websocket(address.toString())
Fixed code: ## 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.

  Forbidden = 403,
import {
  canConnect,
  generateCertificate,
  generatePassword,
  humanPath,
  paths,
  isNodeJSErrnoException,
  splitOnFirstEquals,
} from "./util"
import {
  getPasswordMethod,
  IsCookieValidArgs,
  isCookieValid,
  sanitizeString,
  escapeHtml,
  escapeJSON,
  splitOnFirstEquals,
} from "./util"

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()
}
import { authenticated, ensureAuthenticated, ensureOrigin, redirect, self } from "../http"
  ensureOrigin(req)
  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`)
import { authenticated, ensureAuthenticated, ensureOrigin, redirect, self } from "../http"
  ensureOrigin(req)
import { authenticated, ensureAuthenticated, ensureOrigin, redirect, replaceTemplates, self } from "../http"
    this._wsRouterWrapper.ws("*", ensureOrigin, ensureAuthenticated, this.ensureCodeServerLoaded, this.$proxyWebsocket)

/**
 * 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]]
}
   *
   * If the origin header exists it must match the host or the connection will
   * be prevented.
import * as http from "../../../src/node/http"
import { mockLogger } from "../../utils/helpers"
  beforeEach(() => {
    mockLogger()
  })

  afterEach(() => {
    jest.clearAllMocks()
  })

    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("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)
        })
      })

  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)
    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)
    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)
import { wss, Router as WsRouter } from "../../../src/node/wsRouter"
import { getAvailablePort, mockLogger } from "../../utils/helpers"
  const wsApp = express.default()
  const wsRouter = WsRouter()
    wsApp.use("/", wsRouter.router)
    nhooyrDevServer.listenUpgrade(wsApp)
    mockLogger()
    jest.clearAllMocks()

  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()
  })
    proxyTarget.listen(PROXY_PORT)
    testServer.listen(PORT)
    testServer.close()
    proxyTarget.close()
    proxyTarget.close()
      ws.on("error", (err) => {
        console.error("[healthz]", err)
      })
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()
  })
})

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)
    })
  })
})
  public ws(requestPath: string, options?: Websocket.ClientOptions): Websocket {
    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))
    })