diff --git a/CHANGELOG.md b/CHANGELOG.md index a485bd6b8..82d108e31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ Code v99.99.999 ## Unreleased +### Fixed + +- When proxying, cookies were being decoded and re-encoded, which could cause + issues for applications that encode differently. Cookies are now passed + through unchanged (aside from stripping out code-server's session token). + ## [4.131.0](https://github.com/coder/code-server/releases/tag/v4.131.0) - 2026-07-30 Code v1.131.0 diff --git a/src/node/proxy.ts b/src/node/proxy.ts index 8282729cc..2d9056996 100644 --- a/src/node/proxy.ts +++ b/src/node/proxy.ts @@ -20,17 +20,31 @@ proxy.on("error", (error, _, res) => { } }) +function identity(val: T): T { + return val +} + // Strip the code-server cookie if it exists to avoid transmitting the cookie // to potentially malicious local ports. proxy.on("proxyReq", (preq, req) => { - const cookieSessionName = getCookieSessionName((req as Request).args["cookie-suffix"]) - preq.setHeader( - "Cookie", - cookie.stringifyCookie({ - ...(req as Request).cookies, - [cookieSessionName]: undefined, - }), - ) + if (req.headers.cookie) { + const cookieSessionName = getCookieSessionName((req as Request).args["cookie-suffix"]) + // Encoding and decoding are no-ops; we just want to remove the token + // without changing anything else about the cookies because not all + // applications encode/decode the same way `cookie` here does. + preq.setHeader( + "Cookie", + cookie.stringifyCookie( + { + ...cookie.parseCookie(req.headers.cookie, { decode: identity }), + [cookieSessionName]: undefined, + }, + { + encode: identity, + }, + ), + ) + } }) // Intercept the response to rewrite absolute redirects against the base path. diff --git a/test/unit/node/proxy.test.ts b/test/unit/node/proxy.test.ts index 94945cb94..4c3320a07 100644 --- a/test/unit/node/proxy.test.ts +++ b/test/unit/node/proxy.test.ts @@ -298,25 +298,44 @@ describe("proxy", () => { process.env.HASHED_PASSWORD = token codeServer = await integration.setup(["--auth=password"]) - // Set up a listener that just prints the cookies it got. e.get("/wsup/cookies", (req, res) => { res.writeHead(HttpCode.Ok, { "Content-Type": "text/plain" }) res.end(req.headers.cookie) }) - // Send the token along with other cookies which should be preserved. - // Encode one to make sure they are being re-encoded properly. - const value = "hello=there" - const encodedValue = encodeURIComponent(value) - const resp = await codeServer.fetch(proxyPath + "/cookies", { - headers: { - cookie: `cookie1=${encodedValue}; code-server-session=${token}; cookie2=hello;`, - }, + 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") }) - // The proxied listener should not have printed the code-server token. + const resp = await codeServer.fetch(proxyPath + "/cookies") expect(resp.status).toBe(200) - const text = await resp.text() - expect(text).toBe(`cookie1=${encodedValue}; cookie2=hello`) + expect(await resp.text()).toBe("no cookies") }) })