mirror of
https://github.com/coder/code-server.git
synced 2026-09-02 08:30:17 +02:00
We use the cookie parser to remove the code-server token but by default it encodes using encodeURIComponent, which encodes more than is strictly necessary and can break proxied applications. Now we pass the cookies through unchanged (other than removing the code-server token). Fixes #7927.
342 lines
11 KiB
TypeScript
342 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)
|
|
})
|
|
|
|
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 resp = await codeServer.fetch(proxyPath + "/cookies", {
|
|
headers: {
|
|
cookie: [...left, `code-server-session=${token}`, ...right].join("; "),
|
|
},
|
|
})
|
|
expect(resp.status).toBe(200)
|
|
expect(await resp.text()).toBe(cookies.join("; "))
|
|
}
|
|
})
|
|
|
|
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")
|
|
})
|
|
})
|