From ef5eba4348e89dc0d036d9ccfa528cacef034ca2 Mon Sep 17 00:00:00 2001 From: Limbo Date: Fri, 18 Sep 2026 04:41:01 +0800 Subject: [PATCH] Preserve path, query, and fragment in proxy URL rewrite (#8012) --- CHANGELOG.md | 5 +++ patches/proxy-uri.diff | 19 +++++++++--- test/e2e/extensions.test.ts | 61 +++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9a3585ef..aefb9e997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ Code v99.99.999 ## Unreleased +### Fixed + +- Preserve the original path, query parameters, and fragment when rewriting + localhost URLs through the port proxy (#7668). + ## [4.137.0](https://github.com/coder/code-server/releases/tag/v4.137.0) - 2026-09-11 Code v1.137.0 diff --git a/patches/proxy-uri.diff b/patches/proxy-uri.diff index 6bda9e885..fffa19459 100644 --- a/patches/proxy-uri.diff +++ b/patches/proxy-uri.diff @@ -13,12 +13,18 @@ This has e2e tests. For the `asExternalUri` changes, you'll need to test manually by: 1. running code-server with the test extension 2. Command Palette > code-server: asExternalUri test -3. input a url like http://localhost:3000 -4. it should show a notification and show output as /proxy/3000 +3. input a url like http://localhost:3000/my/path?token=abc#section +4. it should show a notification and show output as + /proxy/3000/my/path?token%3Dabc#section Do the same thing but set `VSCODE_PROXY_URI: "https://{{port}}-main-workspace-name-user-name.coder.com"` and the output should replace `{{port}}` with port used in input url. +The rewritten URI must preserve the original path, query, and fragment (see +#7668). Append the original path to the proxy path using URI components so +encoded characters are not decoded or encoded twice. This also works with +proxy templates that do not have a trailing slash. + This also enables the forwared ports view panel by default. Lastly, it adds a tunnelProvider so that ports are forwarded using code-server's @@ -104,7 +110,7 @@ Index: code-server/lib/vscode/src/vs/code/browser/workbench/workbench.ts import type { IURLCallbackProvider } from '../../../workbench/services/url/browser/urlService.js'; import { create } from '../../../workbench/workbench.web.main.internal.js'; -@@ -612,6 +613,39 @@ class WorkspaceProvider implements IWork +@@ -612,6 +613,44 @@ class WorkspaceProvider implements IWork settingsSyncOptions: config.settingsSyncOptions ? { enabled: config.settingsSyncOptions.enabled, } : undefined, workspaceProvider: WorkspaceProvider.create(config), urlCallbackProvider: new LocalStorageURLCallbackProvider(config.callbackRoute), @@ -116,7 +122,12 @@ Index: code-server/lib/vscode/src/vs/code/browser/workbench/workbench.ts + const renderedTemplate = config.productConfiguration.proxyEndpointTemplate + .replace('{{port}}', localhostMatch.port.toString()) + .replace('{{host}}', window.location.host) -+ resolvedUri = URI.parse(new URL(renderedTemplate, window.location.href).toString()) ++ const proxyUri = URI.parse(new URL(renderedTemplate, window.location.href).toString()) ++ resolvedUri = proxyUri.with({ ++ path: proxyUri.path.replace(/\/$/, '') + uri.path, ++ query: uri.query, ++ fragment: uri.fragment, ++ }) + } else { + throw new Error(`Failed to resolve external URI: ${uri.toString()}. Could not determine base url because productConfiguration missing.`) + } diff --git a/test/e2e/extensions.test.ts b/test/e2e/extensions.test.ts index 9861e0004..70869c03c 100644 --- a/test/e2e/extensions.test.ts +++ b/test/e2e/extensions.test.ts @@ -15,6 +15,56 @@ function runTestExtensionTests() { const normalizedAddress = address.replace(/\/+$/, "") await expect(codeServerPage.page.getByText(`Info: proxyUri: ${normalizedAddress}/proxy/{{port}}/`)).toBeVisible() }) + + runExternalUriTests() +} + +function runExternalUriTests(proxyEndpointTemplate?: string) { + const cases = [ + { + name: "path", + input: "http://127.0.0.1:1234/my/path", + suffix: "/my/path", + }, + { + name: "query and fragment", + input: "http://localhost:1234/my/path?token=abc&mode=preview#section", + suffix: "/my/path?token%3Dabc%26mode%3Dpreview#section", + }, + { + name: "encoded components", + input: "http://0.0.0.0:1234/my%20path/%23file?token=a%20b#my%20section", + suffix: "/my%20path/%23file?token%3Da%20b#my%20section", + }, + { + name: "root path", + input: "http://127.0.0.1:1234/", + suffix: "/", + }, + ] + + for (const { name, input, suffix } of cases) { + test(`asExternalUri should preserve ${name}`, async ({ codeServerPage }) => { + const address = await getMaybeProxiedCodeServer(codeServerPage) + const normalizedAddress = address.replace(/\/+$/, "") + const proxyBase = proxyEndpointTemplate + ? new URL(proxyEndpointTemplate.replace("{{port}}", "1234"), `${normalizedAddress}/`).toString() + : `${normalizedAddress}/proxy/1234/` + + await codeServerPage.waitForTestExtensionLoaded() + await codeServerPage.executeCommandViaMenus("code-server: asExternalUri test") + + const inputBox = codeServerPage.page.locator(".quick-input-widget input") + await inputBox.fill(input) + await inputBox.press("Enter") + + // The test extension displays URI.toString(), which also encodes query delimiters. + const output = `${proxyBase.replace(/\/$/, "")}${suffix}` + await expect( + codeServerPage.page.getByText(`Info: input: ${input} output: ${output}`, { exact: true }), + ).toBeVisible() + }) + } } const flags = ["--disable-workspace-trust", "--extensions-dir", path.join(__dirname, "./extensions")] @@ -23,6 +73,17 @@ describe("Extensions", flags, {}, () => { runTestExtensionTests() }) +for (const proxyEndpointTemplate of ["./proxy/{{port}}", "https://{{port}}-workspace.example.com"]) { + describe( + `Extensions with VSCODE_PROXY_URI=${proxyEndpointTemplate}`, + flags, + { VSCODE_PROXY_URI: proxyEndpointTemplate }, + () => { + runExternalUriTests(proxyEndpointTemplate) + }, + ) +} + if (process.env.USE_PROXY !== "1") { describe("Extensions with --cert", [...flags, "--cert"], {}, () => { runTestExtensionTests()