Files
code-server/test/unit/node/proxy.test.ts
T
Asher cc7388533d Strip cookie from web socket requests (#8111)
This matches what we already do for regular HTTP requests.
2026-11-04 12:59:36 -09:00

358 lines
11 KiB
TypeScript

import * as express from "express"
import { HttpCode } from "../../../src/common/http"
import { wss, Router as WsRouter } from "../../../src/node/wsRouter"
import { mockLogger } from "../../utils/helpers"
import * as httpserver from "../../utils/httpserver"
import * as integration from "../../utils/integration"
describe("proxy", () => {
const proxyTarget = 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 proxyTarget.listen((req, res) => {
e(req, res)
})
proxyTarget.listenUpgrade(wsApp)
proxyPath = `/proxy/${proxyTarget.port()}/wsup`
absProxyPath = proxyPath.replace("/proxy/", "/absproxy/")
})
afterAll(async () => {
await proxyTarget.dispose()
})
beforeEach(() => {
e = express.default()
mockLogger()
delete process.env.PASSWORD
})
afterEach(async () => {
if (codeServer) {
await codeServer.dispose()
codeServer = undefined
}
jest.clearAllMocks()
})
it("should return 403 Forbidden if proxy is disabled", async () => {
e.get("/wsup", (req, res) => {
res.json("you cannot see this")
})
codeServer = await integration.setup(["--auth=none", "--disable-proxy"], "")
const resp = await codeServer.fetch(proxyPath)
expect(resp.status).toBe(403)
const json = await resp.json()
expect(json).toEqual({ error: "Forbidden" })
})
it("should rewrite the base path", async () => {
e.get("/wsup", (req, res) => {
res.json("asher is the best")
})
codeServer = await integration.setup(["--auth=none"], "")
const resp = await codeServer.fetch(proxyPath)
expect(resp.status).toBe(200)
const json = await resp.json()
expect(json).toBe("asher is the best")
})
it("should not rewrite the base path", async () => {
e.get(absProxyPath, (req, res) => {
res.json("joe is the best")
})
codeServer = await integration.setup(["--auth=none"], "")
const resp = await codeServer.fetch(absProxyPath)
expect(resp.status).toBe(200)
const json = await resp.json()
expect(json).toBe("joe is the best")
})
it("should rewrite redirects", async () => {
e.post("/wsup", (req, res) => {
res.redirect(307, "/finale")
})
e.post("/finale", (req, res) => {
res.json("redirect success")
})
codeServer = await integration.setup(["--auth=none"], "")
const resp = await codeServer.fetch(proxyPath, {
method: "POST",
})
expect(resp.status).toBe(200)
expect(await resp.json()).toBe("redirect success")
})
it("should not rewrite redirects", async () => {
const finalePath = absProxyPath.replace("/wsup", "/finale")
e.post(absProxyPath, (req, res) => {
res.redirect(307, finalePath)
})
e.post(finalePath, (req, res) => {
res.json("redirect success")
})
codeServer = await integration.setup(["--auth=none"], "")
const resp = await codeServer.fetch(absProxyPath, {
method: "POST",
})
expect(resp.status).toBe(200)
expect(await resp.json()).toBe("redirect success")
})
it("should allow post bodies", async () => {
e.use(express.json({ strict: false }))
e.post("/wsup", (req, res) => {
res.json(req.body)
})
codeServer = await integration.setup(["--auth=none"], "")
const resp = await codeServer.fetch(proxyPath, {
method: "post",
body: JSON.stringify("coder is the best"),
headers: {
"Content-Type": "application/json",
},
})
expect(resp.status).toBe(200)
expect(await resp.json()).toBe("coder is the best")
})
it("should handle bad requests", async () => {
e.use(express.json({ strict: false }))
e.post("/wsup", (req, res) => {
res.json(req.body)
})
codeServer = await integration.setup(["--auth=none"], "")
const resp = await codeServer.fetch(proxyPath, {
method: "post",
body: "coder is the best",
headers: {
"Content-Type": "application/json",
},
})
expect(resp.status).toBe(400)
expect(resp.statusText).toMatch("Bad Request")
})
it("should handle invalid routes", async () => {
e.post("/wsup", (req, res) => {
res.json(req.body)
})
codeServer = await integration.setup(["--auth=none"], "")
const resp = await codeServer.fetch(`${proxyPath}/hello`)
expect(resp.status).toBe(404)
expect(resp.statusText).toMatch("Not Found")
})
it("should handle errors", async () => {
e.use(express.json({ strict: false }))
e.post("/wsup", (req, res) => {
throw new Error("BROKEN")
})
codeServer = await integration.setup(["--auth=none"], "")
const resp = await codeServer.fetch(proxyPath, {
method: "post",
body: JSON.stringify("coder is the best"),
headers: {
"Content-Type": "application/json",
},
})
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()
})
it("should proxy non-ASCII", async () => {
e.get(/.*/, (req, res) => {
res.json("ほげ")
})
codeServer = await integration.setup(["--auth=none"], "")
const resp = await codeServer.fetch(proxyPath.replace("wsup", "ほげ"))
expect(resp.status).toBe(200)
const json = await resp.json()
expect(json).toBe("ほげ")
})
it("should not double-encode query variables", async () => {
const spy = jest.fn()
e.get(/.*/, (req, res) => {
spy([req.originalUrl, req.query])
res.end()
})
codeServer = await integration.setup(["--auth=none"], "")
for (const test of [
{
endpoint: proxyPath,
query: { foo: "bar with spaces" },
expected: "/wsup?foo=bar+with+spaces",
},
{
endpoint: absProxyPath,
query: { foo: "bar with spaces" },
expected: absProxyPath + "?foo=bar+with+spaces",
},
{
endpoint: proxyPath,
query: { foo: "with-&-ampersand" },
expected: "/wsup?foo=with-%26-ampersand",
},
{
endpoint: absProxyPath,
query: { foo: "with-&-ampersand" },
expected: absProxyPath + "?foo=with-%26-ampersand",
},
{
endpoint: absProxyPath,
query: { foo: "ほげ ほげ" },
expected: absProxyPath + "?foo=%E3%81%BB%E3%81%92+%E3%81%BB%E3%81%92",
},
{
endpoint: proxyPath,
query: { foo: "ほげ ほげ" },
expected: "/wsup?foo=%E3%81%BB%E3%81%92+%E3%81%BB%E3%81%92",
},
]) {
spy.mockClear()
const resp = await codeServer.fetch(test.endpoint, undefined, test.query)
expect(resp.status).toBe(200)
await resp.text()
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith([test.expected, test.query])
}
})
it("should allow specifying an absproxy path", async () => {
const prefixedPath = `/codeserver/app1${absProxyPath}`
e.get(prefixedPath, (req, res) => {
res.send("app being served behind a prefixed path")
})
codeServer = await integration.setup(["--auth=none", "--abs-proxy-base-path=/codeserver/app1"], "")
const resp = await codeServer.fetch(absProxyPath)
expect(resp.status).toBe(200)
const text = await resp.text()
expect(text).toBe("app being served behind a prefixed path")
})
it("should not allow OPTIONS without authentication by default", async () => {
process.env.PASSWORD = "test"
codeServer = await integration.setup(["--auth=password"])
const resp = await codeServer.fetch(proxyPath, { method: "OPTIONS" })
expect(resp.status).toBe(401)
})
it("should allow OPTIONS with `skip-auth-preflight` flag", async () => {
process.env.PASSWORD = "test"
codeServer = await integration.setup(["--auth=password", "--skip-auth-preflight"])
e.post("/wsup", (req, res) => {})
const resp = await codeServer.fetch(proxyPath, { method: "OPTIONS" })
expect(resp.status).toBe(200)
})
it("should return a 500 when no target is running ", async () => {
const target = new httpserver.HttpServer()
await target.listen(() => {})
const port = target.port()
target.dispose()
codeServer = await integration.setup(["--auth=none"], "")
const resp = await codeServer.fetch(`/proxy/${port}/wsup`)
expect(resp.status).toBe(HttpCode.ServerError)
expect(resp.statusText).toBe("Internal Server Error")
})
it("should strip token cookie", async () => {
const token = "my-super-secure-token"
process.env.HASHED_PASSWORD = token
codeServer = await integration.setup(["--auth=password"])
e.get("/wsup/cookies", (req, res) => {
res.writeHead(HttpCode.Ok, { "Content-Type": "text/plain" })
res.end(req.headers.cookie)
})
wsRouter.ws("/wsup/cookies", async (req) => {
wss.handleUpgrade(req, req.ws, req.head, (ws) => {
ws.on("message", () => ws.send(req.headers.cookie || ""))
req.ws.resume()
})
})
const cookies = [
"cookie2=hello",
// Cookies should pass through unchanged, neither encoded nor decoded.
`cookie1=${encodeURIComponent("hello=there")}`,
"cookie3=foo|bar",
`cookie4=${encodeURIComponent("foo|bar")}`,
`cookie5=${encodeURIComponent("bar;baz")}`,
]
// Test each slot to ensure the token is found anywhere.
for (let i = 0; i <= cookies.length; ++i) {
const left = cookies.slice(0, i)
const right = cookies.slice(i)
const expected = cookies.join("; ")
const headers = {
cookie: [...left, `code-server-session=${token}`, ...right].join("; "),
}
// Regular HTTP request.
const resp = await codeServer.fetch(proxyPath + "/cookies", { headers })
expect(resp.status).toBe(200)
expect(await resp.text()).toBe(expected)
// Web socket request.
const ws = await codeServer.wsWait(proxyPath + "/cookies", { headers })
const got = await new Promise((resolve) => {
ws.send("init")
ws.on("message", (msg) => resolve(msg))
})
ws.terminate()
expect(got).toBe(expected)
}
})
it("should proxy when no cookies", async () => {
codeServer = await integration.setup(["--auth=none"])
e.get("/wsup/cookies", (req, res) => {
res.writeHead(HttpCode.Ok, { "Content-Type": "text/plain" })
res.end(req.headers.cookie || "no cookies")
})
const resp = await codeServer.fetch(proxyPath + "/cookies")
expect(resp.status).toBe(200)
expect(await resp.text()).toBe("no cookies")
})
})