CVE-2023-26114 (TypeScript, published 2023-03-23T05:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: 9.3)
Weakness: CWE-346 \u2014 Origin Validation Error
The product does not properly verify that the source of data or communication is valid.
Repository: https://github.com/coder/code-server
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 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

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