From 208c81a07236372a145913138d8fe56e920270b5 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 6 Aug 2026 12:26:01 -0800 Subject: [PATCH] Fix mistakenly encoding cookies to proxy 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. --- CHANGELOG.md | 6 +++++ src/node/proxy.ts | 30 ++++++++++++++++++------- test/unit/node/proxy.test.ts | 43 ++++++++++++++++++++++++++---------- 3 files changed, 59 insertions(+), 20 deletions(-) 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") }) })